import { FocusableElement , isFocusable , tabbable } from 'tabbable' ; import { MutableRefObject , useEffect , useRef , useState } from 'react' ; const findParentElement = ( el , cb ) => { let nextEl = el ; while ( nextEl ) { if ( cb ( nextEl ) ) { return nextEl ; } nextEl = nextEl . parentElement ; } } ; export const getTargetRootElement = ( start ) => { const result = findParentElement ( start , ( el ) => { const htmlEl = el as HTMLElement ; return htmlEl . dataset ?. focusRoot === '1' ; } ) ; return result as HTMLElement | undefined ; } ; const manager = ( ( ) => { const data = [ ] ; const remove = ( item ) => { const idx = data . indexOf ( item ) ; if ( idx === - 1 ) { return ; } data . splice ( idx , 1 ) ; } ; return { add : ( item ) => { data . push ( item ) ; return ( ) => { remove ( item ) ; } ; } , isLast : ( item ) => ( data . length ? data [ data . length - 1 ] === item : false ) , remove , } ; } ) ( ) ; interface Props { rootRef : MutableRefObject < HTMLDivElement | null > ; active ? : boolean ; restoreFocus ? : boolean ; preventScroll ? : boolean ; disableRestoreByPointer ? : boolean ; enableInitialFocus ? : boolean ; } const useFocusTrap = ( { active = true , enableInitialFocus = true , restoreFocus = true , preventScroll = true , disableRestoreByPointer = true , rootRef , } ) => { const [ id ] = useState ( { } ) ; const nodeToRestoreRef = useRef < Element | null > ( null ) ; const prevOpenRef = useRef ( false ) ; const pendingRef = useRef ( '' ) ; useEffect ( ( ) => { prevOpenRef . current = active ; } , [ active ] ) ; if ( ! prevOpenRef . current && active ) { pendingRef . current = '1' ; nodeToRestoreRef . current = document . activeElement ; } useEffect ( ( ) => { if ( ! active || ! rootRef . current ) { return ; } manager . add ( id ) ; const isLastFocusTrap = ( ) => { return manager . isLast ( id ) ; } ; rootRef . current . removeAttribute ( 'data-focus-pending' ) ; pendingRef . current = '' ; let isMouseDownFocusIn = false ; let mouseUpTime ; let keyDownTime ; const focusElement = ( node ? , fallback ? ) => { if ( node === document . activeElement ) { return ; } if ( ! node ?. focus ) { focusElement ( fallback ) ; return ; } node . focus ( { preventScroll : ! ! preventScroll } ) ; } ; const initFocus = ( rootElement ) => { const target = rootElement . querySelector ( '[data-focus-trap-fallback]' ) ; if ( ! target ) { focusElement ( rootElement ) ; return ; } focusElement ( target as HTMLElement , rootElement ) ; } ; const handleFocusIn = ( event ) => { const { current : rootElement } = rootRef ; if ( ! rootElement || ! isLastFocusTrap ( ) || ! document . hasFocus ( ) ) { return ; } const targetRootElement = getTargetRootElement ( document . activeElement ) ; if ( targetRootElement ?. dataset ?. focusPending ) { return ; } if ( targetRootElement === rootElement ) { return ; } if ( isMouseDownFocusIn ) { isMouseDownFocusIn = false ; return ; } event . stopImmediatePropagation ( ) ; initFocus ( rootElement ) ; } ; const handleKeyDown = ( event ) => { keyDownTime = Date . now ( ) ; if ( ! isLastFocusTrap ( ) || event . key !== 'Tab' || ! rootRef . current ) { return ; } const { current : rootElement } = rootRef ; const tabbableElements = tabbable ( rootElement , { includeContainer : false } ) ; if ( tabbableElements . length === 0 ) { focusElement ( rootElement ) ; return ; } const firstTabbableNode = tabbableElements [ 0 ] ; const lastTabbableNode = tabbableElements [ tabbableElements . length - 1 ] ; if ( event . shiftKey && event . target === firstTabbableNode ) { event . preventDefault ( ) ; focusElement ( lastTabbableNode , rootElement ) ; } if ( ! event . shiftKey && event . target === lastTabbableNode ) { event . preventDefault ( ) ; focusElement ( firstTabbableNode , rootElement ) ; } } ; const contain = ( root ) => { if ( ! root . contains ( document . activeElement ) ) { if ( ! enableInitialFocus ) { focusElement ( root ) ; } else { initFocus ( root ) ; } } } ; const containIntervalHandle = window . setInterval ( ( ) => { if ( document . activeElement ?. tagName === 'BODY' && isLastFocusTrap ( ) && rootRef . current ) { contain ( rootRef . current ) ; } } , 100 ) ; contain ( rootRef . current ) ; const handleMouseDown = ( ) => { isMouseDownFocusIn = true ; } ; const handleMouseUp = ( ) => { isMouseDownFocusIn = false ; mouseUpTime = Date . now ( ) ; } ; document . addEventListener ( 'mousedown' , handleMouseDown , true ) ; document . addEventListener ( 'mouseup' , handleMouseUp , true ) ; document . addEventListener ( 'focusin' , handleFocusIn , true ) ; document . addEventListener ( 'keydown' , handleKeyDown , true ) ; return ( ) => { window . clearInterval ( containIntervalHandle ) ; document . removeEventListener ( 'focusin' , handleFocusIn , true ) ; document . removeEventListener ( 'keydown' , handleKeyDown , true ) ; document . removeEventListener ( 'mousedown' , handleMouseDown , true ) ; document . removeEventListener ( 'mouseup' , handleMouseUp , true ) ; manager . remove ( id ) ; const currentActiveElement = document . activeElement ; const targetRootElement = getTargetRootElement ( currentActiveElement ) ; const isFocusInAnotherRoot = targetRootElement && targetRootElement !== rootRef . current ; const isFocusInThisRoot = targetRootElement && targetRootElement === rootRef . current ; const isCurrentActiveElementFocusable = currentActiveElement && isFocusable ( currentActiveElement ) ; const isClosedByPointer = ( mouseUpTime || 0 ) > ( keyDownTime || 0 ) ; const nodeToRestore = nodeToRestoreRef . current as HTMLElement | undefined ; nodeToRestoreRef . current = null ; if ( ! restoreFocus || ( disableRestoreByPointer && isClosedByPointer ) || ! nodeToRestore || isFocusInAnotherRoot || ( ! isFocusInThisRoot && isCurrentActiveElementFocusable ) ) { return ; } nodeToRestore . focus ?. ( ) ; } ; } , [ active ] ) ; return active ? { ... ( pendingRef . current && { 'data-focus-pending' : pendingRef . current } ) , 'data-focus-root' : '1' , tabIndex : - 1 , } : { 'data-focus-root' : '1' , } ; } ; export default useFocusTrap ;