Skip to content

Commit 44cbd06

Browse files
authored
Merge branch 'master' into pass-form-prop-to-input
2 parents 67a26b7 + 06e3488 commit 44cbd06

16 files changed

+501
-82
lines changed

docs/pages/typescript/index.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ const onChange = (option: readonly Option[], actionMeta: ActionMeta<Option>) =>
8484
}
8585
~~~
8686
87-
The \`actionMeta\` parameter is optional. \`ActionMeta\` is a union that is discriminated on the \`action\` type. Take a look at at [types.ts](https://github.com/JedWatson/react-select/blob/master/packages/react-select/src/types.ts) in the source code to see its full definition.
87+
The \`actionMeta\` parameter is optional. \`ActionMeta\` is a union that is discriminated on the \`action\` type. Take a look at [types.ts](https://github.com/JedWatson/react-select/blob/master/packages/react-select/src/types.ts) in the source code to see its full definition.
8888
8989
## Custom Select props
9090

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@
5858
"jest-in-case": "^1.0.2",
5959
"prettier": "^2.2.1",
6060
"style-loader": "^0.23.1",
61-
"typescript": "^4.1.3"
61+
"typescript": "^4.1.3",
62+
"user-agent-data-types": "^0.4.2"
6263
},
6364
"scripts": {
6465
"build": "preconstruct build",

packages/react-select/CHANGELOG.md

+16
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# react-select
22

3+
## 5.8.0
4+
5+
### Minor Changes
6+
7+
- [`884f1c42`](https://github.com/JedWatson/react-select/commit/884f1c42549faad7cb210169223b427ad6f0c9fd) [#5758](https://github.com/JedWatson/react-select/pull/5758) Thanks [@Ke1sy](https://github.com/Ke1sy)! - 1. Added 'aria-activedescendant' for input and functionality to calculate it;
8+
9+
2. Added role 'option' and 'aria-selected' for option;
10+
3. Added role 'listbox' for menu;
11+
4. Added tests for 'aria-activedescendant';
12+
5. Changes in aria-live region:
13+
14+
- the instructions how to use select will be announced only one time when user focuses the input for the first time.
15+
- instructions for menu or selected value will be announced only once after focusing them.
16+
- removed aria-live for focused option because currently with correct aria-attributes it will be announced by screenreader natively as well as the status of this option (active or disabled).
17+
- separated ariaContext into ariaFocused, ariaResults, ariaGuidance to avoid announcing redundant information and higlight only current change.
18+
319
## 5.7.7
420

521
### Patch Changes

packages/react-select/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-select",
3-
"version": "5.7.7",
3+
"version": "5.8.0",
44
"description": "A Select control built with and for ReactJS",
55
"main": "dist/react-select.cjs.js",
66
"module": "dist/react-select.esm.js",

packages/react-select/src/Select.tsx

+116-9
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import LiveRegion from './components/LiveRegion';
1616
import { createFilter, FilterOptionOption } from './filters';
1717
import { DummyInput, ScrollManager, RequiredInput } from './internal/index';
1818
import { AriaLiveMessages, AriaSelection } from './accessibility/index';
19+
import { isAppleDevice } from './accessibility/helpers';
1920

2021
import {
2122
classNames,
@@ -329,12 +330,15 @@ interface State<
329330
inputIsHidden: boolean;
330331
isFocused: boolean;
331332
focusedOption: Option | null;
333+
focusedOptionId: string | null;
334+
focusableOptionsWithIds: FocusableOptionWithId<Option>[];
332335
focusedValue: Option | null;
333336
selectValue: Options<Option>;
334337
clearFocusValueOnUpdate: boolean;
335338
prevWasFocused: boolean;
336339
inputIsHiddenAfterUpdate: boolean | null | undefined;
337340
prevProps: Props<Option, IsMulti, Group> | void;
341+
instancePrefix: string;
338342
}
339343

340344
interface CategorizedOption<Option> {
@@ -347,6 +351,11 @@ interface CategorizedOption<Option> {
347351
index: number;
348352
}
349353

354+
interface FocusableOptionWithId<Option> {
355+
data: Option;
356+
id: string;
357+
}
358+
350359
interface CategorizedGroup<Option, Group extends GroupBase<Option>> {
351360
type: 'group';
352361
data: Group;
@@ -441,6 +450,31 @@ function buildFocusableOptionsFromCategorizedOptions<
441450
);
442451
}
443452

453+
function buildFocusableOptionsWithIds<Option, Group extends GroupBase<Option>>(
454+
categorizedOptions: readonly CategorizedGroupOrOption<Option, Group>[],
455+
optionId: string
456+
) {
457+
return categorizedOptions.reduce<FocusableOptionWithId<Option>[]>(
458+
(optionsAccumulator, categorizedOption) => {
459+
if (categorizedOption.type === 'group') {
460+
optionsAccumulator.push(
461+
...categorizedOption.options.map((option) => ({
462+
data: option.data,
463+
id: `${optionId}-${categorizedOption.index}-${option.index}`,
464+
}))
465+
);
466+
} else {
467+
optionsAccumulator.push({
468+
data: categorizedOption.data,
469+
id: `${optionId}-${categorizedOption.index}`,
470+
});
471+
}
472+
return optionsAccumulator;
473+
},
474+
[]
475+
);
476+
}
477+
444478
function buildFocusableOptions<
445479
Option,
446480
IsMulti extends boolean,
@@ -499,6 +533,17 @@ function getNextFocusedOption<
499533
? lastFocusedOption
500534
: options[0];
501535
}
536+
537+
const getFocusedOptionId = <Option,>(
538+
focusableOptionsWithIds: FocusableOptionWithId<Option>[],
539+
focusedOption: Option
540+
) => {
541+
const focusedOptionId = focusableOptionsWithIds.find(
542+
(option) => option.data === focusedOption
543+
)?.id;
544+
return focusedOptionId || null;
545+
};
546+
502547
const getOptionLabel = <
503548
Option,
504549
IsMulti extends boolean,
@@ -587,6 +632,8 @@ export default class Select<
587632
state: State<Option, IsMulti, Group> = {
588633
ariaSelection: null,
589634
focusedOption: null,
635+
focusedOptionId: null,
636+
focusableOptionsWithIds: [],
590637
focusedValue: null,
591638
inputIsHidden: false,
592639
isFocused: false,
@@ -595,6 +642,7 @@ export default class Select<
595642
prevWasFocused: false,
596643
inputIsHiddenAfterUpdate: undefined,
597644
prevProps: undefined,
645+
instancePrefix: '',
598646
};
599647

600648
// Misc. Instance Properties
@@ -605,10 +653,10 @@ export default class Select<
605653
commonProps: any; // TODO
606654
initialTouchX = 0;
607655
initialTouchY = 0;
608-
instancePrefix = '';
609656
openAfterFocus = false;
610657
scrollToFocusedOptionOnUpdate = false;
611658
userIsDragging?: boolean;
659+
isAppleDevice = isAppleDevice();
612660

613661
// Refs
614662
// ------------------------------
@@ -635,15 +683,21 @@ export default class Select<
635683

636684
constructor(props: Props<Option, IsMulti, Group>) {
637685
super(props);
638-
this.instancePrefix =
686+
this.state.instancePrefix =
639687
'react-select-' + (this.props.instanceId || ++instanceId);
640688
this.state.selectValue = cleanValue(props.value);
641-
642689
// Set focusedOption if menuIsOpen is set on init (e.g. defaultMenuIsOpen)
643690
if (props.menuIsOpen && this.state.selectValue.length) {
691+
const focusableOptionsWithIds: FocusableOptionWithId<Option>[] =
692+
this.getFocusableOptionsWithIds();
644693
const focusableOptions = this.buildFocusableOptions();
645694
const optionIndex = focusableOptions.indexOf(this.state.selectValue[0]);
695+
this.state.focusableOptionsWithIds = focusableOptionsWithIds;
646696
this.state.focusedOption = focusableOptions[optionIndex];
697+
this.state.focusedOptionId = getFocusedOptionId(
698+
focusableOptionsWithIds,
699+
focusableOptions[optionIndex]
700+
);
647701
}
648702
}
649703

@@ -658,6 +712,7 @@ export default class Select<
658712
ariaSelection,
659713
isFocused,
660714
prevWasFocused,
715+
instancePrefix,
661716
} = state;
662717
const { options, value, menuIsOpen, inputValue, isMulti } = props;
663718
const selectValue = cleanValue(value);
@@ -672,13 +727,28 @@ export default class Select<
672727
const focusableOptions = menuIsOpen
673728
? buildFocusableOptions(props, selectValue)
674729
: [];
730+
731+
const focusableOptionsWithIds = menuIsOpen
732+
? buildFocusableOptionsWithIds(
733+
buildCategorizedOptions(props, selectValue),
734+
`${instancePrefix}-option`
735+
)
736+
: [];
737+
675738
const focusedValue = clearFocusValueOnUpdate
676739
? getNextFocusedValue(state, selectValue)
677740
: null;
678741
const focusedOption = getNextFocusedOption(state, focusableOptions);
742+
const focusedOptionId = getFocusedOptionId(
743+
focusableOptionsWithIds,
744+
focusedOption
745+
);
746+
679747
newMenuOptionsState = {
680748
selectValue,
681749
focusedOption,
750+
focusedOptionId,
751+
focusableOptionsWithIds,
682752
focusedValue,
683753
clearFocusValueOnUpdate: false,
684754
};
@@ -801,6 +871,7 @@ export default class Select<
801871
action: 'menu-close',
802872
prevInputValue: this.props.inputValue,
803873
});
874+
804875
this.props.onMenuClose();
805876
}
806877
onInputChange(newValue: string, actionMeta: InputActionMeta) {
@@ -844,6 +915,7 @@ export default class Select<
844915
inputIsHiddenAfterUpdate: false,
845916
focusedValue: null,
846917
focusedOption: focusableOptions[openAtIndex],
918+
focusedOptionId: this.getFocusedOptionId(focusableOptions[openAtIndex]),
847919
},
848920
() => this.onMenuOpen()
849921
);
@@ -921,6 +993,7 @@ export default class Select<
921993
this.setState({
922994
focusedOption: options[nextFocus],
923995
focusedValue: null,
996+
focusedOptionId: this.getFocusedOptionId(options[nextFocus]),
924997
});
925998
}
926999
onChange = (
@@ -941,7 +1014,9 @@ export default class Select<
9411014
const { closeMenuOnSelect, isMulti, inputValue } = this.props;
9421015
this.onInputChange('', { action: 'set-value', prevInputValue: inputValue });
9431016
if (closeMenuOnSelect) {
944-
this.setState({ inputIsHiddenAfterUpdate: !isMulti });
1017+
this.setState({
1018+
inputIsHiddenAfterUpdate: !isMulti,
1019+
});
9451020
this.onMenuClose();
9461021
}
9471022
// when the select value should change, we should reset focusedValue
@@ -1050,6 +1125,20 @@ export default class Select<
10501125
};
10511126
}
10521127

1128+
getFocusedOptionId = (focusedOption: Option) => {
1129+
return getFocusedOptionId(
1130+
this.state.focusableOptionsWithIds,
1131+
focusedOption
1132+
);
1133+
};
1134+
1135+
getFocusableOptionsWithIds = () => {
1136+
return buildFocusableOptionsWithIds(
1137+
buildCategorizedOptions(this.props, this.state.selectValue),
1138+
this.getElementId('option')
1139+
);
1140+
};
1141+
10531142
getValue = () => this.state.selectValue;
10541143

10551144
cx = (...args: any) => classNames(this.props.classNamePrefix, ...args);
@@ -1114,7 +1203,7 @@ export default class Select<
11141203
| 'placeholder'
11151204
| 'live-region'
11161205
) => {
1117-
return `${this.instancePrefix}-${element}`;
1206+
return `${this.state.instancePrefix}-${element}`;
11181207
};
11191208

11201209
getComponents = () => {
@@ -1437,7 +1526,13 @@ export default class Select<
14371526
if (this.blockOptionHover || this.state.focusedOption === focusedOption) {
14381527
return;
14391528
}
1440-
this.setState({ focusedOption });
1529+
const options = this.getFocusableOptions();
1530+
const focusedOptionIndex = options.indexOf(focusedOption!);
1531+
this.setState({
1532+
focusedOption,
1533+
focusedOptionId:
1534+
focusedOptionIndex > -1 ? this.getFocusedOptionId(focusedOption) : null,
1535+
});
14411536
};
14421537
shouldHideSelectedOptions = () => {
14431538
return shouldHideSelectedOptions(this.props);
@@ -1536,7 +1631,9 @@ export default class Select<
15361631
return;
15371632
case 'Escape':
15381633
if (menuIsOpen) {
1539-
this.setState({ inputIsHiddenAfterUpdate: false });
1634+
this.setState({
1635+
inputIsHiddenAfterUpdate: false,
1636+
});
15401637
this.onInputChange('', {
15411638
action: 'menu-close',
15421639
prevInputValue: inputValue,
@@ -1624,9 +1721,12 @@ export default class Select<
16241721
'aria-labelledby': this.props['aria-labelledby'],
16251722
'aria-required': required,
16261723
role: 'combobox',
1724+
'aria-activedescendant': this.isAppleDevice
1725+
? undefined
1726+
: this.state.focusedOptionId || '',
1727+
16271728
...(menuIsOpen && {
16281729
'aria-controls': this.getElementId('listbox'),
1629-
'aria-owns': this.getElementId('listbox'),
16301730
}),
16311731
...(!isSearchable && {
16321732
'aria-readonly': true,
@@ -1891,6 +1991,8 @@ export default class Select<
18911991
onMouseMove: onHover,
18921992
onMouseOver: onHover,
18931993
tabIndex: -1,
1994+
role: 'option',
1995+
'aria-selected': this.isAppleDevice ? undefined : isSelected, // is not supported on Apple devices
18941996
};
18951997

18961998
return (
@@ -1970,7 +2072,6 @@ export default class Select<
19702072
innerProps={{
19712073
onMouseDown: this.onMenuMouseDown,
19722074
onMouseMove: this.onMenuMouseMove,
1973-
id: this.getElementId('listbox'),
19742075
}}
19752076
isLoading={isLoading}
19762077
placement={placement}
@@ -1988,6 +2089,11 @@ export default class Select<
19882089
this.getMenuListRef(instance);
19892090
scrollTargetRef(instance);
19902091
}}
2092+
innerProps={{
2093+
role: 'listbox',
2094+
'aria-multiselectable': commonProps.isMulti,
2095+
id: this.getElementId('listbox'),
2096+
}}
19912097
isLoading={isLoading}
19922098
maxHeight={maxHeight}
19932099
focusedOption={focusedOption}
@@ -2086,6 +2192,7 @@ export default class Select<
20862192
isFocused={isFocused}
20872193
selectValue={selectValue}
20882194
focusableOptions={focusableOptions}
2195+
isAppleDevice={this.isAppleDevice}
20892196
/>
20902197
);
20912198
}

0 commit comments

Comments
 (0)