@@ -70,21 +70,25 @@ function Hamburger() {
70
70
let nav = document . querySelector ( '.' + docsStyle . nav ) ;
71
71
let main = document . querySelector ( 'main' ) ;
72
72
let themeSwitcher = event . target . parentElement . nextElementSibling ;
73
-
73
+ let themeSwitcherButton = themeSwitcher . querySelector ( 'button' ) ;
74
74
nav . classList . toggle ( docsStyle . visible ) ;
75
75
76
76
if ( nav . classList . contains ( docsStyle . visible ) ) {
77
77
setIsPressed ( true ) ;
78
78
main . setAttribute ( 'aria-hidden' , 'true' ) ;
79
79
themeSwitcher . setAttribute ( 'aria-hidden' , 'true' ) ;
80
- themeSwitcher . querySelector ( 'button' ) . tabIndex = - 1 ;
80
+ if ( themeSwitcherButton ) {
81
+ themeSwitcherButton . tabIndex = - 1 ;
82
+ }
81
83
nav . tabIndex = - 1 ;
82
84
nav . focus ( ) ;
83
85
} else {
84
86
setIsPressed ( false ) ;
85
87
main . removeAttribute ( 'aria-hidden' ) ;
86
88
themeSwitcher . removeAttribute ( 'aria-hidden' ) ;
87
- themeSwitcher . querySelector ( 'button' ) . removeAttribute ( 'tabindex' ) ;
89
+ if ( themeSwitcherButton ) {
90
+ themeSwitcherButton . removeAttribute ( 'tabindex' ) ;
91
+ }
88
92
nav . removeAttribute ( 'tabindex' ) ;
89
93
}
90
94
} ;
@@ -98,16 +102,21 @@ function Hamburger() {
98
102
99
103
/* remove visible className and aria-attributes that make nav behave as a modal */
100
104
let removeVisible = ( isNotResponsive = false ) => {
101
- hamburgerButton . setAttribute ( 'aria-pressed' , 'false' ) ;
102
-
103
- if ( nav . contains ( document . activeElement ) && ! isNotResponsive ) {
104
- hamburgerButton . focus ( ) ;
105
+ setIsPressed ( false ) ;
106
+ let button = hamburgerButton . querySelector ( 'button' ) ;
107
+ if ( button ) {
108
+ if ( nav . contains ( document . activeElement ) && ! isNotResponsive ) {
109
+ button . focus ( ) ;
110
+ }
105
111
}
106
112
107
113
nav . classList . remove ( docsStyle . visible ) ;
108
114
main . removeAttribute ( 'aria-hidden' ) ;
109
115
themeSwitcher . removeAttribute ( 'aria-hidden' ) ;
110
- themeSwitcher . querySelector ( 'button' ) . removeAttribute ( 'tabindex' ) ;
116
+ let themeSwitcherButton = themeSwitcher . querySelector ( 'button' ) ;
117
+ if ( themeSwitcherButton ) {
118
+ themeSwitcherButton . removeAttribute ( 'tabindex' ) ;
119
+ }
111
120
nav . removeAttribute ( 'tabindex' ) ;
112
121
} ;
113
122
@@ -171,7 +180,7 @@ function Hamburger() {
171
180
172
181
return (
173
182
< div className = { docsStyle . hamburgerButton } title = "Open navigation panel" role = "presentation" >
174
- < ActionButton onPress = { onPress } aria-label = "Open navigation panel" aria-pressed = { isPressed ? isPressed : undefined } >
183
+ < ActionButton onPress = { onPress } aria-label = "Open navigation panel" aria-pressed = { isPressed ? isPressed : 'false' } >
175
184
< ShowMenu />
176
185
</ ActionButton >
177
186
</ div >
@@ -182,20 +191,116 @@ function DocSearch() {
182
191
useEffect ( ( ) => {
183
192
// the following comes from docsearch.min.js
184
193
// eslint-disable-next-line no-undef
185
- docsearch ( {
194
+ const search = docsearch ( {
186
195
apiKey : '9b5a0967c8bb751b5048ecfc99917979' ,
187
196
indexName : 'react-spectrum' ,
188
197
inputSelector : '#algolia-doc-search' ,
189
198
debug : false // Set debug to true to inspect the dropdown
190
199
} ) ;
200
+
201
+ // autocomplete:opened event handler
202
+ search . autocomplete . on ( 'autocomplete:opened' , event => {
203
+ const input = event . target ;
204
+
205
+ // WAI-ARIA 1.2 uses aria-controls rather than aria-owns on combobox.
206
+ if ( ! input . hasAttribute ( 'aria-controls' ) && input . hasAttribute ( 'aria-owns' ) ) {
207
+ input . setAttribute ( 'aria-controls' , input . getAttribute ( 'aria-owns' ) ) ;
208
+ }
209
+
210
+ // Listbox dropdown should have an accessibility name.
211
+ const listbox = input . parentElement . querySelector ( `#${ input . getAttribute ( 'aria-controls' ) } ` ) ;
212
+ listbox . setAttribute ( 'aria-label' , 'Search results' ) ;
213
+ } ) ;
214
+
215
+ // autocomplete:updated event handler
216
+ search . autocomplete . on ( 'autocomplete:updated' , event => {
217
+ const input = event . target ;
218
+ const listbox = input . parentElement . querySelector ( `#${ input . getAttribute ( 'aria-controls' ) } ` ) ;
219
+
220
+ // Add aria-hidden to the logo in the footer so that it does not break the listbox accessibility tree structure.
221
+ const footer = listbox . querySelector ( '.algolia-docsearch-footer' ) ;
222
+ if ( footer && ! footer . hasAttribute ( 'aria-hidden' ) ) {
223
+ footer . setAttribute ( 'aria-hidden' , 'true' ) ;
224
+ footer . querySelector ( 'a[href]' ) . tabIndex = - 1 ;
225
+ }
226
+
227
+ // With no results, the message should be an option in the listbox.
228
+ const noResults = listbox . querySelector ( '.algolia-docsearch-suggestion--no-results' ) ;
229
+ if ( noResults ) {
230
+ noResults . setAttribute ( 'role' , 'option' ) ;
231
+
232
+ // Use aria-live to ensure that the noResults message gets announced.
233
+ noResults . querySelector ( '.algolia-docsearch-suggestion--title' ) . setAttribute ( 'aria-live' , 'assertive' ) ;
234
+ }
235
+
236
+ // Clean up WAI-ARIA listbox structure by setting role=presentation to non-semantic div and span elements.
237
+ [ ...listbox . querySelectorAll ( 'div:not([role]), span:not([role])' ) ] . forEach ( element => element . setAttribute ( 'role' , 'presentation' ) ) ;
238
+
239
+ // Clean up WAI-ARIA listbox structure by correcting improper nesting of interactive controls.
240
+ [ ...listbox . querySelectorAll ( '.ds-suggestion[role="option"]' ) ] . forEach ( element => {
241
+ const link = element . querySelector ( 'a.algolia-docsearch-suggestion' ) ;
242
+ if ( link ) {
243
+
244
+ // Remove static aria-label="Link to the result" that causes all options to be named the same.
245
+ link . removeAttribute ( 'aria-label' ) ;
246
+
247
+ // The interactive element should have role="option", a unique id, and tabIndex.
248
+ link . setAttribute ( 'role' , 'option' ) ;
249
+ link . id = `${ element . id } -link` ;
250
+ link . tabIndex = - 1 ;
251
+
252
+ // containing element should have role="presentation"
253
+ element . setAttribute ( 'role' , 'presentation' ) ;
254
+
255
+ // Move aria-selected to the link, and update aria-activedescendant on input.
256
+ if ( element . hasAttribute ( 'aria-selected' ) ) {
257
+ link . setAttribute ( 'aria-selected' , element . getAttribute ( 'aria-selected' ) ) ;
258
+ element . removeAttribute ( 'aria-selected' ) ;
259
+ input . setAttribute ( 'aria-activedescendant' , link . id ) ;
260
+ }
261
+
262
+ // Fix double voicing of options when subcategory matches suggestion title.
263
+ const subcategoryColumn = link . querySelector ( '.algolia-docsearch-suggestion--subcategory-column' ) ;
264
+ const suggestionTitle = link . querySelector ( '.algolia-docsearch-suggestion--title' ) ;
265
+ if ( subcategoryColumn . textContent . trim ( ) === suggestionTitle . textContent . trim ( ) ) {
266
+ subcategoryColumn . setAttribute ( 'aria-hidden' , 'true' ) ;
267
+ }
268
+ }
269
+ } ) ;
270
+ } ) ;
271
+
272
+ // When navigating listbox, move aria-selected to link.
273
+ search . autocomplete . on ( 'autocomplete:cursorchanged' , event => {
274
+ const input = event . target ;
275
+ const listbox = input . parentElement . querySelector ( `#${ input . getAttribute ( 'aria-controls' ) } ` ) ;
276
+ let element = listbox . querySelector ( 'a.algolia-docsearch-suggestion[aria-selected]' ) ;
277
+ if ( element ) {
278
+ element . removeAttribute ( 'aria-selected' ) ;
279
+ }
280
+
281
+ element = listbox . querySelector ( '.ds-suggestion.ds-cursor[aria-selected]' ) ;
282
+ if ( element ) {
283
+ let link = element . querySelector ( 'a.algolia-docsearch-suggestion' ) ;
284
+
285
+ // Move aria-selected to the link, and update aria-activedescendant on input.
286
+ if ( link ) {
287
+ link . id = `${ element . id } -link` ;
288
+ link . setAttribute ( 'aria-selected' , 'true' ) ;
289
+ input . setAttribute ( 'aria-activedescendant' , link . id ) ;
290
+ element . removeAttribute ( 'aria-selected' ) ;
291
+ }
292
+ }
293
+ } ) ;
191
294
} , [ ] ) ;
192
295
193
296
return (
194
- < SearchField
195
- aria-label = "Search"
196
- UNSAFE_className = { docsStyle . docSearchBox }
197
- id = "algolia-doc-search"
198
- placeholder = "Search" />
297
+ < div role = "search" >
298
+ < SearchField
299
+ aria-label = "Search"
300
+ UNSAFE_className = { docsStyle . docSearchBox }
301
+ id = "algolia-doc-search"
302
+ placeholder = "Search" />
303
+ </ div >
199
304
) ;
200
305
}
201
306
0 commit comments