import { EventListener } from '@fluentui/react-component-event-listener' ; import * as React from 'react' ; import * as ReactDOM from 'react-dom' ; import * as PropTypes from 'prop-types' ; import * as _ from 'lodash' ; import getElementType from '../utils/getElementType' ; import getUnhandledProps from '../utils/getUnhandledProps' ; import { getNextElement , getFirstTabbable , getLastTabbable , getWindow , getDocument , focusAsync , HIDDEN_FROM_ACC_TREE , } from './focusUtilities' ; import { FocusTrapZoneProps } from './FocusTrapZone.types' ; /** FocusTrapZone is used to trap the focus in any html element placed in body
 *  and hide other elements outside of Focus Trap Zone from accessibility tree.
 *  Pressing tab will circle focus within the inner focusable elements of the FocusTrapZone. */ export default class FocusTrapZone extends React . Component < FocusTrapZoneProps , { } > { static _focusStack : FocusTrapZone [ ] = [ ] ; _root : { current : HTMLElement | null ; } = { current : null } ; _previouslyFocusedElementOutsideTrapZone : HTMLElement ; _previouslyFocusedElementInTrapZone ? : HTMLElement ; _firstBumper = React . createRef < HTMLDivElement > ( ) ; _lastBumper = React . createRef < HTMLDivElement > ( ) ; _hasFocus : boolean = false ; windowRef = React . createRef < Window > ( ) as React . MutableRefObject < Window > ; createRef = elem => { this . _root . current = ReactDOM . findDOMNode ( elem ) as HTMLElement ; this . windowRef . current = getWindow ( this . _root . current ) ; } ; shouldHandleOutsideClick = ( ) => ! this . props . isClickableOutsideFocusTrap || ! this . props . focusTriggerOnOutsideClick ; static propTypes = { as : PropTypes . elementType , className : PropTypes . string , elementToFocusOnDismiss : PropTypes . object , ariaLabelledBy : PropTypes . string , isClickableOutsideFocusTrap : PropTypes . bool , ignoreExternalFocusing : PropTypes . bool , forceFocusInsideTrapOnOutsideFocus : PropTypes . bool , forceFocusInsideTrapOnComponentUpdate : PropTypes . bool , firstFocusableSelector : PropTypes . string , disableFirstFocus : PropTypes . bool , focusPreviouslyFocusedInnerElement : PropTypes . bool , focusTriggerOnOutsideClick : PropTypes . bool , } ; static defaultProps : FocusTrapZoneProps = { as : 'div' , isClickableOutsideFocusTrap : true , forceFocusInsideTrapOnOutsideFocus : false , } ; componentDidMount ( ) { this . _enableFocusTrapZone ( ) ; } componentDidUpdate ( prevProps ) { const { forceFocusInsideTrapOnComponentUpdate , forceFocusInsideTrapOnOutsideFocus , disabled , } = this . props ; const doc = getDocument ( this . _root . current ) ; const activeElement = doc . activeElement as HTMLElement ; if ( ! disabled && ! this . _root . current . contains ( activeElement ) && forceFocusInsideTrapOnComponentUpdate ) { this . _bringFocusIntoZone ( ) ; return ; } const prevForceFocusInsideTrap = prevProps . forceFocusInsideTrapOnOutsideFocus !== undefined ? prevProps . forceFocusInsideTrapOnOutsideFocus : true ; const newForceFocusInsideTrap = forceFocusInsideTrapOnOutsideFocus !== undefined ? forceFocusInsideTrapOnOutsideFocus : true ; const prevDisabled = prevProps . disabled !== undefined ? prevProps . disabled : false ; const newDisabled = disabled !== undefined ? disabled : false ; if ( ( ! prevForceFocusInsideTrap && newForceFocusInsideTrap ) || ( prevDisabled && ! newDisabled ) ) { this . _enableFocusTrapZone ( ) ; } else if ( ( prevForceFocusInsideTrap && ! newForceFocusInsideTrap ) || ( ! prevDisabled && newDisabled ) ) { this . _releaseFocusTrapZone ( ) ; } } componentWillUnmount ( ) { const doc = getDocument ( this . _root . current ) ; if ( ! this . props . disabled || this . props . forceFocusInsideTrapOnOutsideFocus || ! this . _root . current . contains ( doc . activeElement as HTMLElement ) ) { this . _releaseFocusTrapZone ( ) ; } delete this . _previouslyFocusedElementInTrapZone ; delete this . _previouslyFocusedElementOutsideTrapZone ; } render ( ) { const { className , forceFocusInsideTrapOnOutsideFocus , ariaLabelledBy , disabled = false , } = this . props ; const unhandledProps = getUnhandledProps ( _ . keys ( FocusTrapZone . propTypes ) as any , this . props ) ; const ElementType = getElementType ( this . props ) ; const bumperProps = { style : { pointerEvents : 'none' , position : 'fixed' , } , tabIndex : disabled ? - 1 : 0 , 'data-is-visible' : true , } as React . HTMLAttributes < HTMLDivElement > ; return ( < > < ElementType { ... unhandledProps } className = { className } ref = { this . createRef } aria - labelledby = { ariaLabelledBy } onKeyDown = { this . _onKeyboardHandler } onFocusCapture = { this . _onFocusCapture } onFocus = { this . _onRootFocus } onBlur = { this . _onRootBlur } > < div { ... bumperProps } ref = { this . _firstBumper } onFocus = { this . _onFirstBumperFocus } / > { this . props . children } < div { ... bumperProps } ref = { this . _lastBumper } onFocus = { this . _onLastBumperFocus } / > < /ElementType> { forceFocusInsideTrapOnOutsideFocus && ( < EventListener capture listener = { this . _handleOutsideFocus } targetRef = { this . windowRef } type = "focus" / > ) } { this . shouldHandleOutsideClick ( ) && ( < EventListener capture listener = { this . _handleOutsideClick } targetRef = { this . windowRef } type = "click" / > ) } < />); } _onRootFocus = ( ev ) => { if ( this . props . onFocus ) { this . props . onFocus ( ev ) ; } this . _hasFocus = true ; } ; _onRootBlur = ( ev ) => { if ( this . props . onBlur ) { this . props . onBlur ( ev ) ; } let relatedTarget = ev . relatedTarget ; if ( ev . relatedTarget === null ) { const doc = getDocument ( this . _root . current ) ; relatedTarget = doc . activeElement as Element ; } if ( ! this . _root . current . contains ( relatedTarget as HTMLElement ) ) { this . _hasFocus = false ; } } ; _onFirstBumperFocus = ( ) => { this . _onBumperFocus ( true ) ; } ; _onLastBumperFocus = ( ) => { this . _onBumperFocus ( false ) ; } ; _isBumper ( element ) { return element === this . _firstBumper . current || element === this . _lastBumper . current ; } _onBumperFocus = ( isFirstBumper ) => { if ( ! this . _root . current ) { return ; } const currentBumper = ( isFirstBumper === this . _hasFocus ? this . _lastBumper . current : this . _firstBumper . current ) as HTMLElement ; const nextFocusable = isFirstBumper === this . _hasFocus ? getLastTabbable ( this . _root . current , currentBumper , true , false ) : getFirstTabbable ( this . _root . current , currentBumper , true , false ) ; if ( nextFocusable ) { if ( this . _isBumper ( nextFocusable ) ) { this . _findElementAndFocusAsync ( ) ; } else { nextFocusable . focus ( ) ; } } } ; _focusAsync ( element ) { if ( ! this . _isBumper ( element ) ) { focusAsync ( element ) ; } } _enableFocusTrapZone = ( ) => { const { disabled = false } = this . props ; if ( disabled ) { return ; } FocusTrapZone . _focusStack . push ( this ) ; this . _bringFocusIntoZone ( ) ; this . _hideContentFromAccessibilityTree ( ) ; } ; _bringFocusIntoZone = ( ) => { const { disableFirstFocus = false } = this . props ; this . _previouslyFocusedElementOutsideTrapZone = this . _getPreviouslyFocusedElementOutsideTrapZone ( ) ; if ( ! this . _root . current . contains ( this . _previouslyFocusedElementOutsideTrapZone ) && ! disableFirstFocus ) { this . _findElementAndFocusAsync ( ) ; } } ; _releaseFocusTrapZone = ( ) => { const { ignoreExternalFocusing } = this . props ; FocusTrapZone . _focusStack = FocusTrapZone . _focusStack . filter ( ( value ) => { return this !== value ; } ) ; const doc = getDocument ( this . _root . current ) ; const activeElement = doc . activeElement as HTMLElement ; if ( ! ignoreExternalFocusing && this . _previouslyFocusedElementOutsideTrapZone && ( this . _root . current . contains ( activeElement ) || activeElement === doc . body ) ) { this . _focusAsync ( this . _previouslyFocusedElementOutsideTrapZone ) ; } const lastActiveFocusTrap = FocusTrapZone . _focusStack . length && FocusTrapZone . _focusStack [ FocusTrapZone . _focusStack . length - 1 ] ; if ( ! lastActiveFocusTrap ) { this . _showContentInAccessibilityTree ( ) ; } else if ( lastActiveFocusTrap . _root . current && lastActiveFocusTrap . _root . current . hasAttribute ( HIDDEN_FROM_ACC_TREE ) ) { lastActiveFocusTrap . _root . current . removeAttribute ( HIDDEN_FROM_ACC_TREE ) ; lastActiveFocusTrap . _root . current . removeAttribute ( 'aria-hidden' ) ; } } ; _findElementAndFocusAsync = ( ) => { if ( ! this . _root . current ) { return ; } const { focusPreviouslyFocusedInnerElement , firstFocusableSelector } = this . props ; if ( focusPreviouslyFocusedInnerElement && this . _previouslyFocusedElementInTrapZone && this . _root . current . contains ( this . _previouslyFocusedElementInTrapZone ) ) { this . _focusAsync ( this . _previouslyFocusedElementInTrapZone ) ; return ; } const focusSelector = firstFocusableSelector && ( typeof firstFocusableSelector === 'string' ? firstFocusableSelector : firstFocusableSelector ( ) ) ; let firstFocusableChild = null ; if ( focusSelector ) { firstFocusableChild = this . _root . current . querySelector ( focusSelector ) ; } if ( ! firstFocusableChild ) { firstFocusableChild = getNextElement ( this . _root . current , this . _root . current . firstChild as HTMLElement , false , false , false , true ) ; } firstFocusableChild && this . _focusAsync ( firstFocusableChild ) ; } ; _onFocusCapture = ( ev ) => { this . props . onFocusCapture && this . props . onFocusCapture ( ev ) ; if ( ev . target !== ev . currentTarget && ! this . _isBumper ( ev . target ) ) { this . _previouslyFocusedElementInTrapZone = ev . target as HTMLElement ; } } ; _forceFocusInTrap = ( ev , triggeredElement ) => { if ( FocusTrapZone . _focusStack . length && this === FocusTrapZone . _focusStack [ FocusTrapZone . _focusStack . length - 1 ] ) { if ( ! this . _root . current . contains ( triggeredElement ) ) { this . _findElementAndFocusAsync ( ) ; ev . preventDefault ( ) ; ev . stopPropagation ( ) ; } } } ; _handleOutsideFocus = ( ev ) : void => { const doc = getDocument ( this . _root . current ) ; const focusedElement = doc . activeElement as HTMLElement ; focusedElement && this . _forceFocusInTrap ( ev , focusedElement ) ; } ; _handleOutsideClick = ( ev ) : void => { const clickedElement = ev . target as HTMLElement ; const { isClickableOutsideFocusTrap , focusTriggerOnOutsideClick } = this . props ; if ( ! isClickableOutsideFocusTrap ) { clickedElement && this . _forceFocusInTrap ( ev , clickedElement ) ; } else if ( ! focusTriggerOnOutsideClick ) { const isOutsideFocusTrapZone = this . _root . current && ! this . _root . current . contains ( clickedElement ) ; const isOutsideTriggerElement = this . _previouslyFocusedElementOutsideTrapZone && ! this . _previouslyFocusedElementOutsideTrapZone . contains ( clickedElement ) ; if ( isOutsideFocusTrapZone && isOutsideTriggerElement ) { this . _previouslyFocusedElementOutsideTrapZone = null ; } } } ; _onKeyboardHandler = ( ev ) : void => { if ( this . props . onKeyDown ) { this . props . onKeyDown ( ev ) ; } ev . stopPropagation ( ) ; } ; _getPreviouslyFocusedElementOutsideTrapZone = ( ) => { const { elementToFocusOnDismiss } = this . props ; let previouslyFocusedElement = this . _previouslyFocusedElementOutsideTrapZone ; if ( elementToFocusOnDismiss && previouslyFocusedElement !== elementToFocusOnDismiss ) { previouslyFocusedElement = elementToFocusOnDismiss ; } else if ( ! previouslyFocusedElement ) { const doc = getDocument ( this . _root . current ) ; previouslyFocusedElement = doc . activeElement as HTMLElement ; } return previouslyFocusedElement ; } ; _hideContentFromAccessibilityTree = ( ) => { const doc = getDocument ( this . _root . current ) ; const bodyChildren = ( doc . body && doc . body . children ) || [ ] ; if ( bodyChildren . length && ! doc . body . contains ( this . _root . current ) ) { console . warn ( 'Body element does not contain trap zone element. Please, ensure the trap zone element is placed inside body, so it will work properly.' ) ; } for ( let index = 0 ; index < bodyChildren . length ; index ++ ) { const currentChild = bodyChildren [ index ] as HTMLElement ; const isOrHasFocusTrapZone = currentChild === this . _root . current || currentChild . contains ( this . _root . current ) ; const isAriaLiveRegion = currentChild . hasAttribute ( 'aria-live' ) ; if ( ! isOrHasFocusTrapZone && ! isAriaLiveRegion && currentChild . getAttribute ( 'aria-hidden' ) !== 'true' ) { currentChild . setAttribute ( 'aria-hidden' , 'true' ) ; currentChild . setAttribute ( HIDDEN_FROM_ACC_TREE , 'true' ) ; } } } ; _showContentInAccessibilityTree = ( ) => { const doc = getDocument ( this . _root . current ) ; const hiddenElements = doc . querySelectorAll ( `[${ HIDDEN_FROM_ACC_TREE }="true"]` ) ; for ( let index = 0 ; index < hiddenElements . length ; index ++ ) { const element = hiddenElements [ index ] ; element . removeAttribute ( 'aria-hidden' ) ; element . removeAttribute ( HIDDEN_FROM_ACC_TREE ) ; } } ; }