UNPKG

focus-trap-react

Version:
449 lines (387 loc) 16.7 kB
const React = require('react'); const { createFocusTrap } = require('focus-trap'); const { isFocusable } = require('tabbable'); /** * The major version of React currently running. * @type {number} */ const reactVerMajor = parseInt(/^(\d+)\./.exec(React.version)?.[1] ?? 0, 10); /** * @type {import('../index.d.ts').FocusTrap} */ class FocusTrap extends React.Component { constructor(props) { super(props); 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, }; const { focusTrapOptions } = props; for (const 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(); } /** * 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. */ getDocument() { // SSR: careful to check if `document` exists before accessing it as a variable return ( this.props.focusTrapOptions.document || (typeof document !== 'undefined' ? document : undefined) ); } /** * 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. */ getNodeForOption = function (optionName, ...params) { // use internal options first, falling back to original options let optionValue = this.internalOptions[optionName] ?? this.originalOptions[optionName]; if (typeof optionValue === 'function') { optionValue = optionValue(...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( `\`${optionName}\` was specified but was not a node, or did not return a node` ); } let node = optionValue; // could be HTMLElement, SVGElement, or non-empty string at this point if (typeof optionValue === 'string') { node = this.getDocument()?.querySelector(optionValue); // resolve to node, or null if fails if (!node) { throw new Error( `\`${optionName}\` as selector refers to no known node` ); } } return node; }; getReturnFocusNode() { const node = this.getNodeForOption( 'setReturnFocus', this.previouslyFocusedElement ); return node ? node : node === false ? false : this.previouslyFocusedElement; } /** Update the previously focused element with the currently focused element. */ updatePreviousElement() { const currentDocument = this.getDocument(); if (currentDocument) { this.previouslyFocusedElement = currentDocument.activeElement; } } 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 }); } handleClickOutsideDeactivates(event) { // use consumer's option (or call their handler) as the permission or denial const 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, }; } return allowDeactivation; } handleDeactivate() { if (this.originalOptions.onDeactivate) { this.originalOptions.onDeactivate.call(null); // call user's handler out of context } this.deactivateTrap(); } handlePostDeactivate() { const finishDeactivation = () => { const returnFocusNode = this.getReturnFocusNode(); const canReturnFocus = !!( // did the consumer allow it? ( this.originalOptions.returnFocusOnDeactivate && // can we actually focus the node? returnFocusNode?.focus && // was there an outside click that allowed deactivation? (!this.outsideClick || // did the consumer allow deactivation when the outside node was clicked? (this.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( this.outsideClick.target, this.internalOptions.tabbableOptions ))) ) // if no, the restore focus to `returnFocusNode` at this point ); const { preventScroll = false } = this.internalOptions; if (canReturnFocus) { // return focus to the element that had focus when the trap was activated returnFocusNode.focus({ preventScroll, }); } if (this.originalOptions.onPostDeactivate) { this.originalOptions.onPostDeactivate.call(null); // don't call it in context of "this" } this.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(); } } 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 { const 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(); } } } } 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 } componentDidUpdate(prevProps) { if (this.focusTrap) { if (prevProps.containerElements !== this.props.containerElements) { this.focusTrap.updateContainerElements(this.props.containerElements); } const hasActivated = !prevProps.active && this.props.active; const hasDeactivated = prevProps.active && !this.props.active; const hasPaused = !prevProps.paused && this.props.paused; const 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(); } } } componentWillUnmount() { this.deactivateTrap(); } render() { const 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.' ); } const callbackRef = (element) => { const { containerElements } = this.props; 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; } } } this.focusTrapElements = containerElements ? containerElements : [element]; }; const childWithRef = React.cloneElement(child, { ref: callbackRef, }); return childWithRef; } return null; } } // 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;