@@ -120,8 +120,8 @@ export function FocusScope(props: FocusScopeProps) {
120
120
} , [ parentScope ] ) ;
121
121
122
122
useAutoFocus ( scopeRef . current , autoFocus ) ;
123
- useFocusContainment ( scopeRef . current , contain ) ;
124
- useRestoreFocus ( scopeRef . current , restoreFocus , contain ) ;
123
+ let nodeToRestoreRef = useRestoreFocus ( scopeRef . current , restoreFocus ) ;
124
+ useTabOrderSplice ( scopeRef . current , contain , nodeToRestoreRef ) ;
125
125
126
126
let focusManager = createFocusManagerForScope ( scopeRef . current ) ;
127
127
@@ -225,18 +225,18 @@ function getScopeRoot(scope: Scope) {
225
225
return scope [ 0 ] . parentElement ;
226
226
}
227
227
228
- function useFocusContainment ( scope : Scope , contain : boolean ) {
228
+ function useTabOrderSplice ( scope : Scope , contain : boolean , domRef : React . RefObject < HTMLElement > | undefined ) {
229
229
let focusedNode = useRef < HTMLElement > ( ) ;
230
230
231
231
let raf = useRef ( null ) ;
232
232
useLayoutEffect ( ( ) => {
233
- if ( ! contain ) {
234
- return ;
235
- }
236
-
237
233
// Handle the Tab key to contain focus within the scope
238
- let onKeyDown = ( e ) => {
239
- if ( e . key !== 'Tab' || e . altKey || e . ctrlKey || e . metaKey || scope !== containedScope ) {
234
+ let onKeyDown = ( e : KeyboardEvent ) => {
235
+ if ( e . key !== 'Tab' || e . defaultPrevented || e . altKey || e . ctrlKey || e . metaKey ) {
236
+ return ;
237
+ }
238
+
239
+ if ( contain && scope !== containedScope ) {
240
240
return ;
241
241
}
242
242
@@ -245,29 +245,51 @@ function useFocusContainment(scope: Scope, contain: boolean) {
245
245
return ;
246
246
}
247
247
248
- let walker = getFocusableTreeWalker ( getScopeRoot ( scope ) , { tabbable : true } , scope ) ;
248
+ // Create a DOM tree walker that matches all tabbable elements (and when contained, filtered to the current scope)
249
+ let walker = getFocusableTreeWalker ( getScopeRoot ( scope ) , { tabbable : true } , contain ? scope : undefined ) ;
250
+
251
+ // Find the next tabbable element after the currently focused element
249
252
walker . currentNode = focusedElement ;
250
253
let nextElement = ( e . shiftKey ? walker . previousNode ( ) : walker . nextNode ( ) ) as HTMLElement ;
251
- if ( ! nextElement ) {
252
- walker . currentNode = e . shiftKey ? scope [ scope . length - 1 ] . nextElementSibling : scope [ 0 ] . previousElementSibling ;
253
- nextElement = ( e . shiftKey ? walker . previousNode ( ) : walker . nextNode ( ) ) as HTMLElement ;
254
+
255
+ if ( contain ) {
256
+ if ( ! nextElement ) {
257
+ // wrap focus to the opposite end of the scope
258
+ walker . currentNode = e . shiftKey ? scope [ scope . length - 1 ] . nextElementSibling : scope [ 0 ] . previousElementSibling ;
259
+ nextElement = ( e . shiftKey ? walker . previousNode ( ) : walker . nextNode ( ) ) as HTMLElement ;
260
+ }
261
+ } else if ( domRef ?. current ) {
262
+ walker . currentNode = domRef . current ;
263
+
264
+ // Skip over elements within the scope, in case the scope immediately follows the domRef.
265
+ do {
266
+ nextElement = ( e . shiftKey ? walker . previousNode ( ) : walker . nextNode ( ) ) as HTMLElement ;
267
+ } while ( isElementInScope ( nextElement , scope ) ) ;
268
+
269
+ // If there is no next element and the domRef isn't within a FocusScope (i.e. we are leaving the top level focus scope)
270
+ // then move focus to the body.
271
+ // Otherwise restore focus to the domRef (e.g menu within a popover -> tabbing to close the menu should move focus to menu trigger)
272
+ if ( ! nextElement && ! isElementInAnyScope ( domRef . current ) ) {
273
+ focusedElement . blur ( ) ;
274
+ }
254
275
}
255
276
256
- e . preventDefault ( ) ;
257
277
if ( nextElement ) {
278
+ // prevent native focus movement
279
+ e . preventDefault ( ) ;
258
280
focusElement ( nextElement , true ) ;
259
281
}
260
282
} ;
261
283
262
- let onFocus = ( e ) => {
284
+ let onFocus = ( e : FocusEvent ) => {
263
285
// If focusing an element in a child scope of the currently active scope, the child becomes active.
264
286
// Moving out of the active scope to an ancestor is not allowed.
265
- if ( isElementInScope ( e . target , scope ) ) {
287
+ if ( isElementInScope ( e . target as HTMLElement , scope ) ) {
266
288
if ( ! containedScope || isAncestorScope ( containedScope , scope ) ) {
267
289
containedScope = scope ;
268
- focusedNode . current = e . target ;
290
+ focusedNode . current = e . target as HTMLElement ;
269
291
}
270
- } else if ( scope === containedScope && ! isElementInChildScope ( e . target , scope ) ) {
292
+ } else if ( scope === containedScope && ! isElementInChildScope ( e . target as HTMLElement , scope ) ) {
271
293
// If a focus event occurs outside the active scope (e.g. user tabs from browser location bar),
272
294
// restore focus to the previously focused node or the first tabbable element in the active scope.
273
295
if ( focusedNode . current ) {
@@ -276,31 +298,35 @@ function useFocusContainment(scope: Scope, contain: boolean) {
276
298
focusFirstInScope ( containedScope ) ;
277
299
}
278
300
} else if ( scope === containedScope ) {
279
- focusedNode . current = e . target ;
301
+ focusedNode . current = e . target as HTMLElement ;
280
302
}
281
303
} ;
282
304
283
- let onBlur = ( e ) => {
305
+ let onBlur = ( e : FocusEvent ) => {
284
306
// Firefox doesn't shift focus back to the Dialog properly without this
285
307
raf . current = requestAnimationFrame ( ( ) => {
286
308
// Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe
287
309
if ( scope === containedScope && ! isElementInChildScope ( document . activeElement , scope ) ) {
288
310
containedScope = scope ;
289
- focusedNode . current = e . target ;
311
+ focusedNode . current = e . target as HTMLElement ;
290
312
focusedNode . current . focus ( ) ;
291
313
}
292
314
} ) ;
293
315
} ;
294
316
295
317
document . addEventListener ( 'keydown' , onKeyDown , false ) ;
296
- document . addEventListener ( 'focusin' , onFocus , false ) ;
297
- document . addEventListener ( 'focusout' , onBlur , false ) ;
318
+ if ( contain ) {
319
+ document . addEventListener ( 'focusin' , onFocus , false ) ;
320
+ document . addEventListener ( 'focusout' , onBlur , false ) ;
321
+ }
298
322
return ( ) => {
299
323
document . removeEventListener ( 'keydown' , onKeyDown , false ) ;
300
- document . removeEventListener ( 'focusin' , onFocus , false ) ;
301
- document . removeEventListener ( 'focusout' , onBlur , false ) ;
324
+ if ( contain ) {
325
+ document . removeEventListener ( 'focusin' , onFocus , false ) ;
326
+ document . removeEventListener ( 'focusout' , onBlur , false ) ;
327
+ }
302
328
} ;
303
- } , [ scope , contain ] ) ;
329
+ } , [ scope , contain , domRef ] ) ;
304
330
305
331
// eslint-disable-next-line arrow-body-style
306
332
useEffect ( ( ) => {
@@ -379,84 +405,28 @@ function useAutoFocus(scope: Scope, autoFocus: boolean) {
379
405
} , [ scope ] ) ;
380
406
}
381
407
382
- function useRestoreFocus ( scope : Scope , restoreFocus : boolean , contain : boolean ) {
408
+ function useRestoreFocus ( scope : Scope , restoreFocus : boolean ) {
383
409
// create a ref during render instead of useLayoutEffect so the active element is saved before a child with autoFocus=true mounts.
384
- const nodeToRestore = useRef ( typeof document !== 'undefined' ? document . activeElement as HTMLElement : null ) ;
410
+ const nodeToRestoreRef = useRef ( typeof document !== 'undefined' ? document . activeElement as HTMLElement : null ) ;
385
411
useLayoutEffect ( ( ) => {
412
+ let nodeToRestore = nodeToRestoreRef . current ;
413
+
386
414
if ( ! restoreFocus ) {
387
415
return ;
388
416
}
389
417
390
- // Handle the Tab key so that tabbing out of the scope goes to the next element
391
- // after the node that had focus when the scope mounted. This is important when
392
- // using portals for overlays, so that focus goes to the expected element when
393
- // tabbing out of the overlay.
394
- let onKeyDown = ( e : KeyboardEvent ) => {
395
- if ( e . key !== 'Tab' || e . altKey || e . ctrlKey || e . metaKey ) {
396
- return ;
397
- }
398
-
399
- let focusedElement = document . activeElement as HTMLElement ;
400
- if ( ! isElementInScope ( focusedElement , scope ) ) {
401
- return ;
402
- }
403
-
404
- // Create a DOM tree walker that matches all tabbable elements
405
- let walker = getFocusableTreeWalker ( document . body , { tabbable : true } ) ;
406
-
407
- // Find the next tabbable element after the currently focused element
408
- walker . currentNode = focusedElement ;
409
- let nextElement = ( e . shiftKey ? walker . previousNode ( ) : walker . nextNode ( ) ) as HTMLElement ;
410
-
411
- if ( ! document . body . contains ( nodeToRestore . current ) || nodeToRestore . current === document . body ) {
412
- nodeToRestore . current = null ;
413
- }
414
-
415
- // If there is no next element, or it is outside the current scope, move focus to the
416
- // next element after the node to restore to instead.
417
- if ( ( ! nextElement || ! isElementInScope ( nextElement , scope ) ) && nodeToRestore . current ) {
418
- walker . currentNode = nodeToRestore . current ;
419
-
420
- // Skip over elements within the scope, in case the scope immediately follows the node to restore.
421
- do {
422
- nextElement = ( e . shiftKey ? walker . previousNode ( ) : walker . nextNode ( ) ) as HTMLElement ;
423
- } while ( isElementInScope ( nextElement , scope ) ) ;
424
-
425
- e . preventDefault ( ) ;
426
- e . stopPropagation ( ) ;
427
- if ( nextElement ) {
428
- focusElement ( nextElement , true ) ;
429
- } else {
430
- // If there is no next element and the nodeToRestore isn't within a FocusScope (i.e. we are leaving the top level focus scope)
431
- // then move focus to the body.
432
- // Otherwise restore focus to the nodeToRestore (e.g menu within a popover -> tabbing to close the menu should move focus to menu trigger)
433
- if ( ! isElementInAnyScope ( nodeToRestore . current ) ) {
434
- focusedElement . blur ( ) ;
435
- } else {
436
- focusElement ( nodeToRestore . current , true ) ;
437
- }
438
- }
439
- }
440
- } ;
441
-
442
- if ( ! contain ) {
443
- document . addEventListener ( 'keydown' , onKeyDown , true ) ;
444
- }
445
-
446
418
return ( ) => {
447
- if ( ! contain ) {
448
- document . removeEventListener ( 'keydown' , onKeyDown , true ) ;
449
- }
450
-
451
419
if ( restoreFocus && nodeToRestore && isElementInScope ( document . activeElement , scope ) ) {
452
420
requestAnimationFrame ( ( ) => {
453
- if ( document . body . contains ( nodeToRestore . current ) ) {
454
- focusElement ( nodeToRestore . current ) ;
421
+ if ( document . body . contains ( nodeToRestore ) ) {
422
+ focusElement ( nodeToRestore ) ;
455
423
}
456
424
} ) ;
457
425
}
458
426
} ;
459
- } , [ scope , restoreFocus , contain ] ) ;
427
+ } , [ scope , restoreFocus ] ) ;
428
+
429
+ return restoreFocus ? nodeToRestoreRef : undefined ;
460
430
}
461
431
462
432
/**
0 commit comments