UNPKG

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