UNPKG

13.2 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 const isShiftTab = Boolean(lastKeydown.current?.shiftKey && lastKeydown.current?.key === 'Tab');
213 const focusNext = tabbable[0];
214 const focusPrevious = tabbable[tabbable.length - 1];
215 if (typeof focusNext !== 'string' && typeof focusPrevious !== 'string') {
216 if (isShiftTab) {
217 focusPrevious.focus();
218 } else {
219 focusNext.focus();
220 }
221 }
222 // no tabbable elements in the trap focus or the focus was outside of the focus trap
223 } else {
224 rootElement.focus();
225 }
226 };
227 doc.addEventListener('focusin', contain);
228 doc.addEventListener('keydown', loopFocus, true);
229
230 // With Edge, Safari and Firefox, no focus related events are fired when the focused area stops being a focused area.
231 // for example https://bugzilla.mozilla.org/show_bug.cgi?id=559561.
232 // Instead, we can look if the active element was restored on the BODY element.
233 //
234 // The whatwg spec defines how the browser should behave but does not explicitly mention any events:
235 // https://html.spec.whatwg.org/multipage/interaction.html#focus-fixup-rule.
236 const interval = setInterval(() => {
237 if (doc.activeElement && doc.activeElement.tagName === 'BODY') {
238 contain();
239 }
240 }, 50);
241 return () => {
242 clearInterval(interval);
243 doc.removeEventListener('focusin', contain);
244 doc.removeEventListener('keydown', loopFocus, true);
245 };
246 }, [disableAutoFocus, disableEnforceFocus, disableRestoreFocus, isEnabled, open, getTabbable]);
247 const onFocus = event => {
248 if (nodeToRestore.current === null) {
249 nodeToRestore.current = event.relatedTarget;
250 }
251 activated.current = true;
252 reactFocusEventTarget.current = event.target;
253 const childrenPropsHandler = children.props.onFocus;
254 if (childrenPropsHandler) {
255 childrenPropsHandler(event);
256 }
257 };
258 const handleFocusSentinel = event => {
259 if (nodeToRestore.current === null) {
260 nodeToRestore.current = event.relatedTarget;
261 }
262 activated.current = true;
263 };
264 return /*#__PURE__*/_jsxs(React.Fragment, {
265 children: [/*#__PURE__*/_jsx("div", {
266 tabIndex: open ? 0 : -1,
267 onFocus: handleFocusSentinel,
268 ref: sentinelStart,
269 "data-testid": "sentinelStart"
270 }), /*#__PURE__*/React.cloneElement(children, {
271 ref: handleRef,
272 onFocus
273 }), /*#__PURE__*/_jsx("div", {
274 tabIndex: open ? 0 : -1,
275 onFocus: handleFocusSentinel,
276 ref: sentinelEnd,
277 "data-testid": "sentinelEnd"
278 })]
279 });
280}
281process.env.NODE_ENV !== "production" ? FocusTrap.propTypes /* remove-proptypes */ = {
282 // ┌────────────────────────────── Warning ──────────────────────────────┐
283 // │ These PropTypes are generated from the TypeScript type definitions. │
284 // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
285 // └─────────────────────────────────────────────────────────────────────┘
286 /**
287 * A single child content element.
288 */
289 children: elementAcceptingRef,
290 /**
291 * If `true`, the focus trap will not automatically shift focus to itself when it opens, and
292 * replace it to the last focused element when it closes.
293 * This also works correctly with any focus trap children that have the `disableAutoFocus` prop.
294 *
295 * Generally this should never be set to `true` as it makes the focus trap less
296 * accessible to assistive technologies, like screen readers.
297 * @default false
298 */
299 disableAutoFocus: PropTypes.bool,
300 /**
301 * If `true`, the focus trap will not prevent focus from leaving the focus trap while open.
302 *
303 * Generally this should never be set to `true` as it makes the focus trap less
304 * accessible to assistive technologies, like screen readers.
305 * @default false
306 */
307 disableEnforceFocus: PropTypes.bool,
308 /**
309 * If `true`, the focus trap will not restore focus to previously focused element once
310 * focus trap is hidden or unmounted.
311 * @default false
312 */
313 disableRestoreFocus: PropTypes.bool,
314 /**
315 * Returns an array of ordered tabbable nodes (i.e. in tab order) within the root.
316 * For instance, you can provide the "tabbable" npm dependency.
317 * @param {HTMLElement} root
318 */
319 getTabbable: PropTypes.func,
320 /**
321 * This prop extends the `open` prop.
322 * It allows to toggle the open state without having to wait for a rerender when changing the `open` prop.
323 * This prop should be memoized.
324 * It can be used to support multiple focus trap mounted at the same time.
325 * @default function defaultIsEnabled(): boolean {
326 * return true;
327 * }
328 */
329 isEnabled: PropTypes.func,
330 /**
331 * If `true`, focus is locked.
332 */
333 open: PropTypes.bool.isRequired
334} : void 0;
335if (process.env.NODE_ENV !== 'production') {
336 // eslint-disable-next-line
337 FocusTrap['propTypes' + ''] = exactProp(FocusTrap.propTypes);
338}
339export { FocusTrap };
\No newline at end of file