@@ -10,19 +10,19 @@ import React, {
10
10
createContext ,
11
11
useCallback ,
12
12
useContext ,
13
- useEffect ,
14
13
useMemo ,
15
14
useRef ,
16
15
useReducer ,
17
16
Reducer ,
18
17
Dispatch ,
19
18
RefObject ,
20
19
ReactNode ,
20
+ RefCallback ,
21
21
} from "react" ;
22
22
23
23
import { getKeyBindingsManager } from "../KeyBindingsManager" ;
24
24
import { KeyBindingAction } from "./KeyboardShortcuts" ;
25
- import { FocusHandler , Ref } from "./roving/types" ;
25
+ import { FocusHandler } from "./roving/types" ;
26
26
27
27
/**
28
28
* Module to simplify implementing the Roving TabIndex accessibility technique
@@ -49,8 +49,8 @@ export function checkInputableElement(el: HTMLElement): boolean {
49
49
}
50
50
51
51
export interface IState {
52
- activeRef ?: Ref ;
53
- refs : Ref [ ] ;
52
+ activeNode ?: HTMLElement ;
53
+ nodes : HTMLElement [ ] ;
54
54
}
55
55
56
56
export interface IContext {
@@ -60,7 +60,7 @@ export interface IContext {
60
60
61
61
export const RovingTabIndexContext = createContext < IContext > ( {
62
62
state : {
63
- refs : [ ] , // list of refs in DOM order
63
+ nodes : [ ] , // list of nodes in DOM order
64
64
} ,
65
65
dispatch : ( ) => { } ,
66
66
} ) ;
@@ -76,7 +76,7 @@ export enum Type {
76
76
export interface IAction {
77
77
type : Exclude < Type , Type . Update > ;
78
78
payload : {
79
- ref : Ref ;
79
+ node : HTMLElement ;
80
80
} ;
81
81
}
82
82
@@ -87,12 +87,12 @@ interface UpdateAction {
87
87
88
88
type Action = IAction | UpdateAction ;
89
89
90
- const refSorter = ( a : Ref , b : Ref ) : number => {
90
+ const nodeSorter = ( a : HTMLElement , b : HTMLElement ) : number => {
91
91
if ( a === b ) {
92
92
return 0 ;
93
93
}
94
94
95
- const position = a . current ! . compareDocumentPosition ( b . current ! ) ;
95
+ const position = a . compareDocumentPosition ( b ) ;
96
96
97
97
if ( position & Node . DOCUMENT_POSITION_FOLLOWING || position & Node . DOCUMENT_POSITION_CONTAINED_BY ) {
98
98
return - 1 ;
@@ -106,54 +106,56 @@ const refSorter = (a: Ref, b: Ref): number => {
106
106
export const reducer : Reducer < IState , Action > = ( state : IState , action : Action ) => {
107
107
switch ( action . type ) {
108
108
case Type . Register : {
109
- if ( ! state . activeRef ) {
110
- // Our list of refs was empty, set activeRef to this first item
111
- state . activeRef = action . payload . ref ;
109
+ if ( ! state . activeNode ) {
110
+ // Our list of nodes was empty, set activeNode to this first item
111
+ state . activeNode = action . payload . node ;
112
112
}
113
113
114
+ if ( state . nodes . includes ( action . payload . node ) ) return state ;
115
+
114
116
// Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert
115
- state . refs . push ( action . payload . ref ) ;
116
- state . refs . sort ( refSorter ) ;
117
+ state . nodes . push ( action . payload . node ) ;
118
+ state . nodes . sort ( nodeSorter ) ;
117
119
118
120
return { ...state } ;
119
121
}
120
122
121
123
case Type . Unregister : {
122
- const oldIndex = state . refs . findIndex ( ( r ) => r === action . payload . ref ) ;
124
+ const oldIndex = state . nodes . findIndex ( ( r ) => r === action . payload . node ) ;
123
125
124
126
if ( oldIndex === - 1 ) {
125
127
return state ; // already removed, this should not happen
126
128
}
127
129
128
- if ( state . refs . splice ( oldIndex , 1 ) [ 0 ] === state . activeRef ) {
129
- // we just removed the active ref , need to replace it
130
- // pick the ref closest to the index the old ref was in
131
- if ( oldIndex >= state . refs . length ) {
132
- state . activeRef = findSiblingElement ( state . refs , state . refs . length - 1 , true ) ;
130
+ if ( state . nodes . splice ( oldIndex , 1 ) [ 0 ] === state . activeNode ) {
131
+ // we just removed the active node , need to replace it
132
+ // pick the node closest to the index the old node was in
133
+ if ( oldIndex >= state . nodes . length ) {
134
+ state . activeNode = findSiblingElement ( state . nodes , state . nodes . length - 1 , true ) ;
133
135
} else {
134
- state . activeRef =
135
- findSiblingElement ( state . refs , oldIndex ) || findSiblingElement ( state . refs , oldIndex , true ) ;
136
+ state . activeNode =
137
+ findSiblingElement ( state . nodes , oldIndex ) || findSiblingElement ( state . nodes , oldIndex , true ) ;
136
138
}
137
139
if ( document . activeElement === document . body ) {
138
140
// if the focus got reverted to the body then the user was likely focused on the unmounted element
139
- setTimeout ( ( ) => state . activeRef ?. current ?. focus ( ) , 0 ) ;
141
+ setTimeout ( ( ) => state . activeNode ?. focus ( ) , 0 ) ;
140
142
}
141
143
}
142
144
143
- // update the refs list
145
+ // update the nodes list
144
146
return { ...state } ;
145
147
}
146
148
147
149
case Type . SetFocus : {
148
- // if the ref doesn't change just return the same object reference to skip a re-render
149
- if ( state . activeRef === action . payload . ref ) return state ;
150
- // update active ref
151
- state . activeRef = action . payload . ref ;
150
+ // if the node doesn't change just return the same object reference to skip a re-render
151
+ if ( state . activeNode === action . payload . node ) return state ;
152
+ // update active node
153
+ state . activeNode = action . payload . node ;
152
154
return { ...state } ;
153
155
}
154
156
155
157
case Type . Update : {
156
- state . refs . sort ( refSorter ) ;
158
+ state . nodes . sort ( nodeSorter ) ;
157
159
return { ...state } ;
158
160
}
159
161
@@ -174,28 +176,28 @@ interface IProps {
174
176
}
175
177
176
178
export const findSiblingElement = (
177
- refs : RefObject < HTMLElement > [ ] ,
179
+ nodes : HTMLElement [ ] ,
178
180
startIndex : number ,
179
181
backwards = false ,
180
182
loop = false ,
181
- ) : RefObject < HTMLElement > | undefined => {
183
+ ) : HTMLElement | undefined => {
182
184
if ( backwards ) {
183
- for ( let i = startIndex ; i < refs . length && i >= 0 ; i -- ) {
184
- if ( refs [ i ] . current ?. offsetParent !== null ) {
185
- return refs [ i ] ;
185
+ for ( let i = startIndex ; i < nodes . length && i >= 0 ; i -- ) {
186
+ if ( nodes [ i ] ?. offsetParent !== null ) {
187
+ return nodes [ i ] ;
186
188
}
187
189
}
188
190
if ( loop ) {
189
- return findSiblingElement ( refs . slice ( startIndex + 1 ) , refs . length - 1 , true , false ) ;
191
+ return findSiblingElement ( nodes . slice ( startIndex + 1 ) , nodes . length - 1 , true , false ) ;
190
192
}
191
193
} else {
192
- for ( let i = startIndex ; i < refs . length && i >= 0 ; i ++ ) {
193
- if ( refs [ i ] . current ?. offsetParent !== null ) {
194
- return refs [ i ] ;
194
+ for ( let i = startIndex ; i < nodes . length && i >= 0 ; i ++ ) {
195
+ if ( nodes [ i ] ?. offsetParent !== null ) {
196
+ return nodes [ i ] ;
195
197
}
196
198
}
197
199
if ( loop ) {
198
- return findSiblingElement ( refs . slice ( 0 , startIndex ) , 0 , false , false ) ;
200
+ return findSiblingElement ( nodes . slice ( 0 , startIndex ) , 0 , false , false ) ;
199
201
}
200
202
}
201
203
} ;
@@ -211,7 +213,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
211
213
onKeyDown,
212
214
} ) => {
213
215
const [ state , dispatch ] = useReducer < Reducer < IState , Action > > ( reducer , {
214
- refs : [ ] ,
216
+ nodes : [ ] ,
215
217
} ) ;
216
218
217
219
const context = useMemo < IContext > ( ( ) => ( { state, dispatch } ) , [ state ] ) ;
@@ -227,17 +229,17 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
227
229
228
230
let handled = false ;
229
231
const action = getKeyBindingsManager ( ) . getAccessibilityAction ( ev ) ;
230
- let focusRef : RefObject < HTMLElement > | undefined ;
232
+ let focusNode : HTMLElement | undefined ;
231
233
// Don't interfere with input default keydown behaviour
232
234
// but allow people to move focus from it with Tab.
233
235
if ( ! handleInputFields && checkInputableElement ( ev . target as HTMLElement ) ) {
234
236
switch ( action ) {
235
237
case KeyBindingAction . Tab :
236
238
handled = true ;
237
- if ( context . state . refs . length > 0 ) {
238
- const idx = context . state . refs . indexOf ( context . state . activeRef ! ) ;
239
- focusRef = findSiblingElement (
240
- context . state . refs ,
239
+ if ( context . state . nodes . length > 0 ) {
240
+ const idx = context . state . nodes . indexOf ( context . state . activeNode ! ) ;
241
+ focusNode = findSiblingElement (
242
+ context . state . nodes ,
241
243
idx + ( ev . shiftKey ? - 1 : 1 ) ,
242
244
ev . shiftKey ,
243
245
) ;
@@ -251,15 +253,15 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
251
253
if ( handleHomeEnd ) {
252
254
handled = true ;
253
255
// move focus to first (visible) item
254
- focusRef = findSiblingElement ( context . state . refs , 0 ) ;
256
+ focusNode = findSiblingElement ( context . state . nodes , 0 ) ;
255
257
}
256
258
break ;
257
259
258
260
case KeyBindingAction . End :
259
261
if ( handleHomeEnd ) {
260
262
handled = true ;
261
263
// move focus to last (visible) item
262
- focusRef = findSiblingElement ( context . state . refs , context . state . refs . length - 1 , true ) ;
264
+ focusNode = findSiblingElement ( context . state . nodes , context . state . nodes . length - 1 , true ) ;
263
265
}
264
266
break ;
265
267
@@ -270,9 +272,9 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
270
272
( action === KeyBindingAction . ArrowRight && handleLeftRight )
271
273
) {
272
274
handled = true ;
273
- if ( context . state . refs . length > 0 ) {
274
- const idx = context . state . refs . indexOf ( context . state . activeRef ! ) ;
275
- focusRef = findSiblingElement ( context . state . refs , idx + 1 , false , handleLoop ) ;
275
+ if ( context . state . nodes . length > 0 ) {
276
+ const idx = context . state . nodes . indexOf ( context . state . activeNode ! ) ;
277
+ focusNode = findSiblingElement ( context . state . nodes , idx + 1 , false , handleLoop ) ;
276
278
}
277
279
}
278
280
break ;
@@ -284,9 +286,9 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
284
286
( action === KeyBindingAction . ArrowLeft && handleLeftRight )
285
287
) {
286
288
handled = true ;
287
- if ( context . state . refs . length > 0 ) {
288
- const idx = context . state . refs . indexOf ( context . state . activeRef ! ) ;
289
- focusRef = findSiblingElement ( context . state . refs , idx - 1 , true , handleLoop ) ;
289
+ if ( context . state . nodes . length > 0 ) {
290
+ const idx = context . state . nodes . indexOf ( context . state . activeNode ! ) ;
291
+ focusNode = findSiblingElement ( context . state . nodes , idx - 1 , true , handleLoop ) ;
290
292
}
291
293
}
292
294
break ;
@@ -298,17 +300,17 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
298
300
ev . stopPropagation ( ) ;
299
301
}
300
302
301
- if ( focusRef ) {
302
- focusRef . current ?. focus ( ) ;
303
+ if ( focusNode ) {
304
+ focusNode ?. focus ( ) ;
303
305
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
304
306
dispatch ( {
305
307
type : Type . SetFocus ,
306
308
payload : {
307
- ref : focusRef ,
309
+ node : focusNode ,
308
310
} ,
309
311
} ) ;
310
312
if ( scrollIntoView ) {
311
- focusRef . current ?. scrollIntoView ( scrollIntoView ) ;
313
+ focusNode ?. scrollIntoView ( scrollIntoView ) ;
312
314
}
313
315
}
314
316
} ,
@@ -337,46 +339,61 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
337
339
) ;
338
340
} ;
339
341
340
- // Hook to register a roving tab index
341
- // inputRef parameter specifies the ref to use
342
- // onFocus should be called when the index gained focus in any manner
343
- // isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
344
- // ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
342
+ /**
343
+ * Hook to register a roving tab index.
344
+ *
345
+ * inputRef is an optional argument; when passed this ref points to the DOM element
346
+ * to which the callback ref is attached.
347
+ *
348
+ * Returns:
349
+ * onFocus should be called when the index gained focus in any manner.
350
+ * isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`.
351
+ * ref is a callback ref that should be passed to a DOM node which will be used for DOM compareDocumentPosition.
352
+ * nodeRef is a ref that points to the DOM element to which the ref mentioned above is attached.
353
+ *
354
+ * nodeRef = inputRef when inputRef argument is provided.
355
+ */
345
356
export const useRovingTabIndex = < T extends HTMLElement > (
346
357
inputRef ?: RefObject < T > ,
347
- ) : [ FocusHandler , boolean , RefObject < T > ] => {
358
+ ) : [ FocusHandler , boolean , RefCallback < T > , RefObject < T | null > ] => {
348
359
const context = useContext ( RovingTabIndexContext ) ;
349
- let ref = useRef < T > ( null ) ;
360
+
361
+ let nodeRef = useRef < T | null > ( null ) ;
350
362
351
363
if ( inputRef ) {
352
364
// if we are given a ref, use it instead of ours
353
- ref = inputRef ;
365
+ nodeRef = inputRef ;
354
366
}
355
367
356
- // setup (after refs)
357
- useEffect ( ( ) => {
358
- context . dispatch ( {
359
- type : Type . Register ,
360
- payload : { ref } ,
361
- } ) ;
362
- // teardown
363
- return ( ) => {
368
+ const ref = useCallback ( ( node : T | null ) => {
369
+ if ( node ) {
370
+ nodeRef . current = node ;
371
+ context . dispatch ( {
372
+ type : Type . Register ,
373
+ payload : { node } ,
374
+ } ) ;
375
+ } else {
364
376
context . dispatch ( {
365
377
type : Type . Unregister ,
366
- payload : { ref } ,
378
+ payload : { node : nodeRef . current ! } ,
367
379
} ) ;
368
- } ;
380
+ nodeRef . current = null ;
381
+ }
369
382
} , [ ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
370
383
371
384
const onFocus = useCallback ( ( ) => {
385
+ if ( ! nodeRef . current ) {
386
+ console . warn ( "useRovingTabIndex.onFocus called but the react ref does not point to any DOM element!" ) ;
387
+ return ;
388
+ }
372
389
context . dispatch ( {
373
390
type : Type . SetFocus ,
374
- payload : { ref } ,
391
+ payload : { node : nodeRef . current } ,
375
392
} ) ;
376
393
} , [ ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
377
394
378
- const isActive = context . state . activeRef === ref ;
379
- return [ onFocus , isActive , ref ] ;
395
+ const isActive = context . state . activeNode === nodeRef . current ;
396
+ return [ onFocus , isActive , ref , nodeRef ] ;
380
397
} ;
381
398
382
399
// re-export the semantic helper components for simplicity
0 commit comments