diff --git a/.changeset/selfish-dots-look.md b/.changeset/selfish-dots-look.md
new file mode 100644
index 0000000000..28c1e38851
--- /dev/null
+++ b/.changeset/selfish-dots-look.md
@@ -0,0 +1,12 @@
+---
+'react-select': patch
+---
+
+The following improvements have been made for screen reader users:
+
+- NVDA now announces the context text when initially focused
+- Selected option/s (single and multi) are now announced when initially focused
+- VoiceOver now announces the context text when re-focusing
+- The clear action is now announced
+- Placeholder text is now announced
+- Mobile VoiceOver is now able to remove selected multi options
diff --git a/packages/react-select/src/Select.tsx b/packages/react-select/src/Select.tsx
index 30e63fb994..93239cdf41 100644
--- a/packages/react-select/src/Select.tsx
+++ b/packages/react-select/src/Select.tsx
@@ -320,6 +320,7 @@ interface State<
focusedValue: Option | null;
selectValue: Options;
clearFocusValueOnUpdate: boolean;
+ prevWasFocused: boolean;
inputIsHiddenAfterUpdate: boolean | null | undefined;
prevProps: Props | void;
}
@@ -583,6 +584,7 @@ export default class Select<
isFocused: false,
selectValue: [],
clearFocusValueOnUpdate: false,
+ prevWasFocused: false,
inputIsHiddenAfterUpdate: undefined,
prevProps: undefined,
};
@@ -629,13 +631,21 @@ export default class Select<
'react-select-' + (this.props.instanceId || ++instanceId);
this.state.selectValue = cleanValue(props.value);
}
+
static getDerivedStateFromProps(
props: Props>,
state: State>
) {
- const { prevProps, clearFocusValueOnUpdate, inputIsHiddenAfterUpdate } =
- state;
- const { options, value, menuIsOpen, inputValue } = props;
+ const {
+ prevProps,
+ clearFocusValueOnUpdate,
+ inputIsHiddenAfterUpdate,
+ ariaSelection,
+ isFocused,
+ prevWasFocused,
+ } = state;
+ const { options, value, menuIsOpen, inputValue, isMulti } = props;
+ const selectValue = cleanValue(value);
let newMenuOptionsState = {};
if (
prevProps &&
@@ -644,7 +654,6 @@ export default class Select<
menuIsOpen !== prevProps.menuIsOpen ||
inputValue !== prevProps.inputValue)
) {
- const selectValue = cleanValue(value);
const focusableOptions = menuIsOpen
? buildFocusableOptions(props, selectValue)
: [];
@@ -667,10 +676,35 @@ export default class Select<
inputIsHiddenAfterUpdate: undefined,
}
: {};
+
+ let newAriaSelection = ariaSelection;
+
+ let hasKeptFocus = isFocused && prevWasFocused;
+
+ if (isFocused && !hasKeptFocus) {
+ // If `value` or `defaultValue` props are not empty then announce them
+ // when the Select is initially focused
+ newAriaSelection = {
+ value: valueTernary(isMulti, selectValue, selectValue[0] || null),
+ options: selectValue,
+ action: 'initial-input-focus',
+ };
+
+ hasKeptFocus = !prevWasFocused;
+ }
+
+ // If the 'initial-input-focus' action has been set already
+ // then reset the ariaSelection to null
+ if (ariaSelection?.action === 'initial-input-focus') {
+ newAriaSelection = null;
+ }
+
return {
...newMenuOptionsState,
...newInputIsHiddenState,
prevProps: props,
+ ariaSelection: newAriaSelection,
+ prevWasFocused: hasKeptFocus,
};
}
componentDidMount() {
@@ -1027,7 +1061,15 @@ export default class Select<
const custom = this.props.styles[key];
return custom ? custom(base, props as any) : base;
};
- getElementId = (element: 'group' | 'input' | 'listbox' | 'option') => {
+ getElementId = (
+ element:
+ | 'group'
+ | 'input'
+ | 'listbox'
+ | 'option'
+ | 'placeholder'
+ | 'live-region'
+ ) => {
return `${this.instancePrefix}-${element}`;
};
@@ -1178,6 +1220,7 @@ export default class Select<
return;
}
this.clearValue();
+ event.preventDefault();
event.stopPropagation();
this.openAfterFocus = false;
if (event.type === 'touchend') {
@@ -1504,7 +1547,7 @@ export default class Select<
menuIsOpen,
} = this.props;
const { Input } = this.getComponents();
- const { inputIsHidden } = this.state;
+ const { inputIsHidden, ariaSelection } = this.state;
const { commonProps } = this;
const id = inputId || this.getElementId('input');
@@ -1524,6 +1567,13 @@ export default class Select<
...(!isSearchable && {
'aria-readonly': true,
}),
+ ...(this.hasValue()
+ ? ariaSelection?.action === 'initial-input-focus' && {
+ 'aria-describedby': this.getElementId('live-region'),
+ }
+ : {
+ 'aria-describedby': this.getElementId('placeholder'),
+ }),
};
if (!isSearchable) {
@@ -1593,6 +1643,7 @@ export default class Select<
key="placeholder"
isDisabled={isDisabled}
isFocused={isFocused}
+ innerProps={{ id: this.getElementId('placeholder') }}
>
{placeholder}
@@ -1953,6 +2004,7 @@ export default class Select<
return (
interacting with multi values options shows correct A11yTe
let input = container.querySelector('.react-select__value-container input')!;
fireEvent.focus(container.querySelector('input.react-select__input')!);
+
expect(container.querySelector(liveRegionId)!.textContent).toMatch(
' Select is focused ,type to refine list, press Down to open the menu, press left to focus selected values'
);
@@ -2256,6 +2257,9 @@ test('accessibility > A11yTexts can be provided through ariaLiveMessages prop',
/>
);
const liveRegionEventId = '#aria-selection';
+
+ expect(container.querySelector(liveRegionEventId)!).toBeNull();
+
fireEvent.focus(container.querySelector('input.react-select__input')!);
let menu = container.querySelector('.react-select__menu')!;
@@ -2270,6 +2274,43 @@ test('accessibility > A11yTexts can be provided through ariaLiveMessages prop',
);
});
+test('accessibility > announces already selected values when focused', () => {
+ let { container } = render(
+
+ );
+ const liveRegionSelectionId = '#aria-selection';
+ const liveRegionContextId = '#aria-context';
+
+ // the live region should not be mounted yet
+ expect(container.querySelector(liveRegionSelectionId)!).toBeNull();
+
+ fireEvent.focus(container.querySelector('input.react-select__input')!);
+
+ expect(container.querySelector(liveRegionContextId)!.textContent).toMatch(
+ ' Select is focused ,type to refine list, press Down to open the menu, '
+ );
+ expect(container.querySelector(liveRegionSelectionId)!.textContent).toMatch(
+ 'option 0, selected.'
+ );
+});
+
+test('accessibility > announces cleared values', () => {
+ let { container } = render(
+
+ );
+ const liveRegionSelectionId = '#aria-selection';
+ /**
+ * announce deselected value
+ */
+ fireEvent.focus(container.querySelector('input.react-select__input')!);
+ fireEvent.mouseDown(
+ container.querySelector('.react-select__clear-indicator')!
+ );
+ expect(container.querySelector(liveRegionSelectionId)!.textContent).toMatch(
+ 'All selected options have been cleared.'
+ );
+});
+
test('closeMenuOnSelect prop > when passed as false it should not call onMenuClose on selecting option', () => {
let onMenuCloseSpy = jest.fn();
let { container } = render(
diff --git a/packages/react-select/src/__tests__/__snapshots__/Async.test.tsx.snap b/packages/react-select/src/__tests__/__snapshots__/Async.test.tsx.snap
index 269fefe8d3..326248fe43 100644
--- a/packages/react-select/src/__tests__/__snapshots__/Async.test.tsx.snap
+++ b/packages/react-select/src/__tests__/__snapshots__/Async.test.tsx.snap
@@ -18,7 +18,7 @@ exports[`defaults - snapshot 1`] = `
white-space: nowrap;
}
-.emotion-2 {
+.emotion-3 {
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
@@ -48,11 +48,11 @@ exports[`defaults - snapshot 1`] = `
box-sizing: border-box;
}
-.emotion-2:hover {
+.emotion-3:hover {
border-color: hsl(0, 0%, 70%);
}
-.emotion-3 {
+.emotion-4 {
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
@@ -75,7 +75,7 @@ exports[`defaults - snapshot 1`] = `
box-sizing: border-box;
}
-.emotion-4 {
+.emotion-5 {
color: hsl(0, 0%, 50%);
margin-left: 2px;
margin-right: 2px;
@@ -88,7 +88,7 @@ exports[`defaults - snapshot 1`] = `
box-sizing: border-box;
}
-.emotion-5 {
+.emotion-6 {
margin: 2px;
padding-bottom: 2px;
padding-top: 2px;
@@ -102,7 +102,7 @@ exports[`defaults - snapshot 1`] = `
box-sizing: border-box;
}
-.emotion-5:after {
+.emotion-6:after {
content: attr(data-value) " ";
visibility: hidden;
white-space: nowrap;
@@ -115,7 +115,7 @@ exports[`defaults - snapshot 1`] = `
padding: 0;
}
-.emotion-6 {
+.emotion-7 {
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
@@ -133,7 +133,7 @@ exports[`defaults - snapshot 1`] = `
box-sizing: border-box;
}
-.emotion-7 {
+.emotion-8 {
-webkit-align-self: stretch;
-ms-flex-item-align: stretch;
align-self: stretch;
@@ -144,7 +144,7 @@ exports[`defaults - snapshot 1`] = `
box-sizing: border-box;
}
-.emotion-8 {
+.emotion-9 {
color: hsl(0, 0%, 80%);
display: -webkit-box;
display: -webkit-flex;
@@ -156,11 +156,11 @@ exports[`defaults - snapshot 1`] = `
box-sizing: border-box;
}
-.emotion-8:hover {
+.emotion-9:hover {
color: hsl(0, 0%, 60%);
}
-.emotion-9 {
+.emotion-10 {
display: inline-block;
fill: currentColor;
line-height: 1;
@@ -172,6 +172,10 @@ exports[`defaults - snapshot 1`] = `
+
Select...
+
Select...
+
Select...
+
Select...
snapshot 1`] = `
white-space: nowrap;
}
-.emotion-2 {
+.emotion-3 {
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
@@ -48,11 +48,11 @@ exports[`defaults > snapshot 1`] = `
box-sizing: border-box;
}
-.emotion-2:hover {
+.emotion-3:hover {
border-color: hsl(0, 0%, 70%);
}
-.emotion-3 {
+.emotion-4 {
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
@@ -75,7 +75,7 @@ exports[`defaults > snapshot 1`] = `
box-sizing: border-box;
}
-.emotion-4 {
+.emotion-5 {
color: hsl(0, 0%, 50%);
margin-left: 2px;
margin-right: 2px;
@@ -88,7 +88,7 @@ exports[`defaults > snapshot 1`] = `
box-sizing: border-box;
}
-.emotion-5 {
+.emotion-6 {
margin: 2px;
padding-bottom: 2px;
padding-top: 2px;
@@ -102,7 +102,7 @@ exports[`defaults > snapshot 1`] = `
box-sizing: border-box;
}
-.emotion-5:after {
+.emotion-6:after {
content: attr(data-value) " ";
visibility: hidden;
white-space: nowrap;
@@ -115,7 +115,7 @@ exports[`defaults > snapshot 1`] = `
padding: 0;
}
-.emotion-6 {
+.emotion-7 {
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
@@ -133,7 +133,7 @@ exports[`defaults > snapshot 1`] = `
box-sizing: border-box;
}
-.emotion-7 {
+.emotion-8 {
-webkit-align-self: stretch;
-ms-flex-item-align: stretch;
align-self: stretch;
@@ -144,7 +144,7 @@ exports[`defaults > snapshot 1`] = `
box-sizing: border-box;
}
-.emotion-8 {
+.emotion-9 {
color: hsl(0, 0%, 80%);
display: -webkit-box;
display: -webkit-flex;
@@ -156,11 +156,11 @@ exports[`defaults > snapshot 1`] = `
box-sizing: border-box;
}
-.emotion-8:hover {
+.emotion-9:hover {
color: hsl(0, 0%, 60%);
}
-.emotion-9 {
+.emotion-10 {
display: inline-block;
fill: currentColor;
line-height: 1;
@@ -172,6 +172,10 @@ exports[`defaults > snapshot 1`] = `
+
snapshot 1`] = `
class="emotion-1"
/>
=
- ActionMeta & {
- value: OnChangeValue ;
- };
+ | InitialInputFocusedActionMeta
+ | (ActionMeta & {
+ value: OnChangeValue ;
+ option?: Option;
+ options?: Options ;
+ });
export interface AriaGuidanceProps {
/** String value of selectProp aria-label */
@@ -39,6 +43,8 @@ export type AriaOnChangeProps<
> = AriaSelection & {
/** String derived label from selected or removed option/value */
label: string;
+ /** Array of labels derived from multiple selected or cleared options */
+ labels: string[];
/** Boolean indicating if the selected menu option is disabled */
isDisabled: boolean | null;
};
@@ -127,12 +133,18 @@ export const defaultAriaLiveMessages = {
onChange: (
props: AriaOnChangeProps
) => {
- const { action, label = '', isDisabled } = props;
+ const { action, label = '', labels, isDisabled } = props;
switch (action) {
case 'deselect-option':
case 'pop-value':
case 'remove-value':
return `option ${label}, deselected.`;
+ case 'clear':
+ return 'All selected options have been cleared.';
+ case 'initial-input-focus':
+ return `option${labels.length > 1 ? 's' : ''} ${labels.join(
+ ','
+ )}, selected.`;
case 'select-option':
return isDisabled
? `option ${label} is disabled. Select another option.`
diff --git a/packages/react-select/src/components/LiveRegion.tsx b/packages/react-select/src/components/LiveRegion.tsx
index 97891c48d7..d4d7880ccd 100644
--- a/packages/react-select/src/components/LiveRegion.tsx
+++ b/packages/react-select/src/components/LiveRegion.tsx
@@ -30,6 +30,7 @@ export interface LiveRegionProps<
selectValue: Options ;
focusableOptions: Options ;
isFocused: boolean;
+ id: string;
}
const LiveRegion = <
@@ -47,6 +48,7 @@ const LiveRegion = <
isFocused,
selectValue,
selectProps,
+ id,
} = props;
const {
@@ -77,15 +79,31 @@ const LiveRegion = <
const ariaSelected = useMemo(() => {
let message = '';
if (ariaSelection && messages.onChange) {
- const { option, removedValue, value } = ariaSelection;
+ const {
+ option,
+ options: selectedOptions,
+ removedValue,
+ removedValues,
+ value,
+ } = ariaSelection;
// select-option when !isMulti does not return option so we assume selected option is value
const asOption = (val: OnChangeValue ): Option | null =>
!Array.isArray(val) ? (val as Option) : null;
+
+ // If there is just one item from the action then get its label
const selected = removedValue || option || asOption(value);
+ const label = selected ? getOptionLabel(selected) : '';
+
+ // If there are multiple items from the action then return an array of labels
+ const multiSelected = selectedOptions || removedValues || undefined;
+ const labels = multiSelected ? multiSelected.map(getOptionLabel) : [];
const onChangeProps = {
+ // multiSelected items are usually items that have already been selected
+ // or set by the user as a default value so we assume they are not disabled
isDisabled: selected && isOptionDisabled(selected, selectValue),
- label: selected ? getOptionLabel(selected) : '',
+ label,
+ labels,
...ariaSelection,
};
@@ -176,19 +194,28 @@ const LiveRegion = <
const ariaContext = `${ariaFocused} ${ariaResults} ${ariaGuidance}`;
+ const ScreenReaderText = (
+
+ {ariaSelected}
+ {ariaContext}
+
+ );
+
+ const isInitialFocus = ariaSelection?.action === 'initial-input-focus';
+
return (
-
- {isFocused && (
-
- {ariaSelected}
- {ariaContext}
-
- )}
-
+
+ {/* We use 'aria-describedby' linked to this component for the initial focus */}
+ {/* action, then for all other actions we use the live region below */}
+ {isInitialFocus && ScreenReaderText}
+
+ {isFocused && !isInitialFocus && ScreenReaderText}
+
+
);
};
diff --git a/packages/react-select/src/components/MultiValue.tsx b/packages/react-select/src/components/MultiValue.tsx
index 90c155be3e..043d26685f 100644
--- a/packages/react-select/src/components/MultiValue.tsx
+++ b/packages/react-select/src/components/MultiValue.tsx
@@ -127,7 +127,11 @@ export function MultiValueRemove<
IsMulti extends boolean,
Group extends GroupBase
>({ children, innerProps }: MultiValueRemoveProps ) {
- return {children || }
;
+ return (
+
+ {children || }
+
+ );
}
const MultiValue = <
@@ -201,6 +205,7 @@ const MultiValue = <
className
)
),
+ 'aria-label': `Remove ${children || 'option'}`,
...removeProps,
}}
selectProps={selectProps}
diff --git a/packages/react-select/src/types.ts b/packages/react-select/src/types.ts
index 820baa9d17..94febb2627 100644
--- a/packages/react-select/src/types.ts
+++ b/packages/react-select/src/types.ts
@@ -140,7 +140,6 @@ export interface RemoveValueActionMeta
removedValue: Option;
name?: string;
}
-
export interface PopValueActionMeta
extends ActionMetaBase {
action: 'pop-value';
@@ -160,6 +159,14 @@ export interface CreateOptionActionMeta
action: 'create-option';
name?: string;
}
+export interface InitialInputFocusedActionMeta<
+ Option extends OptionBase,
+ IsMulti extends boolean
+> extends ActionMetaBase {
+ action: 'initial-input-focus';
+ value: OnChangeValue ;
+ options?: Options ;
+}
export type ActionMeta =
| SelectOptionActionMeta