@fluentui/react
Version:
Reusable React components for building web experiences.
259 lines • 14.1 kB
JavaScript
import { __assign } from "tslib";
import * as React from 'react';
import { elementContains, getNativeProps, divProperties, getFirstTabbable, getLastTabbable, getNextElement, focusAsync, modalize, on, } from '../../Utilities';
import { useId, useConst, useMergedRefs, useUnmount } from '@fluentui/react-hooks';
import { useDocument } from '../../WindowProvider';
var COMPONENT_NAME = 'FocusTrapZone';
var useComponentRef = function (componentRef, previouslyFocusedElement, focus) {
React.useImperativeHandle(componentRef, function () { return ({
get previouslyFocusedElement() {
return previouslyFocusedElement;
},
focus: focus,
}); }, [previouslyFocusedElement, focus]);
};
export var FocusTrapZone = React.forwardRef(function (props, ref) {
var root = React.useRef(null);
var firstBumper = React.useRef(null);
var lastBumper = React.useRef(null);
var mergedRootRef = useMergedRefs(root, ref);
var id = useId(undefined, props.id);
var doc = useDocument();
var divProps = getNativeProps(props, divProperties);
var internalState = useConst(function () { return ({
previouslyFocusedElementOutsideTrapZone: undefined,
previouslyFocusedElementInTrapZone: undefined,
disposeFocusHandler: undefined,
disposeClickHandler: undefined,
hasFocus: false,
unmodalize: undefined,
}); });
var ariaLabelledBy = props.ariaLabelledBy, className = props.className, children = props.children, componentRef = props.componentRef, disabled = props.disabled, _a = props.disableFirstFocus, disableFirstFocus = _a === void 0 ? false : _a, _b = props.disabled, currentDisabledValue = _b === void 0 ? false : _b, elementToFocusOnDismiss = props.elementToFocusOnDismiss, _c = props.forceFocusInsideTrap, forceFocusInsideTrap = _c === void 0 ? true : _c, focusPreviouslyFocusedInnerElement = props.focusPreviouslyFocusedInnerElement,
// eslint-disable-next-line deprecation/deprecation
firstFocusableSelector = props.firstFocusableSelector, firstFocusableTarget = props.firstFocusableTarget, ignoreExternalFocusing = props.ignoreExternalFocusing, _d = props.isClickableOutsideFocusTrap, isClickableOutsideFocusTrap = _d === void 0 ? false : _d, onFocus = props.onFocus, onBlur = props.onBlur, onFocusCapture = props.onFocusCapture, onBlurCapture = props.onBlurCapture, 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 focus = React.useCallback(function () {
if (focusPreviouslyFocusedInnerElement &&
internalState.previouslyFocusedElementInTrapZone &&
elementContains(root.current, internalState.previouslyFocusedElementInTrapZone)) {
// focus on the last item that had focus in the zone before we left the zone
focusAsync(internalState.previouslyFocusedElementInTrapZone);
return;
}
var focusSelector = typeof firstFocusableSelector === 'string'
? firstFocusableSelector
: firstFocusableSelector && firstFocusableSelector();
var firstFocusableChild = null;
if (root.current) {
if (typeof firstFocusableTarget === 'string') {
firstFocusableChild = root.current.querySelector(firstFocusableTarget);
}
else if (firstFocusableTarget) {
firstFocusableChild = firstFocusableTarget(root.current);
}
else if (focusSelector) {
firstFocusableChild = root.current.querySelector('.' + focusSelector);
}
// Fall back to first element if query selector did not match any elements.
if (!firstFocusableChild) {
firstFocusableChild = getNextElement(root.current, root.current.firstChild, false, false, false, true);
}
}
if (firstFocusableChild) {
focusAsync(firstFocusableChild);
}
}, [firstFocusableSelector, firstFocusableTarget, focusPreviouslyFocusedInnerElement, internalState]);
var onBumperFocus = React.useCallback(function (isFirstBumper) {
if (disabled) {
return;
}
var currentBumper = (isFirstBumper === internalState.hasFocus
? lastBumper.current
: firstBumper.current);
if (root.current) {
var nextFocusable = isFirstBumper === internalState.hasFocus
? getLastTabbable(root.current, currentBumper, true, false)
: getFirstTabbable(root.current, currentBumper, true, false);
if (nextFocusable) {
if (nextFocusable === firstBumper.current || nextFocusable === lastBumper.current) {
// This can happen when FTZ contains no tabbable elements.
// focus will take care of finding a focusable element in FTZ.
focus();
}
else {
nextFocusable.focus();
}
}
}
}, [disabled, focus, internalState]);
var onRootBlurCapture = React.useCallback(function (ev) {
onBlurCapture === null || onBlurCapture === void 0 ? void 0 : onBlurCapture(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 = doc.activeElement;
}
if (!elementContains(root.current, relatedTarget)) {
internalState.hasFocus = false;
}
}, [doc, internalState, onBlurCapture]);
var onRootFocusCapture = React.useCallback(function (ev) {
onFocusCapture === null || onFocusCapture === void 0 ? void 0 : onFocusCapture(ev);
if (ev.target === firstBumper.current) {
onBumperFocus(true);
}
else if (ev.target === lastBumper.current) {
onBumperFocus(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 = ev.target;
}
}, [onFocusCapture, internalState, onBumperFocus]);
var returnFocusToInitiator = React.useCallback(function () {
FocusTrapZone.focusStack = FocusTrapZone.focusStack.filter(function (value) {
return id !== value;
});
if (doc) {
var activeElement = doc.activeElement;
if (!ignoreExternalFocusing &&
internalState.previouslyFocusedElementOutsideTrapZone &&
typeof internalState.previouslyFocusedElementOutsideTrapZone.focus === 'function' &&
(elementContains(root.current, activeElement) || activeElement === doc.body)) {
if (!(internalState.previouslyFocusedElementOutsideTrapZone === firstBumper.current ||
internalState.previouslyFocusedElementOutsideTrapZone === lastBumper.current)) {
focusAsync(internalState.previouslyFocusedElementOutsideTrapZone);
}
}
}
}, [doc, id, ignoreExternalFocusing, internalState]);
var forceFocusInTrap = React.useCallback(function (ev) {
if (disabled) {
return;
}
if (FocusTrapZone.focusStack.length && id === FocusTrapZone.focusStack[FocusTrapZone.focusStack.length - 1]) {
var focusedElement = ev.target;
if (!elementContains(root.current, focusedElement)) {
focus();
internalState.hasFocus = true; // set focus here since we stop event propagation
ev.preventDefault();
ev.stopPropagation();
}
}
}, [disabled, id, focus, internalState]);
var forceClickInTrap = React.useCallback(function (ev) {
if (disabled) {
return;
}
if (FocusTrapZone.focusStack.length && id === FocusTrapZone.focusStack[FocusTrapZone.focusStack.length - 1]) {
var clickedElement = ev.target;
if (clickedElement && !elementContains(root.current, clickedElement)) {
focus();
internalState.hasFocus = true; // set focus here since we stop event propagation
ev.preventDefault();
ev.stopPropagation();
}
}
}, [disabled, id, focus, internalState]);
var updateEventHandlers = React.useCallback(function () {
if (forceFocusInsideTrap && !internalState.disposeFocusHandler) {
internalState.disposeFocusHandler = on(window, 'focus', forceFocusInTrap, true);
}
else if (!forceFocusInsideTrap && internalState.disposeFocusHandler) {
internalState.disposeFocusHandler();
internalState.disposeFocusHandler = undefined;
}
if (!isClickableOutsideFocusTrap && !internalState.disposeClickHandler) {
internalState.disposeClickHandler = on(window, 'click', forceClickInTrap, true);
}
else if (isClickableOutsideFocusTrap && internalState.disposeClickHandler) {
internalState.disposeClickHandler();
internalState.disposeClickHandler = undefined;
}
}, [forceClickInTrap, forceFocusInTrap, forceFocusInsideTrap, isClickableOutsideFocusTrap, internalState]);
// Updates eventHandlers and cleans up focusStack when the component unmounts.
React.useEffect(function () {
var parentRoot = root.current;
updateEventHandlers();
return function () {
// don't handle return focus unless forceFocusInsideTrap is true or focus is still within FocusTrapZone
if (!disabled || forceFocusInsideTrap || !elementContains(parentRoot, doc === null || doc === void 0 ? void 0 : doc.activeElement)) {
returnFocusToInitiator();
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- Should only run on mount.
}, [updateEventHandlers]);
// Updates focusStack and the previouslyFocusedElementOutsideTrapZone on prop change.
React.useEffect(function () {
var newForceFocusInsideTrap = forceFocusInsideTrap !== undefined ? forceFocusInsideTrap : true;
var newDisabled = disabled !== undefined ? disabled : false;
// Transition from forceFocusInsideTrap / FTZ disabled to enabled.
if (!newDisabled || newForceFocusInsideTrap) {
if (currentDisabledValue) {
return;
}
FocusTrapZone.focusStack.push(id);
internalState.previouslyFocusedElementOutsideTrapZone = elementToFocusOnDismiss
? elementToFocusOnDismiss
: doc.activeElement;
if (!disableFirstFocus && !elementContains(root.current, internalState.previouslyFocusedElementOutsideTrapZone)) {
focus();
}
if (!internalState.unmodalize && root.current && enableAriaHiddenSiblings) {
internalState.unmodalize = modalize(root.current);
}
}
else if (!newForceFocusInsideTrap || newDisabled) {
// Transition from forceFocusInsideTrap / FTZ enabled to disabled.
returnFocusToInitiator();
if (internalState.unmodalize) {
internalState.unmodalize();
}
}
if (elementToFocusOnDismiss && internalState.previouslyFocusedElementOutsideTrapZone !== elementToFocusOnDismiss) {
internalState.previouslyFocusedElementOutsideTrapZone = elementToFocusOnDismiss;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementToFocusOnDismiss, forceFocusInsideTrap, disabled]);
// Cleanup lifecyle method for internalState.
useUnmount(function () {
// Dispose of event handlers so their closures can be garbage-collected
if (internalState.disposeClickHandler) {
internalState.disposeClickHandler();
internalState.disposeClickHandler = undefined;
}
if (internalState.disposeFocusHandler) {
internalState.disposeFocusHandler();
internalState.disposeFocusHandler = undefined;
}
if (internalState.unmodalize) {
internalState.unmodalize();
}
// Dispose of element references so the DOM Nodes can be garbage-collected
delete internalState.previouslyFocusedElementInTrapZone;
delete internalState.previouslyFocusedElementOutsideTrapZone;
});
useComponentRef(componentRef, internalState.previouslyFocusedElementInTrapZone, focus);
return (React.createElement("div", __assign({}, divProps, { className: className, ref: mergedRootRef, "aria-labelledby": ariaLabelledBy, onFocusCapture: onRootFocusCapture, onFocus: onFocus, onBlur: onBlur, onBlurCapture: onRootBlurCapture }),
React.createElement("div", __assign({}, bumperProps, { ref: firstBumper })),
children,
React.createElement("div", __assign({}, bumperProps, { ref: lastBumper }))));
});
FocusTrapZone.displayName = COMPONENT_NAME;
FocusTrapZone.focusStack = [];
//# sourceMappingURL=FocusTrapZone.js.map