Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,8 @@ function AccessibilityExample() {
value={value}
onChange={setValue}
options={options}
accessibilityLabel="Select task priority level"
accessibilityLabel="Task priority level options"
controlAccessibilityLabel="Select task priority level"
accessibilityRoles={{
dropdown: 'listbox',
option: 'option',
Expand Down
9 changes: 7 additions & 2 deletions packages/mobile/src/alpha/combobox/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ const ComboboxBase = memo(
variant,
startNode,
endNode,
accessibilityLabel = 'Combobox control',
accessibilityLabel,
defaultOpen,
searchText: searchTextProp,
onSearch: onSearchProp,
Expand Down Expand Up @@ -211,6 +211,8 @@ const ComboboxBase = memo(
'Combobox component must be fully controlled or uncontrolled: "open" and "setOpen" props must be provided together or not at all',
);

const fallbackAccessibilityLabel = typeof label === 'string' ? label : 'Combobox control';

const fuse = useMemo(
() =>
new Fuse(options, {
Expand Down Expand Up @@ -298,6 +300,7 @@ const ComboboxBase = memo(
header={
<Box paddingX={3}>
<ComboboxControl
accessibilityLabel={accessibilityLabel ?? fallbackAccessibilityLabel}
endNode={endNode}
placeholder={placeholder}
startNode={startNode}
Expand All @@ -314,8 +317,10 @@ const ComboboxBase = memo(
[
ComboboxControl,
SelectDropdownComponent,
accessibilityLabel,
closeButtonLabel,
endNode,
fallbackAccessibilityLabel,
handleTrayVisibilityChange,
label,
placeholder,
Expand All @@ -338,7 +343,7 @@ const ComboboxBase = memo(
ref={controlRef}
SelectControlComponent={ComboboxControl}
SelectDropdownComponent={ComboboxDropdown}
accessibilityLabel={accessibilityLabel}
accessibilityLabel={accessibilityLabel ?? fallbackAccessibilityLabel}
defaultOpen={defaultOpen}
disabled={disabled}
endNode={endNode}
Expand Down
12 changes: 11 additions & 1 deletion packages/mobile/src/alpha/combobox/DefaultComboboxControl.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { StyleSheet } from 'react-native';

import { NativeInput } from '../../controls/NativeInput';
Expand Down Expand Up @@ -29,14 +30,24 @@ export const DefaultComboboxControl = <
onSearch,
searchInputRef,
hideSearchInput,
accessibilityLabel,
...props
}: ComboboxControlProps<Type, SelectOptionValue>) => {
const theme = useTheme();
const hasValue = hasSelectedValue(value);
const shouldRenderSearchInput = !hideSearchInput && (!hasValue || open);

const computedAccessibilityLabel = useMemo(() => {
let label = accessibilityLabel;
if (!hasValue && typeof placeholder === 'string') {
label = `${label}, ${placeholder}`;
}
return label;
}, [hasValue, accessibilityLabel, placeholder]);

return (
<SelectControlComponent
accessibilityLabel={computedAccessibilityLabel}
disabled={disabled}
open={open}
options={options}
Expand Down Expand Up @@ -79,7 +90,6 @@ export const DefaultComboboxControl = <
</>
)
}
placeholder={null}
styles={{
...props.styles,
controlEndNode: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,22 @@ const ControlledSearchExample = () => {
);
};

const AccessibilityLabelExample = () => {
const { value, onChange } = useMultiSelect({ initialValue: [] });

return (
<Combobox
accessibilityLabel="Custom accessibility label"
label="Accessible combobox"
onChange={onChange}
options={fruitOptions}
placeholder="Has accessibility label..."
type="multi"
value={value}
/>
);
};

const WithDescriptionsExample = () => {
const { value, onChange } = useMultiSelect({ initialValue: [] });

Expand Down Expand Up @@ -757,6 +773,9 @@ const Default = () => {
<Example title="Controlled search">
<ControlledSearchExample />
</Example>
<Example title="Custom accessibility label">
<AccessibilityLabelExample />
</Example>
<Example title="Options with descriptions">
<WithDescriptionsExample />
</Example>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe('Combobox', () => {
);

const button = screen.getByRole('button');
expect(button.props.accessibilityLabel).toBe('Custom combobox');
expect(button.props.accessibilityLabel).toBe('Custom combobox, Search and select...');
});

it('handles disabled prop', () => {
Expand Down Expand Up @@ -338,7 +338,9 @@ describe('Combobox', () => {
);

const button = screen.getByRole('button');
expect(button.props.accessibilityLabel).toBe('Custom accessibility label');
expect(button.props.accessibilityLabel).toBe(
'Custom accessibility label, Search and select...',
);
expect(button.props.accessibilityHint).toBe('Custom accessibility hint');
});

Expand Down
57 changes: 43 additions & 14 deletions packages/mobile/src/alpha/select/DefaultSelectControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ export const DefaultSelectControlComponent = memo(
compact,
maxSelectedOptionsToShow = 3,
accessibilityLabel,
accessibilityHint,
hiddenSelectedOptionsLabel = 'more',
removeSelectedOptionAccessibilityLabel = 'Remove',
style,
Expand Down Expand Up @@ -122,6 +121,37 @@ export const DefaultSelectControlComponent = memo(
return map;
}, [options]);

const singleValueContent = useMemo(() => {
const option = !isMultiSelect ? optionsMap.get(value as SelectOptionValue) : undefined;
const label = option?.label ?? option?.description ?? option?.value ?? placeholder;
return hasValue ? label : placeholder;
}, [hasValue, isMultiSelect, optionsMap, placeholder, value]);

const computedControlAccessibilityLabel = useMemo(() => {
// For multi-select, set the label to the content of each selected value and the hidden selected options label
if (isMultiSelect) {
const selectedValues = (value as SelectOptionValue[])
.map((v) => {
const option = optionsMap.get(v);
return option?.label ?? option?.description ?? option?.value ?? v;
})
.slice(0, maxSelectedOptionsToShow)
.join(', ');
return `${accessibilityLabel}, ${(value as SelectOptionValue[]).length > 0 ? selectedValues : (placeholder ?? '')}${(value as SelectOptionValue[]).length > maxSelectedOptionsToShow ? ', ' + hiddenSelectedOptionsLabel : ''}`;
}
// If value is React node, fallback to only using passed in accessibility label
return `${accessibilityLabel ?? ''}${typeof singleValueContent === 'string' ? ', ' + singleValueContent : ''}`;
}, [
accessibilityLabel,
hiddenSelectedOptionsLabel,
isMultiSelect,
maxSelectedOptionsToShow,
optionsMap,
placeholder,
singleValueContent,
value,
]);

// Prop value doesn't have default value because it affects the color of the
// animated caret
const focusedVariant = useInputVariant(!!open, variant ?? 'foregroundMuted');
Expand Down Expand Up @@ -214,24 +244,21 @@ export const DefaultSelectControlComponent = memo(
);
}

const option = !isMultiSelect ? optionsMap.get(value as SelectOptionValue) : undefined;
const label = option?.label ?? option?.description ?? option?.value ?? placeholder;
const content = hasValue ? label : placeholder;
return typeof content === 'string' ? (
return typeof singleValueContent === 'string' ? (
<Text color={hasValue ? 'fg' : 'fgMuted'} ellipsize="tail" font="body" textAlign="left">
{content}
{singleValueContent}
</Text>
) : (
content
singleValueContent
);
}, [
hasValue,
isMultiSelect,
optionsMap,
placeholder,
singleValueContent,
value,
maxSelectedOptionsToShow,
hiddenSelectedOptionsLabel,
optionsMap,
removeSelectedOptionAccessibilityLabel,
disabled,
onChange,
Expand All @@ -241,8 +268,7 @@ export const DefaultSelectControlComponent = memo(
() => (
<TouchableOpacity
ref={ref}
accessibilityHint={accessibilityHint}
accessibilityLabel={accessibilityLabel}
accessibilityLabel={computedControlAccessibilityLabel}
accessibilityRole="button"
disabled={disabled}
onPress={() => setOpen((s) => !s)}
Expand Down Expand Up @@ -287,8 +313,7 @@ export const DefaultSelectControlComponent = memo(
),
[
ref,
accessibilityHint,
accessibilityLabel,
computedControlAccessibilityLabel,
disabled,
styles?.controlInputNode,
styles?.controlStartNode,
Expand All @@ -307,7 +332,11 @@ export const DefaultSelectControlComponent = memo(

const endNode = useMemo(
() => (
<Pressable disabled={disabled} onPress={() => setOpen((s) => !s)}>
<Pressable
accessible={customEndNode ? true : false}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: hmm what if the end node is something that needs focus?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the purpose of this conditional value for the accessible prop! If a custom end node is passed in, we assume it may need to receive focus so accessible is set to true. The default end node is the <AnimatedCaret> which doesn't need to receive focus so it's false.

The green highlight is the accessible element in the mobile <DefaultSelectControl>. The end piece not highlighted is the default end node but pressing on it still opens the menu.

Screenshot 2025-12-15 at 12 04 32 PM

disabled={disabled}
onPress={() => setOpen((s) => !s)}
>
<HStack
alignItems="center"
flexGrow={1}
Expand Down
6 changes: 4 additions & 2 deletions packages/mobile/src/alpha/select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const SelectBase = memo(
compact,
label,
labelVariant,
accessibilityLabel = type === 'multi' ? 'Multi select control' : undefined,
accessibilityLabel,
accessibilityHint,
accessibilityRoles = defaultAccessibilityRoles,
selectAllLabel,
Expand Down Expand Up @@ -104,6 +104,8 @@ const SelectBase = memo(
'Select component must be fully controlled or uncontrolled: "open" and "setOpen" props must be provided together or not at all',
);

const fallbackAccessibilityLabel = typeof label === 'string' ? label : 'Select control';

const rootStyles = useMemo(() => {
return [style, styles?.root];
}, [style, styles?.root]);
Expand Down Expand Up @@ -169,7 +171,7 @@ const SelectBase = memo(
<View ref={containerRef} style={rootStyles} testID={testID}>
<SelectControlComponent
accessibilityHint={accessibilityHint}
accessibilityLabel={accessibilityLabel}
accessibilityLabel={accessibilityLabel ?? fallbackAccessibilityLabel}
blendStyles={styles?.controlBlendStyles}
compact={compact}
disabled={disabled}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ const AccessibilityLabelExample = () => {

return (
<Select
accessibilityLabel="Accessibility label"
accessibilityLabel="Custom accessibility label"
label="Single select - accessibility label"
onChange={setValue}
options={exampleOptions}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ describe('DefaultSelectControl', () => {
);

const button = screen.getByRole('button');
expect(button.props.accessibilityLabel).toBe('Custom accessibility label');
expect(button.props.accessibilityLabel).toBe('Custom accessibility label, Option 1');
expect(button.props.accessibilityHint).toBe('Custom accessibility hint');
});

Expand Down
2 changes: 1 addition & 1 deletion packages/mobile/src/alpha/select/__tests__/Select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe('Select', () => {
);

const button = screen.getByRole('button');
expect(button.props.accessibilityLabel).toBe('Custom accessibility label');
expect(button.props.accessibilityLabel).toBe('Custom accessibility label, Select an option');
expect(button.props.accessibilityHint).toBe('Custom accessibility hint');
});

Expand Down
16 changes: 12 additions & 4 deletions packages/web/src/alpha/combobox/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,9 @@ const ComboboxBase = memo(
options,
open: openProp,
setOpen: setOpenProp,
placeholder,
accessibilityLabel = 'Combobox control',
label,
accessibilityLabel,
controlAccessibilityLabel,
defaultOpen,
searchText: searchTextProp,
onSearch: onSearchProp,
Expand Down Expand Up @@ -193,6 +194,10 @@ const ComboboxBase = memo(
'Combobox component must be fully controlled or uncontrolled: "open" and "setOpen" props must be provided together or not at all',
);

const fallbackAccessibilityLabel = typeof label === 'string' ? label : 'Combobox dropdown';
const fallbackControlAccessibilityLabel =
typeof label === 'string' ? label : 'Combobox control';

const fuse = useMemo(
() =>
new Fuse(options, {
Expand Down Expand Up @@ -251,12 +256,15 @@ const ComboboxBase = memo(
<Select
ref={controlRef}
SelectControlComponent={ComboboxControl}
accessibilityLabel={accessibilityLabel}
accessibilityLabel={accessibilityLabel ?? fallbackAccessibilityLabel}
controlAccessibilityLabel={
controlAccessibilityLabel ?? fallbackControlAccessibilityLabel
}
defaultOpen={defaultOpen}
label={label}
onChange={handleChange}
open={open}
options={filteredOptions}
placeholder={placeholder}
setOpen={setOpen}
type={type}
value={value}
Expand Down
13 changes: 11 additions & 2 deletions packages/web/src/alpha/combobox/DefaultComboboxControl.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useRef } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';

import { NativeInput } from '../../controls/NativeInput';
import { HStack } from '../../layout';
Expand Down Expand Up @@ -27,6 +27,7 @@ export const DefaultComboboxControl = memo(
compact,
searchText,
onSearch,
accessibilityLabel,
...props
}: ComboboxControlProps<Type, SelectOptionValue>) => {
const searchInputRef = useRef<HTMLInputElement | null>(null);
Expand Down Expand Up @@ -54,9 +55,18 @@ export const DefaultComboboxControl = memo(
[setOpen],
);

const computedAccessibilityLabel = useMemo(() => {
let label = accessibilityLabel;
if (!hasValue && typeof placeholder === 'string') {
label = `${label}, ${placeholder}`;
}
return label;
}, [hasValue, accessibilityLabel, placeholder]);

return (
<SelectControlComponent
ref={controlRef.current?.refs.setReference}
accessibilityLabel={computedAccessibilityLabel}
compact={compact}
open={open}
options={options}
Expand Down Expand Up @@ -114,7 +124,6 @@ export const DefaultComboboxControl = memo(
</>
)
}
placeholder={null}
styles={{
...props.styles,
controlEndNode: {
Expand Down
Loading
Loading