UNPKG

focus-trap-react

Version:
424 lines (405 loc) 21.7 kB
"use strict"; function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); } var _exec$, _exec; function _classCallCheck(a, n) { if (!(a instanceof n)) throw new TypeError("Cannot call a class as a function"); } function _defineProperties(e, r) { for (var t = 0; t < r.length; t++) { var o = r[t]; o.enumerable = o.enumerable || !1, o.configurable = !0, "value" in o && (o.writable = !0), Object.defineProperty(e, _toPropertyKey(o.key), o); } } function _createClass(e, r, t) { return r && _defineProperties(e.prototype, r), t && _defineProperties(e, t), Object.defineProperty(e, "prototype", { writable: !1 }), e; } function _callSuper(t, o, e) { return o = _getPrototypeOf(o), _possibleConstructorReturn(t, _isNativeReflectConstruct() ? Reflect.construct(o, e || [], _getPrototypeOf(t).constructor) : o.apply(t, e)); } function _possibleConstructorReturn(t, e) { if (e && ("object" == _typeof(e) || "function" == typeof e)) return e; if (void 0 !== e) throw new TypeError("Derived constructors may only return object or undefined"); return _assertThisInitialized(t); } function _assertThisInitialized(e) { if (void 0 === e) throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); return e; } function _isNativeReflectConstruct() { try { var t = !Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); } catch (t) {} return (_isNativeReflectConstruct = function _isNativeReflectConstruct() { return !!t; })(); } function _getPrototypeOf(t) { return _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf.bind() : function (t) { return t.__proto__ || Object.getPrototypeOf(t); }, _getPrototypeOf(t); } function _inherits(t, e) { if ("function" != typeof e && null !== e) throw new TypeError("Super expression must either be null or a function"); t.prototype = Object.create(e && e.prototype, { constructor: { value: t, writable: !0, configurable: !0 } }), Object.defineProperty(t, "prototype", { writable: !1 }), e && _setPrototypeOf(t, e); } function _setPrototypeOf(t, e) { return _setPrototypeOf = Object.setPrototypeOf ? Object.setPrototypeOf.bind() : function (t, e) { return t.__proto__ = e, t; }, _setPrototypeOf(t, e); } function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; } function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == _typeof(i) ? i : i + ""; } function _toPrimitive(t, r) { if ("object" != _typeof(t) || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != _typeof(i)) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); } var React = require('react'); var _require = require('focus-trap'), createFocusTrap = _require.createFocusTrap; var _require2 = require('tabbable'), isFocusable = _require2.isFocusable; /** * The major version of React currently running. * @type {number} */ var reactVerMajor = parseInt((_exec$ = (_exec = /^(\d+)\./.exec(React.version)) === null || _exec === void 0 ? void 0 : _exec[1]) !== null && _exec$ !== void 0 ? _exec$ : 0, 10); /** * @type {import('../index.d.ts').FocusTrap} */ var FocusTrap = /*#__PURE__*/function (_React$Component) { function FocusTrap(props) { var _this; _classCallCheck(this, FocusTrap); _this = _callSuper(this, FocusTrap, [props]); /** * Gets the node for the given option, which is expected to be an option that * can be either a DOM node, a string that is a selector to get a node, `false` * (if a node is explicitly NOT given), or a function that returns any of these * values. * @param {string} optionName * @returns {undefined | false | HTMLElement | SVGElement} Returns * `undefined` if the option is not specified; `false` if the option * resolved to `false` (node explicitly not given); otherwise, the resolved * DOM node. * @throws {Error} If the option is set, not `false`, and is not, or does not * resolve to a node. */ _defineProperty(_this, "getNodeForOption", function (optionName) { var _this$internalOptions; // use internal options first, falling back to original options var optionValue = (_this$internalOptions = this.internalOptions[optionName]) !== null && _this$internalOptions !== void 0 ? _this$internalOptions : this.originalOptions[optionName]; if (typeof optionValue === 'function') { for (var _len = arguments.length, params = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { params[_key - 1] = arguments[_key]; } optionValue = optionValue.apply(void 0, params); } if (optionValue === true) { optionValue = undefined; // use default value } if (!optionValue) { if (optionValue === undefined || optionValue === false) { return optionValue; } // else, empty string (invalid), null (invalid), 0 (invalid) throw new Error("`".concat(optionName, "` was specified but was not a node, or did not return a node")); } var node = optionValue; // could be HTMLElement, SVGElement, or non-empty string at this point if (typeof optionValue === 'string') { var _this$getDocument; node = (_this$getDocument = this.getDocument()) === null || _this$getDocument === void 0 ? void 0 : _this$getDocument.querySelector(optionValue); // resolve to node, or null if fails if (!node) { throw new Error("`".concat(optionName, "` as selector refers to no known node")); } } return node; }); _this.handleDeactivate = _this.handleDeactivate.bind(_this); _this.handlePostDeactivate = _this.handlePostDeactivate.bind(_this); _this.handleClickOutsideDeactivates = _this.handleClickOutsideDeactivates.bind(_this); // focus-trap options used internally when creating the trap _this.internalOptions = { // We need to hijack the returnFocusOnDeactivate option, // because React can move focus into the element before we arrived at // this lifecycle hook (e.g. with autoFocus inputs). So the component // captures the previouslyFocusedElement in componentWillMount, // then (optionally) returns focus to it in componentWillUnmount. returnFocusOnDeactivate: false, // the rest of these are also related to deactivation of the trap, and we // need to use them and control them as well checkCanReturnFocus: null, onDeactivate: _this.handleDeactivate, onPostDeactivate: _this.handlePostDeactivate, // we need to special-case this setting as well so that we can know if we should // NOT return focus if the trap gets auto-deactivated as the result of an // outside click (otherwise, we'll always think we should return focus because // of how we manage that flag internally here) clickOutsideDeactivates: _this.handleClickOutsideDeactivates }; // original options provided by the consumer _this.originalOptions = { // because of the above `internalOptions`, we maintain our own flag for // this option, and default it to `true` because that's focus-trap's default returnFocusOnDeactivate: true, // because of the above `internalOptions`, we keep these separate since // they're part of the deactivation process which we configure (internally) to // be shared between focus-trap and focus-trap-react onDeactivate: null, onPostDeactivate: null, checkCanReturnFocus: null, // the user's setting, defaulted to false since focus-trap defaults this to false clickOutsideDeactivates: false }; var focusTrapOptions = props.focusTrapOptions; for (var optionName in focusTrapOptions) { if (!Object.prototype.hasOwnProperty.call(focusTrapOptions, optionName)) { continue; } if (optionName === 'returnFocusOnDeactivate' || optionName === 'onDeactivate' || optionName === 'onPostDeactivate' || optionName === 'checkCanReturnFocus' || optionName === 'clickOutsideDeactivates') { _this.originalOptions[optionName] = focusTrapOptions[optionName]; continue; // exclude from internalOptions } _this.internalOptions[optionName] = focusTrapOptions[optionName]; } // if set, `{ target: Node, allowDeactivation: boolean }` where `target` is the outside // node that was clicked, and `allowDeactivation` is the result of the consumer's // option (stored in `this.originalOptions.clickOutsideDeactivates`, which may be a // function) whether to allow or deny auto-deactivation on click on this outside node _this.outsideClick = null; // elements from which to create the focus trap on mount; if a child is used // instead of the `containerElements` prop, we'll get the child's related // element when the trap renders and then is declared 'mounted' _this.focusTrapElements = props.containerElements || []; // now we remember what the currently focused element is, not relying on focus-trap _this.updatePreviousElement(); return _this; } /** * Gets the configured document. * @returns {Document|undefined} Configured document, falling back to the main * document, if it exists. During SSR, `undefined` is returned since the * document doesn't exist. */ _inherits(FocusTrap, _React$Component); return _createClass(FocusTrap, [{ key: "getDocument", value: function getDocument() { // SSR: careful to check if `document` exists before accessing it as a variable return this.props.focusTrapOptions.document || (typeof document !== 'undefined' ? document : undefined); } }, { key: "getReturnFocusNode", value: function getReturnFocusNode() { var node = this.getNodeForOption('setReturnFocus', this.previouslyFocusedElement); return node ? node : node === false ? false : this.previouslyFocusedElement; } /** Update the previously focused element with the currently focused element. */ }, { key: "updatePreviousElement", value: function updatePreviousElement() { var currentDocument = this.getDocument(); if (currentDocument) { this.previouslyFocusedElement = currentDocument.activeElement; } } }, { key: "deactivateTrap", value: function deactivateTrap() { // NOTE: it's possible the focus trap has already been deactivated without our knowing it, // especially if the user set the `clickOutsideDeactivates: true` option on the trap, // and the mouse was clicked on some element outside the trap; at that point, focus-trap // will initiate its auto-deactivation process, which will call our own // handleDeactivate(), which will call into this method if (!this.focusTrap || !this.focusTrap.active) { return; } this.focusTrap.deactivate({ // NOTE: we never let the trap return the focus since we do that ourselves returnFocus: false, // we'll call this in our own post deactivate handler so make sure the trap doesn't // do it prematurely checkCanReturnFocus: null, // let it call the user's original deactivate handler, if any, instead of // our own which calls back into this function onDeactivate: this.originalOptions.onDeactivate // NOTE: for post deactivate, don't specify anything so that it calls the // onPostDeactivate handler specified on `this.internalOptions` // which will always be our own `handlePostDeactivate()` handler, which // will finish things off by calling the user's provided onPostDeactivate // handler, if any, at the right time // onPostDeactivate: NOTHING }); } }, { key: "handleClickOutsideDeactivates", value: function handleClickOutsideDeactivates(event) { // use consumer's option (or call their handler) as the permission or denial var allowDeactivation = typeof this.originalOptions.clickOutsideDeactivates === 'function' ? this.originalOptions.clickOutsideDeactivates.call(null, event) // call out of context : this.originalOptions.clickOutsideDeactivates; // boolean if (allowDeactivation) { // capture the outside target that was clicked so we can use it in the deactivation // process since the consumer allowed it to cause auto-deactivation this.outsideClick = { target: event.target, allowDeactivation: allowDeactivation }; } return allowDeactivation; } }, { key: "handleDeactivate", value: function handleDeactivate() { if (this.originalOptions.onDeactivate) { this.originalOptions.onDeactivate.call(null); // call user's handler out of context } this.deactivateTrap(); } }, { key: "handlePostDeactivate", value: function handlePostDeactivate() { var _this2 = this; var finishDeactivation = function finishDeactivation() { var returnFocusNode = _this2.getReturnFocusNode(); var canReturnFocus = !!( // did the consumer allow it? _this2.originalOptions.returnFocusOnDeactivate && // can we actually focus the node? returnFocusNode !== null && returnFocusNode !== void 0 && returnFocusNode.focus && ( // was there an outside click that allowed deactivation? !_this2.outsideClick || // did the consumer allow deactivation when the outside node was clicked? _this2.outsideClick.allowDeactivation && // is the outside node NOT focusable (implying that it did NOT receive focus // as a result of the click-through) -- in which case do NOT restore focus // to `returnFocusNode` because focus should remain on the outside node !isFocusable(_this2.outsideClick.target, _this2.internalOptions.tabbableOptions)) // if no, the restore focus to `returnFocusNode` at this point ); var _this2$internalOption = _this2.internalOptions.preventScroll, preventScroll = _this2$internalOption === void 0 ? false : _this2$internalOption; if (canReturnFocus) { // return focus to the element that had focus when the trap was activated returnFocusNode.focus({ preventScroll: preventScroll }); } if (_this2.originalOptions.onPostDeactivate) { _this2.originalOptions.onPostDeactivate.call(null); // don't call it in context of "this" } _this2.outsideClick = null; // reset: no longer needed }; if (this.originalOptions.checkCanReturnFocus) { this.originalOptions.checkCanReturnFocus.call(null, this.getReturnFocusNode()) // call out of context .then(finishDeactivation, finishDeactivation); } else { finishDeactivation(); } } }, { key: "setupFocusTrap", value: function setupFocusTrap() { if (this.focusTrap) { // trap already exists: it's possible we're in StrictMode and we're being remounted, // in which case, we will have deactivated the trap when we got unmounted (remember, // StrictMode, in development, purposely unmounts and remounts components after // mounting them the first time to make sure they have reusable state, // @see https://reactjs.org/docs/strict-mode.html#ensuring-reusable-state) so now // we need to restore the state of the trap according to our component state // NOTE: Strict mode __violates__ assumptions about the `componentWillUnmount()` API // which clearly states -- even for React 18 -- that, "Once a component instance is // unmounted, __it will never be mounted again.__" (emphasis ours). So when we get // unmounted, we assume we're gone forever and we deactivate the trap. But then // we get remounted and we're supposed to restore state. But if you had paused, // we've now deactivated (we don't know we're amount to get remounted again) // which means we need to reactivate and then pause. Otherwise, do nothing. if (this.props.active && !this.focusTrap.active) { this.focusTrap.activate(); if (this.props.paused) { this.focusTrap.pause(); } } } else { var nodesExist = this.focusTrapElements.some(Boolean); if (nodesExist) { this.focusTrap = this.props._createFocusTrap(this.focusTrapElements, this.internalOptions); if (this.props.active) { this.focusTrap.activate(); } if (this.props.paused) { this.focusTrap.pause(); } } } } }, { key: "componentDidMount", value: function componentDidMount() { if (this.props.active) { this.setupFocusTrap(); } // else, wait for later activation in case the `focusTrapOptions` will be updated // again before the trap is activated (e.g. if waiting to know what the document // object will be, so the Trap must be rendered, but the consumer is waiting to // activate until they have obtained the document from a ref) // @see https://github.com/focus-trap/focus-trap-react/issues/539 } }, { key: "componentDidUpdate", value: function componentDidUpdate(prevProps) { if (this.focusTrap) { if (prevProps.containerElements !== this.props.containerElements) { this.focusTrap.updateContainerElements(this.props.containerElements); } var hasActivated = !prevProps.active && this.props.active; var hasDeactivated = prevProps.active && !this.props.active; var hasPaused = !prevProps.paused && this.props.paused; var hasUnpaused = prevProps.paused && !this.props.paused; if (hasActivated) { this.updatePreviousElement(); this.focusTrap.activate(); } if (hasDeactivated) { this.deactivateTrap(); return; // un/pause does nothing on an inactive trap } if (hasPaused) { this.focusTrap.pause(); } if (hasUnpaused) { this.focusTrap.unpause(); } } else { // NOTE: if we're in `componentDidUpdate` and we don't have a trap yet, // it either means it shouldn't be active, or it should be but none of // of given `containerElements` were present in the DOM the last time // we tried to create the trap if (prevProps.containerElements !== this.props.containerElements) { this.focusTrapElements = this.props.containerElements; } // don't create the trap unless it should be active in case the consumer // is still updating `focusTrapOptions` // @see https://github.com/focus-trap/focus-trap-react/issues/539 if (this.props.active) { this.updatePreviousElement(); this.setupFocusTrap(); } } } }, { key: "componentWillUnmount", value: function componentWillUnmount() { this.deactivateTrap(); } }, { key: "render", value: function render() { var _this3 = this; var child = this.props.children ? React.Children.only(this.props.children) : undefined; if (child) { if (child.type && child.type === React.Fragment) { throw new Error('A focus-trap cannot use a Fragment as its child container. Try replacing it with a <div> element.'); } var callbackRef = function callbackRef(element) { var containerElements = _this3.props.containerElements; if (child) { // React 19 moved the `ref` to an official prop if (reactVerMajor >= 19) { if (typeof child.props.ref === 'function') { child.props.ref(element); } else if (child.props.ref) { child.props.ref.current = element; } } else { // older versions of React had the `ref` separate from props (still works in R19 // but results in a deprecation warning in Dev builds) if (typeof child.ref === 'function') { child.ref(element); } else if (child.ref) { child.ref.current = element; } } } _this3.focusTrapElements = containerElements ? containerElements : [element]; }; var childWithRef = React.cloneElement(child, { ref: callbackRef }); return childWithRef; } return null; } }]); }(React.Component); // NOTE: While React 19 REMOVED support for `propTypes`, support for `defaultProps` // __for class components ONLY__ remains: "Class components will continue to support // defaultProps since there is no ES6 alternative." // @see https://react.dev/blog/2024/04/25/react-19-upgrade-guide#removed-proptypes-and-defaultprops FocusTrap.defaultProps = { active: true, paused: false, focusTrapOptions: {}, _createFocusTrap: createFocusTrap }; // 🔺 DEPRECATED: default export module.exports = FocusTrap; // named export module.exports.FocusTrap = FocusTrap;