UNPKG

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