UNPKG

@fluentui/react

Version:

Reusable React components for building web experiences.

246 lines 14.5 kB
define(["require", "exports", "tslib", "react", "../../Utilities", "@fluentui/react-hooks", "../../WindowProvider", "../../utilities/dom"], function (require, exports, tslib_1, React, Utilities_1, react_hooks_1, WindowProvider_1, dom_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.FocusTrapZone = void 0; var COMPONENT_NAME = 'FocusTrapZone'; var DEFAULT_PROPS = { disabled: false, disableFirstFocus: false, forceFocusInsideTrap: true, isClickableOutsideFocusTrap: false, // Hardcoding completely uncontrolled flag for proper interop with FluentUI V9. 'data-tabster': '{"uncontrolled": {"completely": true}}', }; var useComponentRef = function (componentRef, previouslyFocusedElement, focusFTZ) { React.useImperativeHandle(componentRef, function () { return ({ get previouslyFocusedElement() { return previouslyFocusedElement; }, focus: focusFTZ, }); }, [focusFTZ, previouslyFocusedElement]); }; exports.FocusTrapZone = React.forwardRef(function (propsWithoutDefaults, ref) { var _a; var root = React.useRef(null); var firstBumper = React.useRef(null); var lastBumper = React.useRef(null); var mergedRootRef = (0, react_hooks_1.useMergedRefs)(root, ref); var doc = (0, WindowProvider_1.useDocument)(); var win = (0, dom_1.useWindowEx)(); var inShadow = (0, Utilities_1.useHasMergeStylesShadowRootContext)(); var isFirstRender = (_a = (0, react_hooks_1.usePrevious)(false)) !== null && _a !== void 0 ? _a : true; var props = (0, Utilities_1.getPropsWithDefaults)(DEFAULT_PROPS, propsWithoutDefaults); var internalState = (0, react_hooks_1.useConst)({ hasFocus: false, focusStackId: (0, react_hooks_1.useId)('ftz-', props.id), }); var children = props.children, componentRef = props.componentRef, disabled = props.disabled, disableFirstFocus = props.disableFirstFocus, forceFocusInsideTrap = props.forceFocusInsideTrap, focusPreviouslyFocusedInnerElement = props.focusPreviouslyFocusedInnerElement, // eslint-disable-next-line deprecation/deprecation firstFocusableSelector = props.firstFocusableSelector, firstFocusableTarget = props.firstFocusableTarget, // eslint-disable-next-line deprecation/deprecation _b = props.disableRestoreFocus, // eslint-disable-next-line deprecation/deprecation disableRestoreFocus = _b === void 0 ? props.ignoreExternalFocusing : _b, isClickableOutsideFocusTrap = props.isClickableOutsideFocusTrap, enableAriaHiddenSiblings = props.enableAriaHiddenSiblings; var 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, 'data-is-visible': true, 'data-is-focus-trap-zone-bumper': true, }; var focusElementAsync = React.useCallback(function (element) { if (element !== firstBumper.current && element !== lastBumper.current) { (0, Utilities_1.focusAsync)(element); } }, []); /** * Callback to force focus into FTZ (named to avoid overlap with global focus() callback). * useEventCallback always returns the same callback reference but updates the implementation * every render to avoid stale captured values. */ var focusFTZ = (0, react_hooks_1.useEventCallback)(function () { if (!root.current) { return; // not done mounting } var previouslyFocusedElementInTrapZone = internalState.previouslyFocusedElementInTrapZone; if (focusPreviouslyFocusedInnerElement && previouslyFocusedElementInTrapZone && (0, Utilities_1.elementContains)(root.current, previouslyFocusedElementInTrapZone)) { // focus on the last item that had focus in the zone before we left the zone focusElementAsync(previouslyFocusedElementInTrapZone); return; } var firstFocusableChild = null; if (typeof firstFocusableTarget === 'string') { firstFocusableChild = root.current.querySelector(firstFocusableTarget); } else if (firstFocusableTarget) { firstFocusableChild = firstFocusableTarget(root.current); } else if (firstFocusableSelector) { var focusSelector = typeof firstFocusableSelector === 'string' ? firstFocusableSelector : firstFocusableSelector(); firstFocusableChild = root.current.querySelector('.' + focusSelector); } // Fall back to first element if query selector did not match any elements. if (!firstFocusableChild) { firstFocusableChild = (0, Utilities_1.getNextElement)(root.current, root.current.firstChild, false, false, false, true, undefined, undefined, undefined, inShadow); } if (firstFocusableChild) { focusElementAsync(firstFocusableChild); } }); /** Used in root div focus/blur handlers */ var focusBumper = function (isFirstBumper) { if (disabled || !root.current) { return; } var nextFocusable = isFirstBumper === internalState.hasFocus ? (0, Utilities_1.getLastTabbable)(root.current, lastBumper.current, true, false, inShadow) : (0, Utilities_1.getFirstTabbable)(root.current, firstBumper.current, true, false, inShadow); if (nextFocusable) { if (nextFocusable === firstBumper.current || nextFocusable === lastBumper.current) { // This can happen when FTZ contains no tabbable elements. // focusFTZ() will take care of finding a focusable element in FTZ. focusFTZ(); } else { nextFocusable.focus(); } } }; /** Root div blur handler (doesn't need useCallback since it's for a native element) */ var onRootBlurCapture = function (ev) { var _a; (_a = props.onBlurCapture) === null || _a === void 0 ? void 0 : _a.call(props, ev); var 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 root // 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 relatedTarget = (0, Utilities_1.getActiveElement)(doc); } if (!(0, Utilities_1.elementContains)(root.current, relatedTarget)) { internalState.hasFocus = false; } }; /** Root div focus handler (doesn't need useCallback since it's for a native element) */ var onRootFocusCapture = function (ev) { var _a; (_a = props.onFocusCapture) === null || _a === void 0 ? void 0 : _a.call(props, ev); if (ev.target === firstBumper.current) { focusBumper(true); } else if (ev.target === lastBumper.current) { focusBumper(false); } internalState.hasFocus = true; if (ev.target !== ev.currentTarget && !(ev.target === firstBumper.current || ev.target === lastBumper.current)) { // 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)) internalState.previouslyFocusedElementInTrapZone = (0, Utilities_1.getEventTarget)(ev.nativeEvent); } }; /** Called to restore focus on unmount or props change. (useEventCallback ensures latest prop values are used.) */ var returnFocusToInitiator = (0, react_hooks_1.useEventCallback)(function (elementToFocusOnDismiss) { exports.FocusTrapZone.focusStack = exports.FocusTrapZone.focusStack.filter(function (value) { return internalState.focusStackId !== value; }); if (!doc) { return; } // Do not use getActiveElement() here. // When the FTZ is in shadow DOM focus returns to the // shadow host rather than body so we need to be // able to inspect that var activeElement = doc.activeElement; if (!disableRestoreFocus && typeof (elementToFocusOnDismiss === null || elementToFocusOnDismiss === void 0 ? void 0 : elementToFocusOnDismiss.focus) === 'function' && // only restore focus if the current focused element is within the FTZ, or if nothing is focused ((0, Utilities_1.elementContains)(root.current, activeElement) || activeElement === doc.body || activeElement.shadowRoot)) { focusElementAsync(elementToFocusOnDismiss); } }); /** Called in window event handlers. (useEventCallback ensures latest prop values are used.) */ var forceFocusOrClickInTrap = (0, react_hooks_1.useEventCallback)(function (ev) { // be sure to use the latest values here if (disabled) { return; } if (internalState.focusStackId === exports.FocusTrapZone.focusStack.slice(-1)[0]) { var targetElement = (0, Utilities_1.getEventTarget)(ev); if (targetElement && !(0, Utilities_1.elementContains)(root.current, targetElement)) { if (doc && (0, Utilities_1.getActiveElement)(doc) === doc.body) { setTimeout(function () { if (doc && (0, Utilities_1.getActiveElement)(doc) === doc.body) { focusFTZ(); internalState.hasFocus = true; // set focus here since we stop event propagation } }, 0); } else { focusFTZ(); internalState.hasFocus = true; // set focus here since we stop event propagation } ev.preventDefault(); ev.stopPropagation(); } } }); // Update window event handlers when relevant props change React.useEffect(function () { var disposables = []; if (forceFocusInsideTrap) { disposables.push((0, Utilities_1.on)(win, 'focus', forceFocusOrClickInTrap, true)); } if (!isClickableOutsideFocusTrap) { disposables.push((0, Utilities_1.on)(win, 'click', forceFocusOrClickInTrap, true)); } return function () { disposables.forEach(function (dispose) { return dispose(); }); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- should only run when these two props change }, [forceFocusInsideTrap, isClickableOutsideFocusTrap, win]); // On prop change or first render, focus the FTZ and update focusStack if appropriate React.useEffect(function () { // Do nothing if disabled, or if it's a re-render and forceFocusInsideTrap is false // (to match existing behavior, the FTZ handles first focus even if forceFocusInsideTrap // is false, though it's debatable whether it should do this) if (disabled || (!isFirstRender && !forceFocusInsideTrap) || !root.current) { return; } // Transition from forceFocusInsideTrap / FTZ disabled to enabled (or initial mount) exports.FocusTrapZone.focusStack.push(internalState.focusStackId); var elementToFocusOnDismiss = props.elementToFocusOnDismiss || (0, Utilities_1.getActiveElement)(doc); if (!disableFirstFocus && !(0, Utilities_1.elementContains)(root.current, elementToFocusOnDismiss)) { focusFTZ(); } // To match existing behavior, always return focus on cleanup (even if we didn't handle // initial focus), but it's debatable whether that's correct return function () { return returnFocusToInitiator(elementToFocusOnDismiss); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- should only run when these two props change }, [forceFocusInsideTrap, disabled]); // Handle modalization separately from first focus React.useEffect(function () { if (!disabled && enableAriaHiddenSiblings) { var unmodalize = (0, Utilities_1.modalize)(root.current); return unmodalize; } }, [disabled, enableAriaHiddenSiblings, root]); // Cleanup lifecyle method for internalState. (0, react_hooks_1.useUnmount)(function () { // Dispose of element references so the DOM Nodes can be garbage-collected delete internalState.previouslyFocusedElementInTrapZone; }); useComponentRef(componentRef, internalState.previouslyFocusedElementInTrapZone, focusFTZ); return (React.createElement("div", tslib_1.__assign({ "aria-labelledby": props.ariaLabelledBy }, (0, Utilities_1.getNativeProps)(props, Utilities_1.divProperties), { ref: mergedRootRef, onFocusCapture: onRootFocusCapture, onBlurCapture: onRootBlurCapture }), React.createElement("div", tslib_1.__assign({}, bumperProps, { ref: firstBumper })), children, React.createElement("div", tslib_1.__assign({}, bumperProps, { ref: lastBumper })))); }); exports.FocusTrapZone.displayName = COMPONENT_NAME; exports.FocusTrapZone.focusStack = []; }); //# sourceMappingURL=FocusTrapZone.js.map