UNPKG

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