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 class FocusTrapZone extends React.Component { static _focusStack: FocusTrapZone[] = []; _root: { current: HTMLElement | null } = { current: null }; _previouslyFocusedElementOutsideTrapZone: HTMLElement; _previouslyFocusedElementInTrapZone?: HTMLElement; _firstBumper = React.createRef(); _lastBumper = React.createRef(); _hasFocus: boolean = false; windowRef = React.createRef() as React.MutableRefObject; // @ts-ignore createRef = elem => { this._root.current = ReactDOM.findDOMNode(elem) as HTMLElement; // @ts-ignore 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(): void { this._enableFocusTrapZone(); } componentDidUpdate(prevProps: FocusTrapZoneProps): void { const { forceFocusInsideTrapOnComponentUpdate, forceFocusInsideTrapOnOutsideFocus, disabled } = this.props; const doc = getDocument(this._root.current); // @ts-ignore const activeElement = doc.activeElement as HTMLElement; // if after componentDidUpdate focus is not inside the focus trap, bring it back if ( !disabled && // @ts-ignore !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)) { // Transition from forceFocusInsideTrap / FTZ disabled to enabled. // Emulate what happens when a FocusTrapZone gets mounted. this._enableFocusTrapZone(); } else if ((prevForceFocusInsideTrap && !newForceFocusInsideTrap) || (!prevDisabled && newDisabled)) { // Transition from forceFocusInsideTrap / FTZ enabled to disabled. // Emulate what happens when a FocusTrapZone gets unmounted. this._releaseFocusTrapZone(); } } componentWillUnmount(): void { // don't handle return focus unless forceFocusInsideTrapOnOutsideFocus is true or focus is still within FocusTrapZone const doc = getDocument(this._root.current); if ( !this.props.disabled || this.props.forceFocusInsideTrapOnOutsideFocus || // @ts-ignore !this._root.current.contains(doc.activeElement as HTMLElement) ) { this._releaseFocusTrapZone(); } // Dispose of element references so the DOM Nodes can be garbage-collected delete this._previouslyFocusedElementInTrapZone; delete this._previouslyFocusedElementOutsideTrapZone; } render(): JSX.Element { 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 = { 'aria-hidden': true, style: { pointerEvents: 'none', position: 'fixed', // 'fixed' prevents browsers from scrolling to bumpers when viewport does not contain them }, tabIndex: disabled ? -1 : 0, // make bumpers tabbable only when enabled 'data-is-visible': true, } as React.HTMLAttributes; return ( <>
{this.props.children}
{forceFocusInsideTrapOnOutsideFocus && ( )} {this.shouldHandleOutsideClick() && ( )} ); } _onRootFocus = (ev: React.FocusEvent) => { if (this.props.onFocus) { this.props.onFocus(ev); } this._hasFocus = true; }; _onRootBlur = (ev: React.FocusEvent) => { if (this.props.onBlur) { this.props.onBlur(ev); } let relatedTarget = ev.relatedTarget; if (ev.relatedTarget === null) { // In IE11, due to lack of support, event.relatedTarget is always // null making every onBlur call to be "outside" of the ComboBox // even when it's not. Using document.activeElement is another way // for us to be able to get what the relatedTarget without relying // on the event const doc = getDocument(this._root.current); // @ts-ignore relatedTarget = doc.activeElement as Element; } // @ts-ignore if (!this._root.current.contains(relatedTarget as HTMLElement)) { this._hasFocus = false; } }; _onFirstBumperFocus = () => { this._onBumperFocus(true); }; _onLastBumperFocus = () => { this._onBumperFocus(false); }; _isBumper(element: HTMLElement): boolean { return element === this._firstBumper.current || element === this._lastBumper.current; } _onBumperFocus = (isFirstBumper: boolean) => { 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 can happen when FTZ contains no tabbable elements. Focus will take care of finding a focusable element in FTZ. this._findElementAndFocusAsync(); } else { nextFocusable.focus(); } } }; _focusAsync(element: HTMLElement): void { 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 ( // @ts-ignore !this._root.current.contains(this._previouslyFocusedElementOutsideTrapZone) && !disableFirstFocus ) { this._findElementAndFocusAsync(); } }; _releaseFocusTrapZone = () => { const { ignoreExternalFocusing } = this.props; FocusTrapZone._focusStack = FocusTrapZone._focusStack.filter((value: FocusTrapZone) => { return this !== value; }); // try to focus element which triggered FocusTrapZone - prviously focused element outside trap zone const doc = getDocument(this._root.current); // @ts-ignore const activeElement = doc.activeElement as HTMLElement; if ( !ignoreExternalFocusing && this._previouslyFocusedElementOutsideTrapZone && // @ts-ignore (this._root.current.contains(activeElement) || activeElement === doc.body) ) { this._focusAsync(this._previouslyFocusedElementOutsideTrapZone); } // if last active focus trap zone is going to be released - show previously hidden content in accessibility tree 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) ) { // focus on the last item that had focus in the zone before we left the zone this._focusAsync(this._previouslyFocusedElementInTrapZone); return; } const focusSelector = firstFocusableSelector && (typeof firstFocusableSelector === 'string' ? firstFocusableSelector : firstFocusableSelector()); let firstFocusableChild: HTMLElement | null = null; if (focusSelector) { firstFocusableChild = this._root.current.querySelector(focusSelector); } // Fall back to first element if query selector did not match any elements. if (!firstFocusableChild) { firstFocusableChild = getNextElement( this._root.current, this._root.current.firstChild as HTMLElement, false, false, false, true, ); } firstFocusableChild && this._focusAsync(firstFocusableChild); }; _onFocusCapture = (ev: React.FocusEvent) => { this.props.onFocusCapture && this.props.onFocusCapture(ev); if (ev.target !== ev.currentTarget && !this._isBumper(ev.target)) { // every time focus changes within the trap zone, remember the focused element so that // it can be restored if focus leaves the pane and returns via keystroke (i.e. via a call to this.focus(true)) this._previouslyFocusedElementInTrapZone = ev.target as HTMLElement; } }; _forceFocusInTrap = (ev: Event, triggeredElement: HTMLElement) => { if (FocusTrapZone._focusStack.length && this === FocusTrapZone._focusStack[FocusTrapZone._focusStack.length - 1]) { // @ts-ignore if (!this._root.current.contains(triggeredElement)) { this._findElementAndFocusAsync(); ev.preventDefault(); ev.stopPropagation(); } } }; _handleOutsideFocus = (ev: FocusEvent): void => { const doc = getDocument(this._root.current); // @ts-ignore const focusedElement = doc.activeElement as HTMLElement; focusedElement && this._forceFocusInTrap(ev, focusedElement); }; _handleOutsideClick = (ev: MouseEvent): 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) { // set it to NULL, so the trigger will not be focused on componentWillUnmount // @ts-ignore this._previouslyFocusedElementOutsideTrapZone = null; } } }; _onKeyboardHandler = (ev: React.KeyboardEvent): void => { if (this.props.onKeyDown) { this.props.onKeyDown(ev); } // do not propogate keyboard events outside focus trap zone // https://github.com/microsoft/fluent-ui-react/pull/1180 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); // @ts-ignore previouslyFocusedElement = doc.activeElement as HTMLElement; } return previouslyFocusedElement; }; _hideContentFromAccessibilityTree = () => { const doc = getDocument(this._root.current); // @ts-ignore const bodyChildren = (doc.body && doc.body.children) || []; // @ts-ignore if (bodyChildren.length && !doc.body.contains(this._root.current)) { // In case popup render options will change /* eslint-disable-next-line no-console */ 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); // @ts-ignore 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); } }; }