@@ -16,6 +16,7 @@ import LiveRegion from './components/LiveRegion';
16
16
import { createFilter , FilterOptionOption } from './filters' ;
17
17
import { DummyInput , ScrollManager , RequiredInput } from './internal/index' ;
18
18
import { AriaLiveMessages , AriaSelection } from './accessibility/index' ;
19
+ import { isAppleDevice } from './accessibility/helpers' ;
19
20
20
21
import {
21
22
classNames ,
@@ -329,12 +330,15 @@ interface State<
329
330
inputIsHidden : boolean ;
330
331
isFocused : boolean ;
331
332
focusedOption : Option | null ;
333
+ focusedOptionId : string | null ;
334
+ focusableOptionsWithIds : FocusableOptionWithId < Option > [ ] ;
332
335
focusedValue : Option | null ;
333
336
selectValue : Options < Option > ;
334
337
clearFocusValueOnUpdate : boolean ;
335
338
prevWasFocused : boolean ;
336
339
inputIsHiddenAfterUpdate : boolean | null | undefined ;
337
340
prevProps : Props < Option , IsMulti , Group > | void ;
341
+ instancePrefix : string ;
338
342
}
339
343
340
344
interface CategorizedOption < Option > {
@@ -347,6 +351,11 @@ interface CategorizedOption<Option> {
347
351
index : number ;
348
352
}
349
353
354
+ interface FocusableOptionWithId < Option > {
355
+ data : Option ;
356
+ id : string ;
357
+ }
358
+
350
359
interface CategorizedGroup < Option , Group extends GroupBase < Option > > {
351
360
type : 'group' ;
352
361
data : Group ;
@@ -441,6 +450,31 @@ function buildFocusableOptionsFromCategorizedOptions<
441
450
) ;
442
451
}
443
452
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
+
444
478
function buildFocusableOptions <
445
479
Option ,
446
480
IsMulti extends boolean ,
@@ -499,6 +533,17 @@ function getNextFocusedOption<
499
533
? lastFocusedOption
500
534
: options [ 0 ] ;
501
535
}
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
+
502
547
const getOptionLabel = <
503
548
Option ,
504
549
IsMulti extends boolean ,
@@ -587,6 +632,8 @@ export default class Select<
587
632
state : State < Option , IsMulti , Group > = {
588
633
ariaSelection : null ,
589
634
focusedOption : null ,
635
+ focusedOptionId : null ,
636
+ focusableOptionsWithIds : [ ] ,
590
637
focusedValue : null ,
591
638
inputIsHidden : false ,
592
639
isFocused : false ,
@@ -595,6 +642,7 @@ export default class Select<
595
642
prevWasFocused : false ,
596
643
inputIsHiddenAfterUpdate : undefined ,
597
644
prevProps : undefined ,
645
+ instancePrefix : '' ,
598
646
} ;
599
647
600
648
// Misc. Instance Properties
@@ -605,10 +653,10 @@ export default class Select<
605
653
commonProps : any ; // TODO
606
654
initialTouchX = 0 ;
607
655
initialTouchY = 0 ;
608
- instancePrefix = '' ;
609
656
openAfterFocus = false ;
610
657
scrollToFocusedOptionOnUpdate = false ;
611
658
userIsDragging ?: boolean ;
659
+ isAppleDevice = isAppleDevice ( ) ;
612
660
613
661
// Refs
614
662
// ------------------------------
@@ -635,15 +683,21 @@ export default class Select<
635
683
636
684
constructor ( props : Props < Option , IsMulti , Group > ) {
637
685
super ( props ) ;
638
- this . instancePrefix =
686
+ this . state . instancePrefix =
639
687
'react-select-' + ( this . props . instanceId || ++ instanceId ) ;
640
688
this . state . selectValue = cleanValue ( props . value ) ;
641
-
642
689
// Set focusedOption if menuIsOpen is set on init (e.g. defaultMenuIsOpen)
643
690
if ( props . menuIsOpen && this . state . selectValue . length ) {
691
+ const focusableOptionsWithIds : FocusableOptionWithId < Option > [ ] =
692
+ this . getFocusableOptionsWithIds ( ) ;
644
693
const focusableOptions = this . buildFocusableOptions ( ) ;
645
694
const optionIndex = focusableOptions . indexOf ( this . state . selectValue [ 0 ] ) ;
695
+ this . state . focusableOptionsWithIds = focusableOptionsWithIds ;
646
696
this . state . focusedOption = focusableOptions [ optionIndex ] ;
697
+ this . state . focusedOptionId = getFocusedOptionId (
698
+ focusableOptionsWithIds ,
699
+ focusableOptions [ optionIndex ]
700
+ ) ;
647
701
}
648
702
}
649
703
@@ -658,6 +712,7 @@ export default class Select<
658
712
ariaSelection,
659
713
isFocused,
660
714
prevWasFocused,
715
+ instancePrefix,
661
716
} = state ;
662
717
const { options, value, menuIsOpen, inputValue, isMulti } = props ;
663
718
const selectValue = cleanValue ( value ) ;
@@ -672,13 +727,28 @@ export default class Select<
672
727
const focusableOptions = menuIsOpen
673
728
? buildFocusableOptions ( props , selectValue )
674
729
: [ ] ;
730
+
731
+ const focusableOptionsWithIds = menuIsOpen
732
+ ? buildFocusableOptionsWithIds (
733
+ buildCategorizedOptions ( props , selectValue ) ,
734
+ `${ instancePrefix } -option`
735
+ )
736
+ : [ ] ;
737
+
675
738
const focusedValue = clearFocusValueOnUpdate
676
739
? getNextFocusedValue ( state , selectValue )
677
740
: null ;
678
741
const focusedOption = getNextFocusedOption ( state , focusableOptions ) ;
742
+ const focusedOptionId = getFocusedOptionId (
743
+ focusableOptionsWithIds ,
744
+ focusedOption
745
+ ) ;
746
+
679
747
newMenuOptionsState = {
680
748
selectValue,
681
749
focusedOption,
750
+ focusedOptionId,
751
+ focusableOptionsWithIds,
682
752
focusedValue,
683
753
clearFocusValueOnUpdate : false ,
684
754
} ;
@@ -801,6 +871,7 @@ export default class Select<
801
871
action : 'menu-close' ,
802
872
prevInputValue : this . props . inputValue ,
803
873
} ) ;
874
+
804
875
this . props . onMenuClose ( ) ;
805
876
}
806
877
onInputChange ( newValue : string , actionMeta : InputActionMeta ) {
@@ -844,6 +915,7 @@ export default class Select<
844
915
inputIsHiddenAfterUpdate : false ,
845
916
focusedValue : null ,
846
917
focusedOption : focusableOptions [ openAtIndex ] ,
918
+ focusedOptionId : this . getFocusedOptionId ( focusableOptions [ openAtIndex ] ) ,
847
919
} ,
848
920
( ) => this . onMenuOpen ( )
849
921
) ;
@@ -921,6 +993,7 @@ export default class Select<
921
993
this . setState ( {
922
994
focusedOption : options [ nextFocus ] ,
923
995
focusedValue : null ,
996
+ focusedOptionId : this . getFocusedOptionId ( options [ nextFocus ] ) ,
924
997
} ) ;
925
998
}
926
999
onChange = (
@@ -941,7 +1014,9 @@ export default class Select<
941
1014
const { closeMenuOnSelect, isMulti, inputValue } = this . props ;
942
1015
this . onInputChange ( '' , { action : 'set-value' , prevInputValue : inputValue } ) ;
943
1016
if ( closeMenuOnSelect ) {
944
- this . setState ( { inputIsHiddenAfterUpdate : ! isMulti } ) ;
1017
+ this . setState ( {
1018
+ inputIsHiddenAfterUpdate : ! isMulti ,
1019
+ } ) ;
945
1020
this . onMenuClose ( ) ;
946
1021
}
947
1022
// when the select value should change, we should reset focusedValue
@@ -1050,6 +1125,20 @@ export default class Select<
1050
1125
} ;
1051
1126
}
1052
1127
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
+
1053
1142
getValue = ( ) => this . state . selectValue ;
1054
1143
1055
1144
cx = ( ...args : any ) => classNames ( this . props . classNamePrefix , ...args ) ;
@@ -1114,7 +1203,7 @@ export default class Select<
1114
1203
| 'placeholder'
1115
1204
| 'live-region'
1116
1205
) => {
1117
- return `${ this . instancePrefix } -${ element } ` ;
1206
+ return `${ this . state . instancePrefix } -${ element } ` ;
1118
1207
} ;
1119
1208
1120
1209
getComponents = ( ) => {
@@ -1437,7 +1526,13 @@ export default class Select<
1437
1526
if ( this . blockOptionHover || this . state . focusedOption === focusedOption ) {
1438
1527
return ;
1439
1528
}
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
+ } ) ;
1441
1536
} ;
1442
1537
shouldHideSelectedOptions = ( ) => {
1443
1538
return shouldHideSelectedOptions ( this . props ) ;
@@ -1536,7 +1631,9 @@ export default class Select<
1536
1631
return ;
1537
1632
case 'Escape' :
1538
1633
if ( menuIsOpen ) {
1539
- this . setState ( { inputIsHiddenAfterUpdate : false } ) ;
1634
+ this . setState ( {
1635
+ inputIsHiddenAfterUpdate : false ,
1636
+ } ) ;
1540
1637
this . onInputChange ( '' , {
1541
1638
action : 'menu-close' ,
1542
1639
prevInputValue : inputValue ,
@@ -1624,9 +1721,12 @@ export default class Select<
1624
1721
'aria-labelledby' : this . props [ 'aria-labelledby' ] ,
1625
1722
'aria-required' : required ,
1626
1723
role : 'combobox' ,
1724
+ 'aria-activedescendant' : this . isAppleDevice
1725
+ ? undefined
1726
+ : this . state . focusedOptionId || '' ,
1727
+
1627
1728
...( menuIsOpen && {
1628
1729
'aria-controls' : this . getElementId ( 'listbox' ) ,
1629
- 'aria-owns' : this . getElementId ( 'listbox' ) ,
1630
1730
} ) ,
1631
1731
...( ! isSearchable && {
1632
1732
'aria-readonly' : true ,
@@ -1891,6 +1991,8 @@ export default class Select<
1891
1991
onMouseMove : onHover ,
1892
1992
onMouseOver : onHover ,
1893
1993
tabIndex : - 1 ,
1994
+ role : 'option' ,
1995
+ 'aria-selected' : this . isAppleDevice ? undefined : isSelected , // is not supported on Apple devices
1894
1996
} ;
1895
1997
1896
1998
return (
@@ -1970,7 +2072,6 @@ export default class Select<
1970
2072
innerProps = { {
1971
2073
onMouseDown : this . onMenuMouseDown ,
1972
2074
onMouseMove : this . onMenuMouseMove ,
1973
- id : this . getElementId ( 'listbox' ) ,
1974
2075
} }
1975
2076
isLoading = { isLoading }
1976
2077
placement = { placement }
@@ -1988,6 +2089,11 @@ export default class Select<
1988
2089
this . getMenuListRef ( instance ) ;
1989
2090
scrollTargetRef ( instance ) ;
1990
2091
} }
2092
+ innerProps = { {
2093
+ role : 'listbox' ,
2094
+ 'aria-multiselectable' : commonProps . isMulti ,
2095
+ id : this . getElementId ( 'listbox' ) ,
2096
+ } }
1991
2097
isLoading = { isLoading }
1992
2098
maxHeight = { maxHeight }
1993
2099
focusedOption = { focusedOption }
@@ -2086,6 +2192,7 @@ export default class Select<
2086
2192
isFocused = { isFocused }
2087
2193
selectValue = { selectValue }
2088
2194
focusableOptions = { focusableOptions }
2195
+ isAppleDevice = { this . isAppleDevice }
2089
2196
/>
2090
2197
) ;
2091
2198
}
0 commit comments