UNPKG

13.4 kBJavaScriptView Raw
1/* eslint-disable consistent-return, jsx-a11y/no-noninteractive-tabindex */
2import * as React from 'react';
3import PropTypes from 'prop-types';
4import { exactProp, elementAcceptingRef, unstable_useForkRef as useForkRef, unstable_ownerDocument as ownerDocument } from '@mui/utils';
5import { jsx as _jsx } from "react/jsx-runtime";
6import { jsxs as _jsxs } from "react/jsx-runtime";
7// Inspired by https://github.com/focus-trap/tabbable
8var candidatesSelector = ['input', 'select', 'textarea', 'a[href]', 'button', '[tabindex]', 'audio[controls]', 'video[controls]', '[contenteditable]:not([contenteditable="false"])'].join(',');
9function getTabIndex(node) {
10 var tabindexAttr = parseInt(node.getAttribute('tabindex') || '', 10);
11 if (!Number.isNaN(tabindexAttr)) {
12 return tabindexAttr;
13 }
14
15 // Browsers do not return `tabIndex` correctly for contentEditable nodes;
16 // https://bugs.chromium.org/p/chromium/issues/detail?id=661108&q=contenteditable%20tabindex&can=2
17 // so if they don't have a tabindex attribute specifically set, assume it's 0.
18 // in Chrome, <details/>, <audio controls/> and <video controls/> elements get a default
19 // `tabIndex` of -1 when the 'tabindex' attribute isn't specified in the DOM,
20 // yet they are still part of the regular tab order; in FF, they get a default
21 // `tabIndex` of 0; since Chrome still puts those elements in the regular tab
22 // order, consider their tab index to be 0.
23 if (node.contentEditable === 'true' || (node.nodeName === 'AUDIO' || node.nodeName === 'VIDEO' || node.nodeName === 'DETAILS') && node.getAttribute('tabindex') === null) {
24 return 0;
25 }
26 return node.tabIndex;
27}
28function isNonTabbableRadio(node) {
29 if (node.tagName !== 'INPUT' || node.type !== 'radio') {
30 return false;
31 }
32 if (!node.name) {
33 return false;
34 }
35 var getRadio = function getRadio(selector) {
36 return node.ownerDocument.querySelector("input[type=\"radio\"]".concat(selector));
37 };
38 var roving = getRadio("[name=\"".concat(node.name, "\"]:checked"));
39 if (!roving) {
40 roving = getRadio("[name=\"".concat(node.name, "\"]"));
41 }
42 return roving !== node;
43}
44function isNodeMatchingSelectorFocusable(node) {
45 if (node.disabled || node.tagName === 'INPUT' && node.type === 'hidden' || isNonTabbableRadio(node)) {
46 return false;
47 }
48 return true;
49}
50function defaultGetTabbable(root) {
51 var regularTabNodes = [];
52 var orderedTabNodes = [];
53 Array.from(root.querySelectorAll(candidatesSelector)).forEach(function (node, i) {
54 var nodeTabIndex = getTabIndex(node);
55 if (nodeTabIndex === -1 || !isNodeMatchingSelectorFocusable(node)) {
56 return;
57 }
58 if (nodeTabIndex === 0) {
59 regularTabNodes.push(node);
60 } else {
61 orderedTabNodes.push({
62 documentOrder: i,
63 tabIndex: nodeTabIndex,
64 node: node
65 });
66 }
67 });
68 return orderedTabNodes.sort(function (a, b) {
69 return a.tabIndex === b.tabIndex ? a.documentOrder - b.documentOrder : a.tabIndex - b.tabIndex;
70 }).map(function (a) {
71 return a.node;
72 }).concat(regularTabNodes);
73}
74function defaultIsEnabled() {
75 return true;
76}
77
78/**
79 * Utility component that locks focus inside the component.
80 *
81 * Demos:
82 *
83 * - [Focus Trap](https://mui.com/base/react-focus-trap/)
84 *
85 * API:
86 *
87 * - [FocusTrap API](https://mui.com/base/react-focus-trap/components-api/#focus-trap)
88 */
89function FocusTrap(props) {
90 var children = props.children,
91 _props$disableAutoFoc = props.disableAutoFocus,
92 disableAutoFocus = _props$disableAutoFoc === void 0 ? false : _props$disableAutoFoc,
93 _props$disableEnforce = props.disableEnforceFocus,
94 disableEnforceFocus = _props$disableEnforce === void 0 ? false : _props$disableEnforce,
95 _props$disableRestore = props.disableRestoreFocus,
96 disableRestoreFocus = _props$disableRestore === void 0 ? false : _props$disableRestore,
97 _props$getTabbable = props.getTabbable,
98 getTabbable = _props$getTabbable === void 0 ? defaultGetTabbable : _props$getTabbable,
99 _props$isEnabled = props.isEnabled,
100 isEnabled = _props$isEnabled === void 0 ? defaultIsEnabled : _props$isEnabled,
101 open = props.open;
102 var ignoreNextEnforceFocus = React.useRef(false);
103 var sentinelStart = React.useRef(null);
104 var sentinelEnd = React.useRef(null);
105 var nodeToRestore = React.useRef(null);
106 var reactFocusEventTarget = React.useRef(null);
107 // This variable is useful when disableAutoFocus is true.
108 // It waits for the active element to move into the component to activate.
109 var activated = React.useRef(false);
110 var rootRef = React.useRef(null);
111 // @ts-expect-error TODO upstream fix
112 var handleRef = useForkRef(children.ref, rootRef);
113 var lastKeydown = React.useRef(null);
114 React.useEffect(function () {
115 // We might render an empty child.
116 if (!open || !rootRef.current) {
117 return;
118 }
119 activated.current = !disableAutoFocus;
120 }, [disableAutoFocus, open]);
121 React.useEffect(function () {
122 // We might render an empty child.
123 if (!open || !rootRef.current) {
124 return;
125 }
126 var doc = ownerDocument(rootRef.current);
127 if (!rootRef.current.contains(doc.activeElement)) {
128 if (!rootRef.current.hasAttribute('tabIndex')) {
129 if (process.env.NODE_ENV !== 'production') {
130 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'));
131 }
132 rootRef.current.setAttribute('tabIndex', '-1');
133 }
134 if (activated.current) {
135 rootRef.current.focus();
136 }
137 }
138 return function () {
139 // restoreLastFocus()
140 if (!disableRestoreFocus) {
141 // In IE11 it is possible for document.activeElement to be null resulting
142 // in nodeToRestore.current being null.
143 // Not all elements in IE11 have a focus method.
144 // Once IE11 support is dropped the focus() call can be unconditional.
145 if (nodeToRestore.current && nodeToRestore.current.focus) {
146 ignoreNextEnforceFocus.current = true;
147 nodeToRestore.current.focus();
148 }
149 nodeToRestore.current = null;
150 }
151 };
152 // Missing `disableRestoreFocus` which is fine.
153 // We don't support changing that prop on an open FocusTrap
154 // eslint-disable-next-line react-hooks/exhaustive-deps
155 }, [open]);
156 React.useEffect(function () {
157 // We might render an empty child.
158 if (!open || !rootRef.current) {
159 return;
160 }
161 var doc = ownerDocument(rootRef.current);
162 var contain = function contain(nativeEvent) {
163 var rootElement = rootRef.current; // Cleanup functions are executed lazily in React 17.
164 // Contain can be called between the component being unmounted and its cleanup function being run.
165 if (rootElement === null) {
166 return;
167 }
168 if (!doc.hasFocus() || disableEnforceFocus || !isEnabled() || ignoreNextEnforceFocus.current) {
169 ignoreNextEnforceFocus.current = false;
170 return;
171 }
172 if (!rootElement.contains(doc.activeElement)) {
173 // if the focus event is not coming from inside the children's react tree, reset the refs
174 if (nativeEvent && reactFocusEventTarget.current !== nativeEvent.target || doc.activeElement !== reactFocusEventTarget.current) {
175 reactFocusEventTarget.current = null;
176 } else if (reactFocusEventTarget.current !== null) {
177 return;
178 }
179 if (!activated.current) {
180 return;
181 }
182 var tabbable = [];
183 if (doc.activeElement === sentinelStart.current || doc.activeElement === sentinelEnd.current) {
184 tabbable = getTabbable(rootRef.current);
185 }
186 if (tabbable.length > 0) {
187 var _lastKeydown$current, _lastKeydown$current2;
188 var isShiftTab = Boolean(((_lastKeydown$current = lastKeydown.current) == null ? void 0 : _lastKeydown$current.shiftKey) && ((_lastKeydown$current2 = lastKeydown.current) == null ? void 0 : _lastKeydown$current2.key) === 'Tab');
189 var focusNext = tabbable[0];
190 var focusPrevious = tabbable[tabbable.length - 1];
191 if (typeof focusNext !== 'string' && typeof focusPrevious !== 'string') {
192 if (isShiftTab) {
193 focusPrevious.focus();
194 } else {
195 focusNext.focus();
196 }
197 }
198 } else {
199 rootElement.focus();
200 }
201 }
202 };
203 var loopFocus = function loopFocus(nativeEvent) {
204 lastKeydown.current = nativeEvent;
205 if (disableEnforceFocus || !isEnabled() || nativeEvent.key !== 'Tab') {
206 return;
207 }
208
209 // Make sure the next tab starts from the right place.
210 // doc.activeElement refers to the origin.
211 if (doc.activeElement === rootRef.current && nativeEvent.shiftKey) {
212 // We need to ignore the next contain as
213 // it will try to move the focus back to the rootRef element.
214 ignoreNextEnforceFocus.current = true;
215 if (sentinelEnd.current) {
216 sentinelEnd.current.focus();
217 }
218 }
219 };
220 doc.addEventListener('focusin', contain);
221 doc.addEventListener('keydown', loopFocus, true);
222
223 // With Edge, Safari and Firefox, no focus related events are fired when the focused area stops being a focused area.
224 // e.g. https://bugzilla.mozilla.org/show_bug.cgi?id=559561.
225 // Instead, we can look if the active element was restored on the BODY element.
226 //
227 // The whatwg spec defines how the browser should behave but does not explicitly mention any events:
228 // https://html.spec.whatwg.org/multipage/interaction.html#focus-fixup-rule.
229 var interval = setInterval(function () {
230 if (doc.activeElement && doc.activeElement.tagName === 'BODY') {
231 contain(null);
232 }
233 }, 50);
234 return function () {
235 clearInterval(interval);
236 doc.removeEventListener('focusin', contain);
237 doc.removeEventListener('keydown', loopFocus, true);
238 };
239 }, [disableAutoFocus, disableEnforceFocus, disableRestoreFocus, isEnabled, open, getTabbable]);
240 var onFocus = function onFocus(event) {
241 if (nodeToRestore.current === null) {
242 nodeToRestore.current = event.relatedTarget;
243 }
244 activated.current = true;
245 reactFocusEventTarget.current = event.target;
246 var childrenPropsHandler = children.props.onFocus;
247 if (childrenPropsHandler) {
248 childrenPropsHandler(event);
249 }
250 };
251 var handleFocusSentinel = function handleFocusSentinel(event) {
252 if (nodeToRestore.current === null) {
253 nodeToRestore.current = event.relatedTarget;
254 }
255 activated.current = true;
256 };
257 return /*#__PURE__*/_jsxs(React.Fragment, {
258 children: [/*#__PURE__*/_jsx("div", {
259 tabIndex: open ? 0 : -1,
260 onFocus: handleFocusSentinel,
261 ref: sentinelStart,
262 "data-testid": "sentinelStart"
263 }), /*#__PURE__*/React.cloneElement(children, {
264 ref: handleRef,
265 onFocus: onFocus
266 }), /*#__PURE__*/_jsx("div", {
267 tabIndex: open ? 0 : -1,
268 onFocus: handleFocusSentinel,
269 ref: sentinelEnd,
270 "data-testid": "sentinelEnd"
271 })]
272 });
273}
274process.env.NODE_ENV !== "production" ? FocusTrap.propTypes /* remove-proptypes */ = {
275 // ----------------------------- Warning --------------------------------
276 // | These PropTypes are generated from the TypeScript type definitions |
277 // | To update them edit TypeScript types and run "yarn proptypes" |
278 // ----------------------------------------------------------------------
279 /**
280 * A single child content element.
281 */
282 children: elementAcceptingRef,
283 /**
284 * If `true`, the focus trap will not automatically shift focus to itself when it opens, and
285 * replace it to the last focused element when it closes.
286 * This also works correctly with any focus trap children that have the `disableAutoFocus` prop.
287 *
288 * Generally this should never be set to `true` as it makes the focus trap less
289 * accessible to assistive technologies, like screen readers.
290 * @default false
291 */
292 disableAutoFocus: PropTypes.bool,
293 /**
294 * If `true`, the focus trap will not prevent focus from leaving the focus trap while open.
295 *
296 * Generally this should never be set to `true` as it makes the focus trap less
297 * accessible to assistive technologies, like screen readers.
298 * @default false
299 */
300 disableEnforceFocus: PropTypes.bool,
301 /**
302 * If `true`, the focus trap will not restore focus to previously focused element once
303 * focus trap is hidden or unmounted.
304 * @default false
305 */
306 disableRestoreFocus: PropTypes.bool,
307 /**
308 * Returns an array of ordered tabbable nodes (i.e. in tab order) within the root.
309 * For instance, you can provide the "tabbable" npm dependency.
310 * @param {HTMLElement} root
311 */
312 getTabbable: PropTypes.func,
313 /**
314 * This prop extends the `open` prop.
315 * It allows to toggle the open state without having to wait for a rerender when changing the `open` prop.
316 * This prop should be memoized.
317 * It can be used to support multiple focus trap mounted at the same time.
318 * @default function defaultIsEnabled(): boolean {
319 * return true;
320 * }
321 */
322 isEnabled: PropTypes.func,
323 /**
324 * If `true`, focus is locked.
325 */
326 open: PropTypes.bool.isRequired
327} : void 0;
328if (process.env.NODE_ENV !== 'production') {
329 // eslint-disable-next-line
330 FocusTrap['propTypes' + ''] = exactProp(FocusTrap.propTypes);
331}
332export default FocusTrap;
\No newline at end of file