346 lines
14 KiB
Plaintext
346 lines
14 KiB
Plaintext
"use strict";
|
|
'use client';
|
|
|
|
/* eslint-disable consistent-return, jsx-a11y/no-noninteractive-tabindex */
|
|
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
|
|
Object.defineProperty(exports, "__esModule", {
|
|
value: true
|
|
});
|
|
exports.FocusTrap = FocusTrap;
|
|
var React = _interopRequireWildcard(require("react"));
|
|
var _propTypes = _interopRequireDefault(require("prop-types"));
|
|
var _utils = require("@mui/utils");
|
|
var _jsxRuntime = require("react/jsx-runtime");
|
|
function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); }
|
|
function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && Object.prototype.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; }
|
|
// Inspired by https://github.com/focus-trap/tabbable
|
|
const candidatesSelector = ['input', 'select', 'textarea', 'a[href]', 'button', '[tabindex]', 'audio[controls]', 'video[controls]', '[contenteditable]:not([contenteditable="false"])'].join(',');
|
|
function getTabIndex(node) {
|
|
const tabindexAttr = parseInt(node.getAttribute('tabindex') || '', 10);
|
|
if (!Number.isNaN(tabindexAttr)) {
|
|
return tabindexAttr;
|
|
}
|
|
|
|
// Browsers do not return `tabIndex` correctly for contentEditable nodes;
|
|
// https://bugs.chromium.org/p/chromium/issues/detail?id=661108&q=contenteditable%20tabindex&can=2
|
|
// so if they don't have a tabindex attribute specifically set, assume it's 0.
|
|
// in Chrome, <details/>, <audio controls/> and <video controls/> elements get a default
|
|
// `tabIndex` of -1 when the 'tabindex' attribute isn't specified in the DOM,
|
|
// yet they are still part of the regular tab order; in FF, they get a default
|
|
// `tabIndex` of 0; since Chrome still puts those elements in the regular tab
|
|
// order, consider their tab index to be 0.
|
|
if (node.contentEditable === 'true' || (node.nodeName === 'AUDIO' || node.nodeName === 'VIDEO' || node.nodeName === 'DETAILS') && node.getAttribute('tabindex') === null) {
|
|
return 0;
|
|
}
|
|
return node.tabIndex;
|
|
}
|
|
function isNonTabbableRadio(node) {
|
|
if (node.tagName !== 'INPUT' || node.type !== 'radio') {
|
|
return false;
|
|
}
|
|
if (!node.name) {
|
|
return false;
|
|
}
|
|
const getRadio = selector => node.ownerDocument.querySelector(`input[type="radio"]${selector}`);
|
|
let roving = getRadio(`[name="${node.name}"]:checked`);
|
|
if (!roving) {
|
|
roving = getRadio(`[name="${node.name}"]`);
|
|
}
|
|
return roving !== node;
|
|
}
|
|
function isNodeMatchingSelectorFocusable(node) {
|
|
if (node.disabled || node.tagName === 'INPUT' && node.type === 'hidden' || isNonTabbableRadio(node)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
function defaultGetTabbable(root) {
|
|
const regularTabNodes = [];
|
|
const orderedTabNodes = [];
|
|
Array.from(root.querySelectorAll(candidatesSelector)).forEach((node, i) => {
|
|
const nodeTabIndex = getTabIndex(node);
|
|
if (nodeTabIndex === -1 || !isNodeMatchingSelectorFocusable(node)) {
|
|
return;
|
|
}
|
|
if (nodeTabIndex === 0) {
|
|
regularTabNodes.push(node);
|
|
} else {
|
|
orderedTabNodes.push({
|
|
documentOrder: i,
|
|
tabIndex: nodeTabIndex,
|
|
node: node
|
|
});
|
|
}
|
|
});
|
|
return orderedTabNodes.sort((a, b) => a.tabIndex === b.tabIndex ? a.documentOrder - b.documentOrder : a.tabIndex - b.tabIndex).map(a => a.node).concat(regularTabNodes);
|
|
}
|
|
function defaultIsEnabled() {
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Utility component that locks focus inside the component.
|
|
*
|
|
* Demos:
|
|
*
|
|
* - [Focus Trap](https://mui.com/base-ui/react-focus-trap/)
|
|
*
|
|
* API:
|
|
*
|
|
* - [FocusTrap API](https://mui.com/base-ui/react-focus-trap/components-api/#focus-trap)
|
|
*/
|
|
function FocusTrap(props) {
|
|
const {
|
|
children,
|
|
disableAutoFocus = false,
|
|
disableEnforceFocus = false,
|
|
disableRestoreFocus = false,
|
|
getTabbable = defaultGetTabbable,
|
|
isEnabled = defaultIsEnabled,
|
|
open
|
|
} = props;
|
|
const ignoreNextEnforceFocus = React.useRef(false);
|
|
const sentinelStart = React.useRef(null);
|
|
const sentinelEnd = React.useRef(null);
|
|
const nodeToRestore = React.useRef(null);
|
|
const reactFocusEventTarget = React.useRef(null);
|
|
// This variable is useful when disableAutoFocus is true.
|
|
// It waits for the active element to move into the component to activate.
|
|
const activated = React.useRef(false);
|
|
const rootRef = React.useRef(null);
|
|
// @ts-expect-error TODO upstream fix
|
|
const handleRef = (0, _utils.unstable_useForkRef)(children.ref, rootRef);
|
|
const lastKeydown = React.useRef(null);
|
|
React.useEffect(() => {
|
|
// We might render an empty child.
|
|
if (!open || !rootRef.current) {
|
|
return;
|
|
}
|
|
activated.current = !disableAutoFocus;
|
|
}, [disableAutoFocus, open]);
|
|
React.useEffect(() => {
|
|
// We might render an empty child.
|
|
if (!open || !rootRef.current) {
|
|
return;
|
|
}
|
|
const doc = (0, _utils.unstable_ownerDocument)(rootRef.current);
|
|
if (!rootRef.current.contains(doc.activeElement)) {
|
|
if (!rootRef.current.hasAttribute('tabIndex')) {
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
console.error(['MUI: The modal content node does not accept focus.', 'For the benefit of assistive technologies, ' + 'the tabIndex of the node is being set to "-1".'].join('\n'));
|
|
}
|
|
rootRef.current.setAttribute('tabIndex', '-1');
|
|
}
|
|
if (activated.current) {
|
|
rootRef.current.focus();
|
|
}
|
|
}
|
|
return () => {
|
|
// restoreLastFocus()
|
|
if (!disableRestoreFocus) {
|
|
// In IE11 it is possible for document.activeElement to be null resulting
|
|
// in nodeToRestore.current being null.
|
|
// Not all elements in IE11 have a focus method.
|
|
// Once IE11 support is dropped the focus() call can be unconditional.
|
|
if (nodeToRestore.current && nodeToRestore.current.focus) {
|
|
ignoreNextEnforceFocus.current = true;
|
|
nodeToRestore.current.focus();
|
|
}
|
|
nodeToRestore.current = null;
|
|
}
|
|
};
|
|
// Missing `disableRestoreFocus` which is fine.
|
|
// We don't support changing that prop on an open FocusTrap
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open]);
|
|
React.useEffect(() => {
|
|
// We might render an empty child.
|
|
if (!open || !rootRef.current) {
|
|
return;
|
|
}
|
|
const doc = (0, _utils.unstable_ownerDocument)(rootRef.current);
|
|
const loopFocus = nativeEvent => {
|
|
lastKeydown.current = nativeEvent;
|
|
if (disableEnforceFocus || !isEnabled() || nativeEvent.key !== 'Tab') {
|
|
return;
|
|
}
|
|
|
|
// Make sure the next tab starts from the right place.
|
|
// doc.activeElement refers to the origin.
|
|
if (doc.activeElement === rootRef.current && nativeEvent.shiftKey) {
|
|
// We need to ignore the next contain as
|
|
// it will try to move the focus back to the rootRef element.
|
|
ignoreNextEnforceFocus.current = true;
|
|
if (sentinelEnd.current) {
|
|
sentinelEnd.current.focus();
|
|
}
|
|
}
|
|
};
|
|
const contain = () => {
|
|
const rootElement = rootRef.current;
|
|
|
|
// Cleanup functions are executed lazily in React 17.
|
|
// Contain can be called between the component being unmounted and its cleanup function being run.
|
|
if (rootElement === null) {
|
|
return;
|
|
}
|
|
if (!doc.hasFocus() || !isEnabled() || ignoreNextEnforceFocus.current) {
|
|
ignoreNextEnforceFocus.current = false;
|
|
return;
|
|
}
|
|
|
|
// The focus is already inside
|
|
if (rootElement.contains(doc.activeElement)) {
|
|
return;
|
|
}
|
|
|
|
// The disableEnforceFocus is set and the focus is outside of the focus trap (and sentinel nodes)
|
|
if (disableEnforceFocus && doc.activeElement !== sentinelStart.current && doc.activeElement !== sentinelEnd.current) {
|
|
return;
|
|
}
|
|
|
|
// if the focus event is not coming from inside the children's react tree, reset the refs
|
|
if (doc.activeElement !== reactFocusEventTarget.current) {
|
|
reactFocusEventTarget.current = null;
|
|
} else if (reactFocusEventTarget.current !== null) {
|
|
return;
|
|
}
|
|
if (!activated.current) {
|
|
return;
|
|
}
|
|
let tabbable = [];
|
|
if (doc.activeElement === sentinelStart.current || doc.activeElement === sentinelEnd.current) {
|
|
tabbable = getTabbable(rootRef.current);
|
|
}
|
|
|
|
// one of the sentinel nodes was focused, so move the focus
|
|
// to the first/last tabbable element inside the focus trap
|
|
if (tabbable.length > 0) {
|
|
var _lastKeydown$current, _lastKeydown$current2;
|
|
const isShiftTab = Boolean(((_lastKeydown$current = lastKeydown.current) == null ? void 0 : _lastKeydown$current.shiftKey) && ((_lastKeydown$current2 = lastKeydown.current) == null ? void 0 : _lastKeydown$current2.key) === 'Tab');
|
|
const focusNext = tabbable[0];
|
|
const focusPrevious = tabbable[tabbable.length - 1];
|
|
if (typeof focusNext !== 'string' && typeof focusPrevious !== 'string') {
|
|
if (isShiftTab) {
|
|
focusPrevious.focus();
|
|
} else {
|
|
focusNext.focus();
|
|
}
|
|
}
|
|
// no tabbable elements in the trap focus or the focus was outside of the focus trap
|
|
} else {
|
|
rootElement.focus();
|
|
}
|
|
};
|
|
doc.addEventListener('focusin', contain);
|
|
doc.addEventListener('keydown', loopFocus, true);
|
|
|
|
// With Edge, Safari and Firefox, no focus related events are fired when the focused area stops being a focused area.
|
|
// e.g. https://bugzilla.mozilla.org/show_bug.cgi?id=559561.
|
|
// Instead, we can look if the active element was restored on the BODY element.
|
|
//
|
|
// The whatwg spec defines how the browser should behave but does not explicitly mention any events:
|
|
// https://html.spec.whatwg.org/multipage/interaction.html#focus-fixup-rule.
|
|
const interval = setInterval(() => {
|
|
if (doc.activeElement && doc.activeElement.tagName === 'BODY') {
|
|
contain();
|
|
}
|
|
}, 50);
|
|
return () => {
|
|
clearInterval(interval);
|
|
doc.removeEventListener('focusin', contain);
|
|
doc.removeEventListener('keydown', loopFocus, true);
|
|
};
|
|
}, [disableAutoFocus, disableEnforceFocus, disableRestoreFocus, isEnabled, open, getTabbable]);
|
|
const onFocus = event => {
|
|
if (nodeToRestore.current === null) {
|
|
nodeToRestore.current = event.relatedTarget;
|
|
}
|
|
activated.current = true;
|
|
reactFocusEventTarget.current = event.target;
|
|
const childrenPropsHandler = children.props.onFocus;
|
|
if (childrenPropsHandler) {
|
|
childrenPropsHandler(event);
|
|
}
|
|
};
|
|
const handleFocusSentinel = event => {
|
|
if (nodeToRestore.current === null) {
|
|
nodeToRestore.current = event.relatedTarget;
|
|
}
|
|
activated.current = true;
|
|
};
|
|
return /*#__PURE__*/(0, _jsxRuntime.jsxs)(React.Fragment, {
|
|
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
|
|
tabIndex: open ? 0 : -1,
|
|
onFocus: handleFocusSentinel,
|
|
ref: sentinelStart,
|
|
"data-testid": "sentinelStart"
|
|
}), /*#__PURE__*/React.cloneElement(children, {
|
|
ref: handleRef,
|
|
onFocus
|
|
}), /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
|
|
tabIndex: open ? 0 : -1,
|
|
onFocus: handleFocusSentinel,
|
|
ref: sentinelEnd,
|
|
"data-testid": "sentinelEnd"
|
|
})]
|
|
});
|
|
}
|
|
process.env.NODE_ENV !== "production" ? FocusTrap.propTypes /* remove-proptypes */ = {
|
|
// ┌────────────────────────────── Warning ──────────────────────────────┐
|
|
// │ These PropTypes are generated from the TypeScript type definitions. │
|
|
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
|
|
// └─────────────────────────────────────────────────────────────────────┘
|
|
/**
|
|
* A single child content element.
|
|
*/
|
|
children: _utils.elementAcceptingRef,
|
|
/**
|
|
* If `true`, the focus trap will not automatically shift focus to itself when it opens, and
|
|
* replace it to the last focused element when it closes.
|
|
* This also works correctly with any focus trap children that have the `disableAutoFocus` prop.
|
|
*
|
|
* Generally this should never be set to `true` as it makes the focus trap less
|
|
* accessible to assistive technologies, like screen readers.
|
|
* @default false
|
|
*/
|
|
disableAutoFocus: _propTypes.default.bool,
|
|
/**
|
|
* If `true`, the focus trap will not prevent focus from leaving the focus trap while open.
|
|
*
|
|
* Generally this should never be set to `true` as it makes the focus trap less
|
|
* accessible to assistive technologies, like screen readers.
|
|
* @default false
|
|
*/
|
|
disableEnforceFocus: _propTypes.default.bool,
|
|
/**
|
|
* If `true`, the focus trap will not restore focus to previously focused element once
|
|
* focus trap is hidden or unmounted.
|
|
* @default false
|
|
*/
|
|
disableRestoreFocus: _propTypes.default.bool,
|
|
/**
|
|
* Returns an array of ordered tabbable nodes (i.e. in tab order) within the root.
|
|
* For instance, you can provide the "tabbable" npm dependency.
|
|
* @param {HTMLElement} root
|
|
*/
|
|
getTabbable: _propTypes.default.func,
|
|
/**
|
|
* This prop extends the `open` prop.
|
|
* It allows to toggle the open state without having to wait for a rerender when changing the `open` prop.
|
|
* This prop should be memoized.
|
|
* It can be used to support multiple focus trap mounted at the same time.
|
|
* @default function defaultIsEnabled(): boolean {
|
|
* return true;
|
|
* }
|
|
*/
|
|
isEnabled: _propTypes.default.func,
|
|
/**
|
|
* If `true`, focus is locked.
|
|
*/
|
|
open: _propTypes.default.bool.isRequired
|
|
} : void 0;
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
// eslint-disable-next-line
|
|
FocusTrap['propTypes' + ''] = (0, _utils.exactProp)(FocusTrap.propTypes);
|
|
} |