Skip to content

Commit

Permalink
Merge pull request JedWatson#4676 from MatthewCharlton/improve-a11y-a…
Browse files Browse the repository at this point in the history
…nnouncements

Improve a11y announcements
  • Loading branch information
MatthewCharlton committed Aug 5, 2021
2 parents dfa68c8 + 1c9ba38 commit 3d33e7d
Show file tree
Hide file tree
Showing 12 changed files with 307 additions and 121 deletions.
12 changes: 12 additions & 0 deletions .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
64 changes: 58 additions & 6 deletions packages/react-select/src/Select.tsx
Expand Up @@ -320,6 +320,7 @@ interface State<
focusedValue: Option | null;
selectValue: Options<Option>;
clearFocusValueOnUpdate: boolean;
prevWasFocused: boolean;
inputIsHiddenAfterUpdate: boolean | null | undefined;
prevProps: Props<Option, IsMulti, Group> | void;
}
Expand Down Expand Up @@ -583,6 +584,7 @@ export default class Select<
isFocused: false,
selectValue: [],
clearFocusValueOnUpdate: false,
prevWasFocused: false,
inputIsHiddenAfterUpdate: undefined,
prevProps: undefined,
};
Expand Down Expand Up @@ -629,13 +631,21 @@ export default class Select<
'react-select-' + (this.props.instanceId || ++instanceId);
this.state.selectValue = cleanValue(props.value);
}

static getDerivedStateFromProps(
props: Props<OptionBase, boolean, GroupBase<OptionBase>>,
state: State<OptionBase, boolean, GroupBase<OptionBase>>
) {
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 &&
Expand All @@ -644,7 +654,6 @@ export default class Select<
menuIsOpen !== prevProps.menuIsOpen ||
inputValue !== prevProps.inputValue)
) {
const selectValue = cleanValue(value);
const focusableOptions = menuIsOpen
? buildFocusableOptions(props, selectValue)
: [];
Expand All @@ -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() {
Expand Down Expand Up @@ -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}`;
};

Expand Down Expand Up @@ -1178,6 +1220,7 @@ export default class Select<
return;
}
this.clearValue();
event.preventDefault();
event.stopPropagation();
this.openAfterFocus = false;
if (event.type === 'touchend') {
Expand Down Expand Up @@ -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');
Expand All @@ -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) {
Expand Down Expand Up @@ -1593,6 +1643,7 @@ export default class Select<
key="placeholder"
isDisabled={isDisabled}
isFocused={isFocused}
innerProps={{ id: this.getElementId('placeholder') }}
>
{placeholder}
</Placeholder>
Expand Down Expand Up @@ -1953,6 +2004,7 @@ export default class Select<
return (
<LiveRegion
{...commonProps}
id={this.getElementId('live-region')}
ariaSelection={ariaSelection}
focusedOption={focusedOption}
focusedValue={focusedValue}
Expand Down
41 changes: 41 additions & 0 deletions packages/react-select/src/__tests__/Select.test.tsx
Expand Up @@ -2155,6 +2155,7 @@ test('accessibility > 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'
);
Expand Down Expand Up @@ -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')!;
Expand All @@ -2270,6 +2274,43 @@ test('accessibility > A11yTexts can be provided through ariaLiveMessages prop',
);
});

test('accessibility > announces already selected values when focused', () => {
let { container } = render(
<Select {...BASIC_PROPS} options={OPTIONS} value={OPTIONS[0]} />
);
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(
<Select {...BASIC_PROPS} options={OPTIONS} value={OPTIONS[0]} isClearable />
);
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(
Expand Down
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -88,7 +88,7 @@ exports[`defaults - snapshot 1`] = `
box-sizing: border-box;
}
.emotion-5 {
.emotion-6 {
margin: 2px;
padding-bottom: 2px;
padding-top: 2px;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -172,30 +172,36 @@ exports[`defaults - snapshot 1`] = `
<div
class=" emotion-0"
>
<span
class="emotion-1"
id="react-select-2-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
aria-relevant="additions text"
class="emotion-1"
/>
<div
class=" emotion-2"
class=" emotion-3"
>
<div
class=" emotion-3"
class=" emotion-4"
>
<div
class=" emotion-4"
class=" emotion-5"
id="react-select-2-placeholder"
>
Select...
</div>
<div
class=" emotion-5"
class=" emotion-6"
data-value=""
>
<input
aria-autocomplete="list"
aria-controls="react-select-2-listbox"
aria-describedby="react-select-2-placeholder"
aria-expanded="false"
aria-haspopup="true"
aria-owns="react-select-2-listbox"
Expand All @@ -214,18 +220,18 @@ exports[`defaults - snapshot 1`] = `
</div>
</div>
<div
class=" emotion-6"
class=" emotion-7"
>
<span
class=" emotion-7"
class=" emotion-8"
/>
<div
aria-hidden="true"
class=" emotion-8"
class=" emotion-9"
>
<svg
aria-hidden="true"
class="emotion-9"
class="emotion-10"
focusable="false"
height="20"
viewBox="0 0 20 20"
Expand Down

0 comments on commit 3d33e7d

Please sign in to comment.