UNPKG

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