import { RefObject , createContext , useCallback , useContext , useEffect , useMemo } from "react" ; import { createFocusableTreeWalker , isFocusableElement } from "./focusableTreeWalker" ; import { isNil } from "./assertions" ; import { useRefState } from "./useRefState" ; export interface ChildScopesOptions { includeChildScopes ? : boolean ; } export type ScopeChangeEventHandler = ( newElements : HTMLElement [ ] , previousElements : HTMLElement [ ] ) => void ; export class FocusScope { private scopeRef : RefObject < HTMLElement [ ] > ; private handlersRef : RefObject < ScopeChangeEventHandler [ ] > ; private childScopes : Set < FocusScope > ; constructor ( scopeRef , handlersRef ) { this . scopeRef = scopeRef ; this . handlersRef = handlersRef ; this . childScopes = new Set ( ) ; } registerChangeHandler ( handler ) { this . handlersRef . current . push ( handler ) ; } removeChangeHandler ( handler ) { const handlers = this . handlersRef . current ; handlers . splice ( handlers . indexOf ( handler ) , 1 ) ; } registerChildScope ( scope ) { this . childScopes . add ( scope ) ; } removeChildScope ( scope ) { return this . childScopes . delete ( scope ) ; } getElements ( { includeChildScopes = false } = { } ) { if ( ! includeChildScopes || this . childScopes . size === 0 ) { return this . scopeRef . current ; } const elements = new Set ( this . scopeRef . current ) ; this . childScopes . forEach ( x => { const children = x . getElements ( { includeChildScopes : true } ) ; children . forEach ( y => { elements . add ( y ) ; } ) ; } ) ; return Array . from ( elements ) ; } isInScope ( element , { includeChildScopes = false } = { } ) { if ( isNil ( element ) ) { return false ; } return this . getElements ( { includeChildScopes } ) . some ( x => x . contains ( element ) ) ; } } export interface FocusScopeContextType { scope ? : FocusScope ; } export const FocusScopeContext = createContext < FocusScopeContextType > ( undefined ) ; export function useFocusScopeContext ( ) { const context = useContext ( FocusScopeContext ) ; if ( ! isNil ( context ) ) { return [ context , true ] ; } return [ { } , false ] ; } export function useFocusScope ( ) { const [ scopeRef , setScope ] = useRefState < HTMLElement [ ] > ( [ ] ) ; const [ handlersRef ] = useRefState < ScopeChangeEventHandler [ ] > ( [ ] ) ; const setRef = useCallback ( ( rootElement ) => { const setElements = ( elements ) => { handlersRef . current . forEach ( x => { x ( elements , scopeRef . current ) ; } ) ; setScope ( elements ) ; } ; const parseElements = ( ) => { const scope = [ ] ; const walker = createFocusableTreeWalker ( rootElement ) ; let currentNode = walker . firstChild ( ) ; while ( ! isNil ( currentNode ) ) { scope . push ( currentNode as HTMLElement ) ; currentNode = walker . nextNode ( ) ; } if ( isFocusableElement ( rootElement , { rootElement : rootElement . parentElement } ) ) { scope . unshift ( rootElement ) ; } setElements ( scope ) ; } ; const mutationObserver = new MutationObserver ( ( ) => { parseElements ( ) ; } ) ; if ( rootElement ) { parseElements ( ) ; mutationObserver . observe ( rootElement , { attributeFilter : [ "style" , "class" , "hidden" , "aria-hidden" ] , attributes : true , childList : true , subtree : true } ) ; } else { mutationObserver . disconnect ( ) ; } } , [ scopeRef , setScope , handlersRef ] ) ; const scope = useMemo ( ( ) => new FocusScope ( scopeRef , handlersRef ) , [ scopeRef , handlersRef ] ) ; const [ { scope : parentScope } ] = useFocusScopeContext ( ) ; useEffect ( ( ) => { if ( ! isNil ( parentScope ) ) { parentScope . registerChildScope ( scope ) ; return ( ) => { parentScope . removeChildScope ( scope ) ; } ; } } , [ scope , parentScope ] ) ; return [ scope , setRef ] ; } export interface FocusScopeIteratorOptions extends ChildScopesOptions { from ? : number ; tabbableOnly ? : boolean ; } export interface FocusScopeIterationOptions { acceptElement ? : ( element : HTMLElement ) => boolean ; } export class FocusScopeIterator { private currentIndex : number ; private includeChildScopes : boolean ; private scope : FocusScope ; private tabbableOnly : boolean ; constructor ( scope , { from , includeChildScopes , tabbableOnly = false } = { } ) { this . currentIndex = from ; this . includeChildScopes = includeChildScopes ; this . scope = scope ; this . tabbableOnly = tabbableOnly ; } private isValid ( element ) { return this . tabbableOnly ? element . getAttribute ( "tabindex" ) !== "-1" : true ; } firstElement ( { acceptElement = ( ) => true } = { } ) { const elements = this . scope . getElements ( { includeChildScopes : this . includeChildScopes } ) ; if ( elements . length === 0 ) { return null ; } this . currentIndex = - 1 ; let current ; do { current = elements [ ++ this . currentIndex ] ; if ( ! isNil ( current ) ) { if ( this . isValid ( current ) ) { if ( acceptElement ( current ) ) { break ; } } } current = null ; if ( this . currentIndex > elements . length - 1 ) { break ; } } while ( isNil ( current ) ) ; return current ; } lastElement ( { acceptElement = ( ) => true } = { } ) { const elements = this . scope . getElements ( { includeChildScopes : this . includeChildScopes } ) ; if ( elements . length === 0 ) { return null ; } this . currentIndex = elements . length ; let current ; do { current = elements [ -- this . currentIndex ] ; if ( ! isNil ( current ) ) { if ( this . isValid ( current ) ) { if ( acceptElement ( current ) ) { break ; } } } current = null ; if ( this . currentIndex < 0 ) { break ; } } while ( isNil ( current ) ) ; return current ; } nextElement ( { acceptElement = ( ) => true } = { } ) { const elements = this . scope . getElements ( { includeChildScopes : this . includeChildScopes } ) ; if ( elements . length === 0 ) { return null ; } const startingIndex = this . currentIndex = this . currentIndex ?? - 1 ; let current ; do { current = elements [ ++ this . currentIndex ] ; if ( isNil ( current ) ) { this . currentIndex = - 1 ; } else { if ( this . isValid ( current ) ) { if ( acceptElement ( current ) ) { break ; } } } current = null ; if ( startingIndex === this . currentIndex ) { break ; } } while ( isNil ( current ) ) ; return current ; } previousElement ( { acceptElement = ( ) => true } = { } ) { const elements = this . scope . getElements ( { includeChildScopes : this . includeChildScopes } ) ; if ( elements . length === 0 ) { return null ; } const startingIndex = this . currentIndex = this . currentIndex ?? elements . length ; let current ; do { current = elements [ -- this . currentIndex ] ; if ( isNil ( current ) ) { this . currentIndex = elements . length ; } else { if ( this . isValid ( current ) ) { if ( acceptElement ( current ) ) { break ; } } } current = null ; if ( startingIndex === this . currentIndex ) { break ; } } while ( isNil ( current ) ) ; return current ; } }