focus-trap-react
Version:
A React component that traps focus.
424 lines (405 loc) • 21.7 kB
JavaScript
"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;