123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786 |
- import { tabbable, focusable, isFocusable, isTabbable } from 'tabbable';
- const activeFocusTraps = (function () {
- const trapQueue = [];
- return {
- activateTrap(trap) {
- if (trapQueue.length > 0) {
- const activeTrap = trapQueue[trapQueue.length - 1];
- if (activeTrap !== trap) {
- activeTrap.pause();
- }
- }
- const trapIndex = trapQueue.indexOf(trap);
- if (trapIndex === -1) {
- trapQueue.push(trap);
- } else {
- // move this existing trap to the front of the queue
- trapQueue.splice(trapIndex, 1);
- trapQueue.push(trap);
- }
- },
- deactivateTrap(trap) {
- const trapIndex = trapQueue.indexOf(trap);
- if (trapIndex !== -1) {
- trapQueue.splice(trapIndex, 1);
- }
- if (trapQueue.length > 0) {
- trapQueue[trapQueue.length - 1].unpause();
- }
- },
- };
- })();
- const isSelectableInput = function (node) {
- return (
- node.tagName &&
- node.tagName.toLowerCase() === 'input' &&
- typeof node.select === 'function'
- );
- };
- const isEscapeEvent = function (e) {
- return e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27;
- };
- const isTabEvent = function (e) {
- return e.key === 'Tab' || e.keyCode === 9;
- };
- const delay = function (fn) {
- return setTimeout(fn, 0);
- };
- // Array.find/findIndex() are not supported on IE; this replicates enough
- // of Array.findIndex() for our needs
- const findIndex = function (arr, fn) {
- let idx = -1;
- arr.every(function (value, i) {
- if (fn(value)) {
- idx = i;
- return false; // break
- }
- return true; // next
- });
- return idx;
- };
- /**
- * Get an option's value when it could be a plain value, or a handler that provides
- * the value.
- * @param {*} value Option's value to check.
- * @param {...*} [params] Any parameters to pass to the handler, if `value` is a function.
- * @returns {*} The `value`, or the handler's returned value.
- */
- const valueOrHandler = function (value, ...params) {
- return typeof value === 'function' ? value(...params) : value;
- };
- const getActualTarget = function (event) {
- // NOTE: If the trap is _inside_ a shadow DOM, event.target will always be the
- // shadow host. However, event.target.composedPath() will be an array of
- // nodes "clicked" from inner-most (the actual element inside the shadow) to
- // outer-most (the host HTML document). If we have access to composedPath(),
- // then use its first element; otherwise, fall back to event.target (and
- // this only works for an _open_ shadow DOM; otherwise,
- // composedPath()[0] === event.target always).
- return event.target.shadowRoot && typeof event.composedPath === 'function'
- ? event.composedPath()[0]
- : event.target;
- };
- const createFocusTrap = function (elements, userOptions) {
- // SSR: a live trap shouldn't be created in this type of environment so this
- // should be safe code to execute if the `document` option isn't specified
- const doc = userOptions?.document || document;
- const config = {
- returnFocusOnDeactivate: true,
- escapeDeactivates: true,
- delayInitialFocus: true,
- ...userOptions,
- };
- const state = {
- // containers given to createFocusTrap()
- // @type {Array<HTMLElement>}
- containers: [],
- // list of objects identifying tabbable nodes in `containers` in the trap
- // NOTE: it's possible that a group has no tabbable nodes if nodes get removed while the trap
- // is active, but the trap should never get to a state where there isn't at least one group
- // with at least one tabbable node in it (that would lead to an error condition that would
- // result in an error being thrown)
- // @type {Array<{
- // container: HTMLElement,
- // tabbableNodes: Array<HTMLElement>, // empty if none
- // focusableNodes: Array<HTMLElement>, // empty if none
- // firstTabbableNode: HTMLElement|null,
- // lastTabbableNode: HTMLElement|null,
- // nextTabbableNode: (node: HTMLElement, forward: boolean) => HTMLElement|undefined
- // }>}
- containerGroups: [], // same order/length as `containers` list
- // references to objects in `containerGroups`, but only those that actually have
- // tabbable nodes in them
- // NOTE: same order as `containers` and `containerGroups`, but __not necessarily__
- // the same length
- tabbableGroups: [],
- nodeFocusedBeforeActivation: null,
- mostRecentlyFocusedNode: null,
- active: false,
- paused: false,
- // timer ID for when delayInitialFocus is true and initial focus in this trap
- // has been delayed during activation
- delayInitialFocusTimer: undefined,
- };
- let trap; // eslint-disable-line prefer-const -- some private functions reference it, and its methods reference private functions, so we must declare here and define later
- /**
- * Gets a configuration option value.
- * @param {Object|undefined} configOverrideOptions If true, and option is defined in this set,
- * value will be taken from this object. Otherwise, value will be taken from base configuration.
- * @param {string} optionName Name of the option whose value is sought.
- * @param {string|undefined} [configOptionName] Name of option to use __instead of__ `optionName`
- * IIF `configOverrideOptions` is not defined. Otherwise, `optionName` is used.
- */
- const getOption = (configOverrideOptions, optionName, configOptionName) => {
- return configOverrideOptions &&
- configOverrideOptions[optionName] !== undefined
- ? configOverrideOptions[optionName]
- : config[configOptionName || optionName];
- };
- /**
- * Finds the index of the container that contains the element.
- * @param {HTMLElement} element
- * @returns {number} Index of the container in either `state.containers` or
- * `state.containerGroups` (the order/length of these lists are the same); -1
- * if the element isn't found.
- */
- const findContainerIndex = function (element) {
- // NOTE: search `containerGroups` because it's possible a group contains no tabbable
- // nodes, but still contains focusable nodes (e.g. if they all have `tabindex=-1`)
- // and we still need to find the element in there
- return state.containerGroups.findIndex(
- ({ container, tabbableNodes }) =>
- container.contains(element) ||
- // fall back to explicit tabbable search which will take into consideration any
- // web components if the `tabbableOptions.getShadowRoot` option was used for
- // the trap, enabling shadow DOM support in tabbable (`Node.contains()` doesn't
- // look inside web components even if open)
- tabbableNodes.find((node) => node === element)
- );
- };
- /**
- * 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.
- */
- const getNodeForOption = function (optionName, ...params) {
- let optionValue = config[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 = doc.querySelector(optionValue); // resolve to node, or null if fails
- if (!node) {
- throw new Error(
- `\`${optionName}\` as selector refers to no known node`
- );
- }
- }
- return node;
- };
- const getInitialFocusNode = function () {
- let node = getNodeForOption('initialFocus');
- // false explicitly indicates we want no initialFocus at all
- if (node === false) {
- return false;
- }
- if (node === undefined) {
- // option not specified: use fallback options
- if (findContainerIndex(doc.activeElement) >= 0) {
- node = doc.activeElement;
- } else {
- const firstTabbableGroup = state.tabbableGroups[0];
- const firstTabbableNode =
- firstTabbableGroup && firstTabbableGroup.firstTabbableNode;
- // NOTE: `fallbackFocus` option function cannot return `false` (not supported)
- node = firstTabbableNode || getNodeForOption('fallbackFocus');
- }
- }
- if (!node) {
- throw new Error(
- 'Your focus-trap needs to have at least one focusable element'
- );
- }
- return node;
- };
- const updateTabbableNodes = function () {
- state.containerGroups = state.containers.map((container) => {
- const tabbableNodes = tabbable(container, config.tabbableOptions);
- // NOTE: if we have tabbable nodes, we must have focusable nodes; focusable nodes
- // are a superset of tabbable nodes
- const focusableNodes = focusable(container, config.tabbableOptions);
- return {
- container,
- tabbableNodes,
- focusableNodes,
- firstTabbableNode: tabbableNodes.length > 0 ? tabbableNodes[0] : null,
- lastTabbableNode:
- tabbableNodes.length > 0
- ? tabbableNodes[tabbableNodes.length - 1]
- : null,
- /**
- * Finds the __tabbable__ node that follows the given node in the specified direction,
- * in this container, if any.
- * @param {HTMLElement} node
- * @param {boolean} [forward] True if going in forward tab order; false if going
- * in reverse.
- * @returns {HTMLElement|undefined} The next tabbable node, if any.
- */
- nextTabbableNode(node, forward = true) {
- // NOTE: If tabindex is positive (in order to manipulate the tab order separate
- // from the DOM order), this __will not work__ because the list of focusableNodes,
- // while it contains tabbable nodes, does not sort its nodes in any order other
- // than DOM order, because it can't: Where would you place focusable (but not
- // tabbable) nodes in that order? They have no order, because they aren't tabbale...
- // Support for positive tabindex is already broken and hard to manage (possibly
- // not supportable, TBD), so this isn't going to make things worse than they
- // already are, and at least makes things better for the majority of cases where
- // tabindex is either 0/unset or negative.
- // FYI, positive tabindex issue: https://github.com/focus-trap/focus-trap/issues/375
- const nodeIdx = focusableNodes.findIndex((n) => n === node);
- if (nodeIdx < 0) {
- return undefined;
- }
- if (forward) {
- return focusableNodes
- .slice(nodeIdx + 1)
- .find((n) => isTabbable(n, config.tabbableOptions));
- }
- return focusableNodes
- .slice(0, nodeIdx)
- .reverse()
- .find((n) => isTabbable(n, config.tabbableOptions));
- },
- };
- });
- state.tabbableGroups = state.containerGroups.filter(
- (group) => group.tabbableNodes.length > 0
- );
- // throw if no groups have tabbable nodes and we don't have a fallback focus node either
- if (
- state.tabbableGroups.length <= 0 &&
- !getNodeForOption('fallbackFocus') // returning false not supported for this option
- ) {
- throw new Error(
- 'Your focus-trap must have at least one container with at least one tabbable node in it at all times'
- );
- }
- };
- const tryFocus = function (node) {
- if (node === false) {
- return;
- }
- if (node === doc.activeElement) {
- return;
- }
- if (!node || !node.focus) {
- tryFocus(getInitialFocusNode());
- return;
- }
- node.focus({ preventScroll: !!config.preventScroll });
- state.mostRecentlyFocusedNode = node;
- if (isSelectableInput(node)) {
- node.select();
- }
- };
- const getReturnFocusNode = function (previousActiveElement) {
- const node = getNodeForOption('setReturnFocus', previousActiveElement);
- return node ? node : node === false ? false : previousActiveElement;
- };
- // This needs to be done on mousedown and touchstart instead of click
- // so that it precedes the focus event.
- const checkPointerDown = function (e) {
- const target = getActualTarget(e);
- if (findContainerIndex(target) >= 0) {
- // allow the click since it ocurred inside the trap
- return;
- }
- if (valueOrHandler(config.clickOutsideDeactivates, e)) {
- // immediately deactivate the trap
- trap.deactivate({
- // if, on deactivation, we should return focus to the node originally-focused
- // when the trap was activated (or the configured `setReturnFocus` node),
- // then assume it's also OK to return focus to the outside node that was
- // just clicked, causing deactivation, as long as that node is focusable;
- // if it isn't focusable, then return focus to the original node focused
- // on activation (or the configured `setReturnFocus` node)
- // NOTE: by setting `returnFocus: false`, deactivate() will do nothing,
- // which will result in the outside click setting focus to the node
- // that was clicked, whether it's focusable or not; by setting
- // `returnFocus: true`, we'll attempt to re-focus the node originally-focused
- // on activation (or the configured `setReturnFocus` node)
- returnFocus:
- config.returnFocusOnDeactivate &&
- !isFocusable(target, config.tabbableOptions),
- });
- return;
- }
- // This is needed for mobile devices.
- // (If we'll only let `click` events through,
- // then on mobile they will be blocked anyways if `touchstart` is blocked.)
- if (valueOrHandler(config.allowOutsideClick, e)) {
- // allow the click outside the trap to take place
- return;
- }
- // otherwise, prevent the click
- e.preventDefault();
- };
- // In case focus escapes the trap for some strange reason, pull it back in.
- const checkFocusIn = function (e) {
- const target = getActualTarget(e);
- const targetContained = findContainerIndex(target) >= 0;
- // In Firefox when you Tab out of an iframe the Document is briefly focused.
- if (targetContained || target instanceof Document) {
- if (targetContained) {
- state.mostRecentlyFocusedNode = target;
- }
- } else {
- // escaped! pull it back in to where it just left
- e.stopImmediatePropagation();
- tryFocus(state.mostRecentlyFocusedNode || getInitialFocusNode());
- }
- };
- // Hijack Tab events on the first and last focusable nodes of the trap,
- // in order to prevent focus from escaping. If it escapes for even a
- // moment it can end up scrolling the page and causing confusion so we
- // kind of need to capture the action at the keydown phase.
- const checkTab = function (e) {
- const target = getActualTarget(e);
- updateTabbableNodes();
- let destinationNode = null;
- if (state.tabbableGroups.length > 0) {
- // make sure the target is actually contained in a group
- // NOTE: the target may also be the container itself if it's focusable
- // with tabIndex='-1' and was given initial focus
- const containerIndex = findContainerIndex(target);
- const containerGroup =
- containerIndex >= 0 ? state.containerGroups[containerIndex] : undefined;
- if (containerIndex < 0) {
- // target not found in any group: quite possible focus has escaped the trap,
- // so bring it back in to...
- if (e.shiftKey) {
- // ...the last node in the last group
- destinationNode =
- state.tabbableGroups[state.tabbableGroups.length - 1]
- .lastTabbableNode;
- } else {
- // ...the first node in the first group
- destinationNode = state.tabbableGroups[0].firstTabbableNode;
- }
- } else if (e.shiftKey) {
- // REVERSE
- // is the target the first tabbable node in a group?
- let startOfGroupIndex = findIndex(
- state.tabbableGroups,
- ({ firstTabbableNode }) => target === firstTabbableNode
- );
- if (
- startOfGroupIndex < 0 &&
- (containerGroup.container === target ||
- (isFocusable(target, config.tabbableOptions) &&
- !isTabbable(target, config.tabbableOptions) &&
- !containerGroup.nextTabbableNode(target, false)))
- ) {
- // an exception case where the target is either the container itself, or
- // a non-tabbable node that was given focus (i.e. tabindex is negative
- // and user clicked on it or node was programmatically given focus)
- // and is not followed by any other tabbable node, in which
- // case, we should handle shift+tab as if focus were on the container's
- // first tabbable node, and go to the last tabbable node of the LAST group
- startOfGroupIndex = containerIndex;
- }
- if (startOfGroupIndex >= 0) {
- // YES: then shift+tab should go to the last tabbable node in the
- // previous group (and wrap around to the last tabbable node of
- // the LAST group if it's the first tabbable node of the FIRST group)
- const destinationGroupIndex =
- startOfGroupIndex === 0
- ? state.tabbableGroups.length - 1
- : startOfGroupIndex - 1;
- const destinationGroup = state.tabbableGroups[destinationGroupIndex];
- destinationNode = destinationGroup.lastTabbableNode;
- }
- } else {
- // FORWARD
- // is the target the last tabbable node in a group?
- let lastOfGroupIndex = findIndex(
- state.tabbableGroups,
- ({ lastTabbableNode }) => target === lastTabbableNode
- );
- if (
- lastOfGroupIndex < 0 &&
- (containerGroup.container === target ||
- (isFocusable(target, config.tabbableOptions) &&
- !isTabbable(target, config.tabbableOptions) &&
- !containerGroup.nextTabbableNode(target)))
- ) {
- // an exception case where the target is the container itself, or
- // a non-tabbable node that was given focus (i.e. tabindex is negative
- // and user clicked on it or node was programmatically given focus)
- // and is not followed by any other tabbable node, in which
- // case, we should handle tab as if focus were on the container's
- // last tabbable node, and go to the first tabbable node of the FIRST group
- lastOfGroupIndex = containerIndex;
- }
- if (lastOfGroupIndex >= 0) {
- // YES: then tab should go to the first tabbable node in the next
- // group (and wrap around to the first tabbable node of the FIRST
- // group if it's the last tabbable node of the LAST group)
- const destinationGroupIndex =
- lastOfGroupIndex === state.tabbableGroups.length - 1
- ? 0
- : lastOfGroupIndex + 1;
- const destinationGroup = state.tabbableGroups[destinationGroupIndex];
- destinationNode = destinationGroup.firstTabbableNode;
- }
- }
- } else {
- // NOTE: the fallbackFocus option does not support returning false to opt-out
- destinationNode = getNodeForOption('fallbackFocus');
- }
- if (destinationNode) {
- e.preventDefault();
- tryFocus(destinationNode);
- }
- // else, let the browser take care of [shift+]tab and move the focus
- };
- const checkKey = function (e) {
- if (
- isEscapeEvent(e) &&
- valueOrHandler(config.escapeDeactivates, e) !== false
- ) {
- e.preventDefault();
- trap.deactivate();
- return;
- }
- if (isTabEvent(e)) {
- checkTab(e);
- return;
- }
- };
- const checkClick = function (e) {
- const target = getActualTarget(e);
- if (findContainerIndex(target) >= 0) {
- return;
- }
- if (valueOrHandler(config.clickOutsideDeactivates, e)) {
- return;
- }
- if (valueOrHandler(config.allowOutsideClick, e)) {
- return;
- }
- e.preventDefault();
- e.stopImmediatePropagation();
- };
- //
- // EVENT LISTENERS
- //
- const addListeners = function () {
- if (!state.active) {
- return;
- }
- // There can be only one listening focus trap at a time
- activeFocusTraps.activateTrap(trap);
- // Delay ensures that the focused element doesn't capture the event
- // that caused the focus trap activation.
- state.delayInitialFocusTimer = config.delayInitialFocus
- ? delay(function () {
- tryFocus(getInitialFocusNode());
- })
- : tryFocus(getInitialFocusNode());
- doc.addEventListener('focusin', checkFocusIn, true);
- doc.addEventListener('mousedown', checkPointerDown, {
- capture: true,
- passive: false,
- });
- doc.addEventListener('touchstart', checkPointerDown, {
- capture: true,
- passive: false,
- });
- doc.addEventListener('click', checkClick, {
- capture: true,
- passive: false,
- });
- doc.addEventListener('keydown', checkKey, {
- capture: true,
- passive: false,
- });
- return trap;
- };
- const removeListeners = function () {
- if (!state.active) {
- return;
- }
- doc.removeEventListener('focusin', checkFocusIn, true);
- doc.removeEventListener('mousedown', checkPointerDown, true);
- doc.removeEventListener('touchstart', checkPointerDown, true);
- doc.removeEventListener('click', checkClick, true);
- doc.removeEventListener('keydown', checkKey, true);
- return trap;
- };
- //
- // TRAP DEFINITION
- //
- trap = {
- get active() {
- return state.active;
- },
- get paused() {
- return state.paused;
- },
- activate(activateOptions) {
- if (state.active) {
- return this;
- }
- const onActivate = getOption(activateOptions, 'onActivate');
- const onPostActivate = getOption(activateOptions, 'onPostActivate');
- const checkCanFocusTrap = getOption(activateOptions, 'checkCanFocusTrap');
- if (!checkCanFocusTrap) {
- updateTabbableNodes();
- }
- state.active = true;
- state.paused = false;
- state.nodeFocusedBeforeActivation = doc.activeElement;
- if (onActivate) {
- onActivate();
- }
- const finishActivation = () => {
- if (checkCanFocusTrap) {
- updateTabbableNodes();
- }
- addListeners();
- if (onPostActivate) {
- onPostActivate();
- }
- };
- if (checkCanFocusTrap) {
- checkCanFocusTrap(state.containers.concat()).then(
- finishActivation,
- finishActivation
- );
- return this;
- }
- finishActivation();
- return this;
- },
- deactivate(deactivateOptions) {
- if (!state.active) {
- return this;
- }
- const options = {
- onDeactivate: config.onDeactivate,
- onPostDeactivate: config.onPostDeactivate,
- checkCanReturnFocus: config.checkCanReturnFocus,
- ...deactivateOptions,
- };
- clearTimeout(state.delayInitialFocusTimer); // noop if undefined
- state.delayInitialFocusTimer = undefined;
- removeListeners();
- state.active = false;
- state.paused = false;
- activeFocusTraps.deactivateTrap(trap);
- const onDeactivate = getOption(options, 'onDeactivate');
- const onPostDeactivate = getOption(options, 'onPostDeactivate');
- const checkCanReturnFocus = getOption(options, 'checkCanReturnFocus');
- const returnFocus = getOption(
- options,
- 'returnFocus',
- 'returnFocusOnDeactivate'
- );
- if (onDeactivate) {
- onDeactivate();
- }
- const finishDeactivation = () => {
- delay(() => {
- if (returnFocus) {
- tryFocus(getReturnFocusNode(state.nodeFocusedBeforeActivation));
- }
- if (onPostDeactivate) {
- onPostDeactivate();
- }
- });
- };
- if (returnFocus && checkCanReturnFocus) {
- checkCanReturnFocus(
- getReturnFocusNode(state.nodeFocusedBeforeActivation)
- ).then(finishDeactivation, finishDeactivation);
- return this;
- }
- finishDeactivation();
- return this;
- },
- pause() {
- if (state.paused || !state.active) {
- return this;
- }
- state.paused = true;
- removeListeners();
- return this;
- },
- unpause() {
- if (!state.paused || !state.active) {
- return this;
- }
- state.paused = false;
- updateTabbableNodes();
- addListeners();
- return this;
- },
- updateContainerElements(containerElements) {
- const elementsAsArray = [].concat(containerElements).filter(Boolean);
- state.containers = elementsAsArray.map((element) =>
- typeof element === 'string' ? doc.querySelector(element) : element
- );
- if (state.active) {
- updateTabbableNodes();
- }
- return this;
- },
- };
- // initialize container elements
- trap.updateContainerElements(elements);
- return trap;
- };
- export { createFocusTrap };
|