UNPKG

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