Giter Club home page Giter Club logo

focus-trap's Introduction

focus-trap CI license

All Contributors

Trap focus within a DOM node.

There may come a time when you find it important to trap focus within a DOM node — so that when a user hits Tab or Shift+Tab or clicks around, she can't escape a certain cycle of focusable elements.

You will definitely face this challenge when you are trying to build accessible modals.

This module is a little, modular vanilla JS solution to that problem.

Use it in your higher-level components. For example, if you are using React check out focus-trap-react, a light wrapper around this library. If you are not a React user, consider creating light wrappers in your framework-of-choice.

What it does

When a focus trap is activated, this is what should happen:

  • Some element within the focus trap receives focus. By default, this will be the first element in the focus trap's tab order (as determined by tabbable). Alternately, you can specify an element that should receive this initial focus.
  • The Tab and Shift+Tab keys will cycle through the focus trap's tabbable elements but will not leave the focus trap.
  • Clicks within the focus trap behave normally; but clicks outside the focus trap are blocked.
  • The Escape key will deactivate the focus trap.

When the focus trap is deactivated, this is what should happen:

  • Focus is passed to whichever element had focus when the trap was activated (e.g. the button that opened the modal or menu).
  • Tabbing and clicking behave normally everywhere.

Check out the demos.

For more advanced usage (e.g. focus traps within focus traps), you can also pause a focus trap's behavior without deactivating it entirely, then unpause at will.

Installation

npm install focus-trap

UMD

You can also use a UMD version published to unpkg.com as dist/focus-trap.umd.js and dist/focus-trap.umd.min.js.

NOTE: The UMD build does not bundle the tabbable dependency. Therefore you will have to also include that one, and include it before focus-trap.

<head>
  <script src="https://unpkg.com/tabbable/dist/index.umd.js"></script>
  <script src="https://unpkg.com/focus-trap/dist/focus-trap.umd.js"></script>
</head>

Browser Support

As old and as broad as reasonably possible, excluding browsers that are out of support or have nearly no user base.

Focused on desktop browsers, particularly Chrome, Edge, FireFox, Safari, and Opera.

Focus-trap is not officially tested on any mobile browsers or devices.

❗️ Safari: By default, Safari does not tab through all elements on a page, which alters the normal DOM-based tab order expected by focus-trap. If you use or support Safari with this library, make sure you and your users know they must enable the Preferences > Advanced > Press Tab to highlight each item on a webpage feature. Otherwise, your traps will not work the way you expect them to.

⚠️ Microsoft no longer supports any version of IE, so IE is no longer supported by this library.

💬 Focus-trap relies on tabbable so its browser support is at least what tabbable supports.

💬 Keep in mind that performance optimization and old browser support are often at odds, so tabbable may not always be able to use the most optimal (typically modern) APIs in all cases.

Usage

createFocusTrap()

import * as focusTrap from 'focus-trap'; // ESM
const focusTrap = require('focus-trap'); // CJS
// UMD: `focusTrap` is defined as a global on `window`

trap = focusTrap.createFocusTrap(element[, createOptions]);

Returns a new focus trap on element (one or more "containers" of tabbable nodes that, together, form the total set of nodes that can be visited, with clicks or the tab key, within the trap).

element can be:

  • a DOM node (the focus trap itself);
  • a selector string (which will be passed to document.querySelector() to find the DOM node); or
  • an array of DOM nodes or selector strings (where the order determines where the focus will go after the last tabbable element of a DOM node/selector is reached).

A focus trap must have at least one container with at least one tabbable/focusable node in it to be considered valid. While nodes can be added/removed at runtime, with the trap adjusting to added/removed tabbable nodes, an error will be thrown if the trap ever gets into a state where it determines none of its containers have any tabbable nodes in them and the fallbackFocus option does not resolve to an alternate node where focus can go.

createOptions

  • onActivate {() => void}: A function that will be called before sending focus to the target element upon activation.
  • onPostActivate {() => void}: A function that will be called after sending focus to the target element upon activation.
  • onPause {() => void}: A function that will be called immediately after the trap's state is updated to be paused.
  • onPostPause {() => void}: A function that will be called after the trap has been completely paused and is no longer managing/trapping focus.
  • onUnpause {() => void}: A function that will be called immediately after the trap's state is updated to be active again, but prior to updating its knowledge of what nodes are tabbable within its containers, and prior to actively managing/trapping focus.
  • onPostUnpause {() => void}: A function that will be called after the trap has been completely unpaused and is once again managing/trapping focus.
  • checkCanFocusTrap {(containers: Array<HTMLElement | SVGElement>) => Promise<void>}: Animated dialogs have a small delay between when onActivate is called and when the focus trap is focusable. checkCanFocusTrap expects a promise to be returned. When that promise settles (resolves or rejects), focus will be sent to the first tabbable node (in tab order) in the focus trap (or the node configured in the initialFocus option).
  • onDeactivate {() => void}: A function that will be called before returning focus to the node that had focus prior to activation (or configured with the setReturnFocus option) upon deactivation.
  • onPostDeactivate {() => void}: A function that will be called after the trap is deactivated, after onDeactivate. If the returnFocus deactivation option was set, it will be called after returning focus to the node that had focus prior to activation (or configured with the setReturnFocus option) upon deactivation; otherwise, it will be called after deactivation completes.
  • checkCanReturnFocus {(trigger: HTMLElement | SVGElement) => Promise<void>}: An animated trigger button will have a small delay between when onDeactivate is called and when the focus is able to be sent back to the trigger. checkCanReturnFocus expects a promise to be returned. When that promise settles (resolves or rejects), focus will be sent to to the node that had focus prior to the activation of the trap (or the node configured in the setReturnFocus option).
  • initialFocus {HTMLElement | SVGElement | string | false | undefined | (() => HTMLElement | SVGElement | string | false | undefined)}: By default, when a focus trap is activated the first element in the focus trap's tab order will receive focus. With this option you can specify a different element to receive that initial focus. Can be a DOM node, or a selector string (which will be passed to document.querySelector() to find the DOM node), or a function that returns any of these. You can also set this option to false (or to a function that returns false) to prevent any initial focus at all when the trap activates.
    • 💬 Setting this option to false (or a function that returns false) will prevent the fallbackFocus option from being used.
    • Returning undefined from a function will result in the default behavior.
    • ⚠️ See warning below about Shadow DOM and selector strings.
  • fallbackFocus {HTMLElement | SVGElement | string | () => HTMLElement | SVGElement | string}: By default, an error will be thrown if the focus trap contains no elements in its tab order. With this option you can specify a fallback element to programmatically receive focus if no other tabbable elements are found. For example, you may want a popover's <div> to receive focus if the popover's content includes no tabbable elements. Make sure the fallback element has a negative tabindex so it can be programmatically focused. The option value can be a DOM node, a selector string (which will be passed to document.querySelector() to find the DOM node), or a function that returns any of these.
    • 💬 If initialFocus is false (or a function that returns false), this function will not be called when the trap is activated, and no element will be initially focused. This function may still be called while the trap is active if things change such that there are no longer any tabbable nodes in the trap.
    • ⚠️ See warning below about Shadow DOM and selector strings.
  • escapeDeactivates {boolean} | (e: KeyboardEvent) => boolean): Default: true. If false or returns false, the Escape key will not trigger deactivation of the focus trap. This can be useful if you want to force the user to make a decision instead of allowing an easy way out. Note that if a function is given, it's only called if the ESC key was pressed.
  • clickOutsideDeactivates {boolean | (e: MouseEvent | TouchEvent) => boolean}: If true or returns true, a click outside the focus trap will immediately deactivate the focus trap and allow the click event to do its thing (i.e. to pass-through to the element that was clicked). This option takes precedence over allowOutsideClick when it's set to true. Default: false.
    • 💬 If a function is provided, it will be called up to twice (but only if the click occurs outside the trap's containers): First on the mousedown (or touchstart on mobile) event and, if true was returned, again on the click event. It will get the same node each time, and it's recommended that the returned value is also the same each time. Be sure to check the event type if the double call is an issue in your code.
    • ⚠️ If you're using a password manager such as 1Password, where the app adds a clickable icon to all fillable fields, you should avoid using this option, and instead use the allowOutsideClick option to better control exactly when the focus trap can be deactivated. The clickable icons are usually positioned absolutely, floating on top of the fields, and therefore not part of the container the trap is managing. When using the clickOutsideDeactivates option, clicking on a field's 1Password icon will likely cause the trap to be unintentionally deactivated.
  • allowOutsideClick {boolean | (e: MouseEvent | TouchEvent) => boolean}: If set and is or returns true, a click outside the focus trap will not be prevented (letting focus temporarily escape the trap, without deactivating it), even if clickOutsideDeactivates=false. Default: false.
    • 💬 If this is a function, it will be called up to twice on every click (but only if the click occurs outside the trap's containers): First on mousedown (or touchstart on mobile), and then on the actual click if the function returned true on the first event. Be sure to check the event type if the double call is an issue in your code.
    • 💡 When clickOutsideDeactivates=true, this option is ignored (i.e. if it's a function, it will not be called).
    • Use this option to control if (and even which) clicks are allowed outside the trap in conjunction with clickOutsideDeactivates=false.
  • returnFocusOnDeactivate {boolean}: Default: true. If false, when the trap is deactivated, focus will not return to the element that had focus before activation.
    • 💬 When using this option in conjunction with clickOutsideDeactivates=true:
      • If returnFocusOnDeactivate=true and the outside click causing deactivation is on a focusable element, focus will not return to that element; instead, it will return to the node focused just before activation.
      • If returnFocusOnDeactivate=false and the outside click is on a focusable node, focus will remain on that node instead of the node focused just before activation. If the outside click is on a non-focusable node, then "nothing" will have focus post-deactivation.
  • setReturnFocus {HTMLElement | SVGElement | string | (previousActiveElement: HTMLElement | SVGElement) => HTMLElement | SVGElement | string | false}: By default, on deactivation, if returnFocusOnDeactivate=true (or if returnFocus=true in the deactivation options), focus will be returned to the element that was focused just before activation. With this option, you can specify another element to programmatically receive focus after deactivation. It can be a DOM node, a selector string (which will be passed to document.querySelector() to find the DOM node upon deactivation), or a function that returns any of these to call upon deactivation (i.e. the selector and function options are only executed at the time the trap is deactivated). Can also be false (or return false) to leave focus where it is at the time of deactivation.
    • 💬 Using the selector or function options is a good way to return focus to a DOM node that may not exist at the time the trap is activated.
    • ⚠️ See warning below about Shadow DOM and selector strings.
  • preventScroll {boolean}: By default, focus() will scroll to the element if not in viewport. It can produce unintended effects like scrolling back to the top of a modal. If set to true, no scroll will happen.
  • delayInitialFocus {boolean}: Default: true. Delays the autofocus to the next execution frame when the focus trap is activated. This prevents elements within the focusable element from capturing the event that triggered the focus trap activation.
  • document {Document}: Default: window.document. Document where the focus trap will be active. This enables the use of FocusTrap inside an iFrame.
    • ⚠️ Note that FocusTrap will be unable to trap focus outside the iFrame if you configure this option to be the iFrame's document. It will only trap focus inside of it (as the demo shows). If you want to trap focus outside as well, then your FocusTrap must be configured on an element that contains the iFrame.
  • tabbableOptions: (optional) tabbable options configurable on FocusTrap (all the common options).
  • trapStack (optional) {Array<FocusTrap>}: Define the global trap stack. This makes it possible to share the same stack in multiple instances of focus-trap in the same page such that auto-activation/pausing of traps is properly coordinated among all instances as activating a trap when another is already active should result in the other being auto-paused. By default, each instance will have its own internal stack, leading to conflicts if they each try to trap the focus at the same time.
  • isKeyForward {(event: KeyboardEvent) => boolean}: (optional) Determines if the given keyboard event is a "tab forward" event that will move the focus to the next trapped element in tab order. Defaults to the TAB key. Use this to override the trap's behavior if you want to use arrow keys to control keyboard navigation within the trap, for example. Also see isKeyBackward() option.
    • ⚠️ Using this option will not automatically prevent use of the TAB key as the browser will continue to respond to it by moving focus forward because that's what using the TAB key does in a browser, but it will no longer respect the trap's container edges as it normally would. You will need to add your own keydown handler to call preventDefault() on a TAB key event if you want to completely suppress the use of the TAB key.
  • isKeyBackward {(event: KeyboardEvent) => boolean}: (optional) Determines if the given keyboard event is a "tab backward" event that will move the focus to the previous trapped element in tab order. Defaults to the SHIFT+TAB key. Use this to override the trap's behavior if you want to use arrow keys to control keyboard navigation within the trap, for example. Also see isKeyForward() option.
    • ⚠️ Using this option will not automatically prevent use of the SHIFT+TAB key as the browser will continue to respond to it by moving focus backward because that's what using the SHIFT+TAB key sequence does in a browser, but it will no longer respect the trap's container edges as it normally would. You will need to add your own keydown handler to call preventDefault() on a TAB key event if you want to completely suppress the use of the SHIFT+TAB key sequence.

Shadow DOM

Selector strings

⚠️ Beware that putting a focus-trap inside an open Shadow DOM means you must not use selector strings for options that support these (because nodes inside Shadow DOMs, even open shadows, are not visible via document.querySelector()).

Closed shadows

If you have closed shadow roots that you would like considered for tabbable/focusable nodes, use the tabbableOptions.getShadowRoot option to provide Tabbable (used internally) with a reference to a given node's shadow root so that it can be searched for candidates.

Positive Tabindexes

⚠️ Using positive tab indexes (i.e. <button tabindex="1">Label</button>) is not recommended, primarily for accessibility reasons. Supporting them properly also means a lot of hoops to jump through when Shadow DOM is used as some key DOM APIs like Node.compareDocumentPosition() do not properly support Shadow DOM.

As such, focus-trap considers using positive tabindexes an edge case and only supports them in single-container traps with some caveats for related edge case behavior (see the demo for more details).

If you try to create a multi-container trap where at least one container has one node with a positive tabindex, an exception will be thrown:

At least one node with a positive tabindex was found in one of your focus-trap's multiple containers. Positive tabindexes are only supported in single-container focus-traps.

trap.active

trap.active: boolean

True if the trap is currently active.

trap.paused

trap.paused: boolean

True if the trap is currently paused.

trap.activate()

trap.activate([activateOptions]) => FocusTrap

Activates the focus trap, adding various event listeners to the document.

If focus is already within it the trap, it remains unaffected. Otherwise, focus-trap will try to focus the following nodes, in order:

  • createOptions.initialFocus
  • The first tabbable node in the trap
  • createOptions.fallbackFocus

If none of the above exist, an error will be thrown. You cannot have a focus trap that lacks focus.

Returns the trap.

activateOptions:

These options are used to override the focus trap's default behavior for this particular activation.

  • onActivate {() => void}: Default: whatever you chose for createOptions.onActivate. null or false are the equivalent of a noop.
  • onPostActivate {() => void}: Default: whatever you chose for createOptions.onPostActivate. null or false are the equivalent of a noop.
  • checkCanFocusTrap {(containers: Array<HTMLElement | SVGElement>) => Promise<void>}: Default: whatever you chose for createOptions.checkCanFocusTrap.

trap.deactivate()

trap.deactivate([deactivateOptions]) => FocusTrap

Deactivates the focus trap.

Returns the trap.

deactivateOptions:

These options are used to override the focus trap's default behavior for this particular deactivation.

  • returnFocus {boolean}: Default: whatever you set for createOptions.returnFocusOnDeactivate. If true, then the setReturnFocus option (specified when the trap was created) is used to determine where focus will be returned.
  • onDeactivate {() => void}: Default: whatever you set for createOptions.onDeactivate. null or false are the equivalent of a noop.
  • onPostDeactivate {() => void}: Default: whatever you set for createOptions.onPostDeactivate. null or false are the equivalent of a noop.
  • checkCanReturnFocus {(trigger: HTMLElement | SVGElement) => Promise<void>}: Default: whatever you set for createOptions.checkCanReturnFocus. Not called if the returnFocus option is falsy. trigger is either the originally focused node prior to activation, or the result of the setReturnFocus configuration option.

trap.pause()

trap.pause([pauseOptions]) => FocusTrap

Pause an active focus trap's event listening without deactivating the trap.

If the focus trap has not been activated, nothing happens.

Returns the trap.

Any onDeactivate callback will not be called, and focus will not return to the element that was focused before the trap's activation. But the trap's behavior will be paused.

This is useful in various cases, one of which is when you want one focus trap within another. demo-six exemplifies how you can implement this.

pauseOptions:

These options are used to override the focus trap's default behavior for this particular pausing.

  • onPause {() => void}: Default: whatever you chose for createOptions.onPause. null or false are the equivalent of a noop.
  • onPostPause {() => void}: Default: whatever you chose for createOptions.onPostPause. null or false are the equivalent of a noop.

trap.unpause()

trap.unpause([unpauseOptions]) => FocusTrap

Unpause an active focus trap. (See pause(), above.)

Focus is forced into the trap just as described for focusTrap.activate().

If the focus trap has not been activated or has not been paused, nothing happens.

Returns the trap.

unpauseOptions:

These options are used to override the focus trap's default behavior for this particular unpausing.

  • onUnpause {() => void}: Default: whatever you chose for createOptions.onUnpause. null or false are the equivalent of a noop.
  • onPostUnpause {() => void}: Default: whatever you chose for createOptions.onPostUnpause. null or false are the equivalent of a noop.

trap.updateContainerElements()

trap.updateContainerElements(HTMLElement | SVGElement | string | Array<HTMLElement | SVGElement | string>) => FocusTrap

Update the element(s) that are used as containers for the focus trap.

When you call createFocusTrap(), you give it an element (or selector), or an array of elements (or selectors) to keep the focus within. This method simply allows you to update which elements to keep the focus within even while the trap is active.

A use case for this is found in focus-trap-react, where React ref's may not be initialized yet, but when they are you want to have them be a container element.

Returns the trap.

Examples

Read code in docs/ and see how it works.

Here's generally what happens in default.js (the "default behavior" demo):

const { createFocusTrap } = require('../../index');

const container = document.getElementById('default');

const focusTrap = createFocusTrap('#default', {
  onActivate: () => container.classList.add('is-active'),
  onDeactivate: () => container.classList.remove('is-active'),
});

document
  .getElementById('activate-default')
  .addEventListener('click', focusTrap.activate);
document
  .getElementById('deactivate-default')
  .addEventListener('click', focusTrap.deactivate);

Other details

One at a time

Only one focus trap can be listening at a time. If a second focus trap is activated the first will automatically pause. The first trap is unpaused and again traps focus when the second is deactivated.

Focus trap manages a queue of traps: if A activates; then B activates, pausing A; then C activates, pausing B; when C then deactivates, B is unpaused; and when B then deactivates, A is unpaused.

Use predictable elements for the first and last tabbable elements in your trap

The focus trap will work best if the first and last focusable elements in your trap are simple elements that all browsers treat the same, like buttons and inputs.**

Tabbing will work as expected with trickier, less predictable elements — like iframes, shadow trees, audio and video elements, etc. — as long as they are between more predictable elements (that is, if they are not the first or last tabbable element in the trap).

This limitation is ultimately rooted in browser inconsistencies and inadequacies, but it comes to focus-trap through its dependency Tabbable. You can read about more details in the Tabbable documentation.

Your trap should include a tabbable element or a focusable container

You can't have a focus trap without focus, so an error will be thrown if you try to initialize focus-trap with an element that contains no tabbable nodes.

If you find yourself in this situation, you should give you container tabindex="-1" and set it as initialFocus or fallbackFocus. A couple of demos illustrate this.

Development

Because of the nature of the functionality, involving keyboard and click and (especially) focus events, JavaScript unit tests don't make sense. After all, JSDom does not fully support focus events. Since the demo was developed to also be the test, we use Cypress to automate running through all demos in the demo page.

Help

Testing in JSDom

⚠️ JSDom is not officially supported. Your mileage may vary, and tests may break from one release to the next (even a patch or minor release).

This topic is just here to help with what we know may affect your tests.

In general, a focus trap is best tested in a full browser environment such as Cypress, Playwright, or Nightwatch where a full DOM is available.

Sometimes, that's not entirely desirable, and depending on what you're testing, you may be able to get away with using JSDom (e.g. via Jest), but you'll have to configure your traps using the tabbableOptions.displayCheck: 'none' option.

See Testing tabbable in JSDom for more details.

ERROR: Your focus-trap must have at least one container with at least one tabbable node in it at all times

This error happens when the containers you specified when you setup your focus trap do not have -- or no longer have -- any tabbable elements in them, which means that focus will inevitably escape your trap because focus must always go somewhere.

You will hit this error if your trap does not have (or no longer has) any tabbable (and therefore focusable) elements in it, and it was not configured with a backup element (see the fallbackFocus option -- which must still be in the trap, but does not necessarily have to be tabbable, i.e. it could have tabindex="-1", making it focusable, but not tabbable).

This often happens when traps are related to elements that appear and disappear dynamically. Typically, the error will fire either as the element is being shown (because the trap gets created before the trapped children have been inserted into the DOM), or as it's being hidden (because the trapped children are destroyed before the trap is either destroyed or disabled).

First element in trap is unreachable with the TAB key

If you create a trap and try to use the TAB key to set focus to the first element in your trap, the first element seems unreachable because focus keeps skipping over it for some reason.

This can happen in projects where the Angular-related zone.js module is being used because Zone can interfere with Focus-trap's ability to control where focus goes when it leaves an edge node (that is, a node that is on the edge of a container in which it is trapping focus).

What is actually happening is that Focus-trap is correctly wrapping focus around to that first element (or last element, if going in reverse with SHIFT+TAB, and you're seeing that get skipped) and setting focus to it, but because of Zone's interference (in which Focus-trap's call to preventDefault() on the focus event triggered by the TAB key press is rendered ineffective), once Focus-trap is done handling the event, the browser hasn't received the signal that its default behavior should be prevented, and so it proceeds to move focus to the next element -- effectively "skipping" over the element to which Focus-trap set focus, making it seem "unreachable".

Unfortunately, there's no good workaround to this issue from Focus-trap's perspective. The issue was reported to Angular (not by Focus-trap) and has a PR (also not by Focus-trap) for a fix.

This was originally investigated in #1165 if you want to go deeper.

Contributing

See CONTRIBUTING.

Contributors

In alphabetical order:

Anders Thorsen
Anders Thorsen

🐛
Benjamin Parish
Benjamin Parish

🐛
Clint Goodman
Clint Goodman

💻 📖 💡 ⚠️
Daniel Tonon
Daniel Tonon

📖 🔧 ️️️️♿️ 💻
DaviDevMod
DaviDevMod

📖 💻 🐛
David Clark
David Clark

💻 🐛 🚇 ⚠️ 📖 🚧
Dependabot
Dependabot

🚧
Joas Schilling
Joas Schilling

👀
John Molakvoæ
John Molakvoæ

🤔
Kasper Garnæs
Kasper Garnæs

📖 🐛 💻
Matt Driscoll
Matt Driscoll

🐛 💻
Maxime
Maxime

🐛
Michael Reynolds
Michael Reynolds

🐛
Nate Liu
Nate Liu

⚠️
Piotr Panek
Piotr Panek

🐛 📖 💻 ⚠️
Randy Puro
Randy Puro

🐛
Sadick
Sadick

💻 ⚠️ 📖
Scott Blinch
Scott Blinch

📖
Sean McPherson
Sean McPherson

💻 📖
Sebastian Kriems
Sebastian Kriems

🐛
Slapbox
Slapbox

🐛
Stefan Cameron
Stefan Cameron

💻 🐛 🚇 ⚠️ 📖 🚧
Tyler Hawkins
Tyler Hawkins

🔧 ⚠️ 📖
Vasiliki Boutas
Vasiliki Boutas

🐛
Vinicius Reis
Vinicius Reis

💻 🤔
Wandrille Verlut
Wandrille Verlut

💻 ⚠️ 📖 🔧
Will Mruzek
Will Mruzek

💻 📖 💡 ⚠️ 💬
Zioth
Zioth

🤔 🐛
glushkova91
glushkova91

📖
jpveooys
jpveooys

🐛
Ábris Simon
Ábris Simon

💻 🐛

focus-trap's People

Contributors

allcontributors[bot] avatar andrea-spotsoftware avatar cgood92 avatar dan503 avatar davidevmod avatar davidtheclark avatar dependabot[bot] avatar far-fetched avatar github-actions[bot] avatar gravityrail avatar jtomaszewski avatar kasperg avatar liunate avatar mrescagreenwing avatar pgn-vole avatar sadick254 avatar scottblinch avatar seanmcp avatar simonxabris avatar sonyaorlova avatar stefcameron avatar stof avatar teddriggs avatar thawkin3 avatar tomraithel avatar vinicius73 avatar vonagam avatar wandroll avatar willmruzek avatar wldcordeiro avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

focus-trap's Issues

Update `clickOutsideDeactivates` on the fly

Use-case: modal w/ form can be hidden on click outside only if its fields are empty. Is it possible to update clickOutsideDeactivates setting w/o re-mounting the whole thing?

Thanks!

Feature request: the ability to turn off the error altogether

Hi there @davidtheclark ! To start, I'd like to thank you for sharing this amazing library. It's been really useful for me today.

I'm writing because I'm wondering if it would be possible to completely disable the error that this library throws when there are no focusable elements?

I can see why one may want an error when there are no focusable elements, but other times, it may be OK.

To provide some more details, my specific use case is a dropdown without any items. The docs suggest providing a fallback element, and I can see how that could work in some situations. However, I'm using this with React, and I don't want to make any assumptions about the existence of a ref.

This is for a production app at work, and I'd feel much more confident in my code if I had the guarantee that the error was disabled altogether.

What do you think, @davidtheclark ? Is there any way for the error to be disabled? I'd be happy to open a PR if you think that it's technically possible, and also worth adding.

Thanks for reading, and thank you again for creating such a great library!

--

Update: My current solution is to use fallbackFocus: document.body everywhere. It works inasmuch as it prevents explosions, but it isn't particularly expressive, and breaks the trap (tabbing still works). I think it could still be worthwhile to update the API of the library to support this in a less "hacky" feeling way ✌️

Seeking co-maintainers!

I've been shifting my focus away from UI development, so don't plan on addressing new issues myself. If you use this library and want to see development continue, you can make that happen by becoming a co-maintainer — with permissions to triage issues, merge PRs, cut releases, etc.

Please comment below if you're interested!

Another possibility is for a dedicated owner to fork this code and create a new package. I'd be happy to link to that library from the README of this one.

Allow to interact on some outside elements

I'm having an issue using focus-trap with KendoUI and pickers components like Date Picker. When Kendo Date Picker is placed inside trapped container, then I want to open the picker and click inside picker to select a day. focus-trap prevents clicks inside Kendo picker, beacuse Kendo reparents its pickers directly under <body> element, so pickers are treated as outside elements by focus-trap.

To solve this problem I'm going to create PR with additional configuration option:

includeOutsideElement: Function(element:HTMLElement):boolean a function which will be called when focus-trap detects click/mousedown/touchdown events outside trapped container. true result of this function will not prevents and not stops above events. Developer needs to prevent those events on his own handlers. Kendo prevents mousedown events inside pickers, so focus won't be lost.

focus-trap throws errors on switching tabs in Firefox

Steps to reproduce:

  1. Clone the repo and run npm install.
  2. Build the demo bundle (npm run demo-bundle).
  3. Open the demo page in Firefox (I used v48.0.1), then open another tab. Go back to the demo page's tab and open the browser console (so that you can see the error mentioned below).
  4. Activate a focus trap (I chose to activate trap 1, but it doesn't make a difference).
  5. Switch to the other tab in Firefox, then switch back to the tab with the demo page.
  6. Observe that the following error is thrown in the console: TypeError: e.target.blur is not a function.

What should happen:

The error thrown in step 6 should not be thrown. Everything should just keep working as usual.

Why it happens:

Despite the fact that the spec disallows this, Firefox fires a focus event on the document object itself, and has done so for quite some time now. However, the document object does not have a blur method. Since focus-trap adds a focus listener to the document object, the result is that, in Firefox, the checkFocus method receives the document object as e.target, and attempts to call e.target.blur, which is not a function.

Ways to fix it:

Here are a couple of options for fixing this (I assume it's something that focus-trap should at least work around for now, since Firefox has had this issue for a while and is unlikely to be fixed quickly). I'd like to submit a PR for either one of these, but figured it would also be worthwhile to ask which one is desired. I've locally written up both of them and checked (albeit quickly) that each one works. I'll include a PR for the first solution, below, as a proof-of-concept (which you can go ahead and merge in if it's ultimately the way you'd like to go); I'll be happy to modify it if necessary.

  1. Check if e.target is equal to document in checkFocus and return early if so, or
  2. Add listeners -- or at least the focus listener -- to document.body instead of document, or
  3. Do both (1) and (2).

Any thoughts?

Thanks!

About case with absence of focusable elements

About pull request #13.

There was stated that "I'd rather throw the error; and then if you or others want it silenced, you can use a try-catch to ignore it".

My use case: a popup which may or may not contain focusable elements.
If there is no such element and i use try-catch, then new focusTrap and its event bindings will not be created. User still will have ability to navigate and activate stuff under popup by tab or other means.

I propose to add some option to createOptions, which will determine, to throw an error or to return null from firstFocusNode if there is no focusable element.

Scrolling to top when gaining focus

I have a scrollable div with lots of inputs in it.

When the focus-trap activates, it scrolls to the top. So even if you click on an input at the bottom, and it gains focus, the view is scrolled to the top, and the input with focus is no longer visible.

Other than that, this is working great, so thank you! :D

Does it work in mobile screen readers (TB/VO)?

I checked the demo with mobile TalkBack/VoiceOver and traps don't work in them.
Is it intended by design or just impossible to implement?

The only solution for me is to hide all elements on a page except the modal window by adding aria-hidden="true" to them.

Also, thanks for the great library!

There should be a delay before applying the autofocus

The autofocus of should be delayed to ensure that the focused item don't respond to the same keystroke that have activated the focus trap.

For instance
1 - Press Enter on a button to show a modal
2 - The modal get displayed with its close button focused
3 - Releasing the Enter key closes the modal because the confirm button was focused

The issue there is that the same same event is caught by the OK button of the modal because it was focused within the same event cycle.
A solution would be to delay the autofocus to make sure it is not part of the same cycle.

You can find an example of this issue here:
https://codepen.io/anon/pen/MVZrxW

Opt out of initial default focus.

We have a use case that makes the initial focus of the first element undesirable. Could we add an option to not automatically focus the first tabbable element?

initialFocus: null,  // Maybe?

Thanks for all the awesome work!

Make clickOutsideDeactivates=true return the focus if the click target is not focusable

I'm having an issue building a drawer component using a backdrop (shadow overlay). When the backdrop is clicked, the drawer should be closed. Setting clickOutsideDeactivates to true is required for the click event on the backdrop to be fired, but it seems that enabling this setting does not return the focus to the button used to activate the drawer.

This is understandable because the element clicked outside the focus trap might be a focusable element itself. Thats why I would suggest to add an extra config option to toggle if the focus should be returned, or do an extra check if the element outside is focusable.

Tab issue in Edge/IE11

I am having an issue on Edge and IE11 where pressing tab is not cycling the focus around the elements in the focus trap as it does in other browsers. It doesn't move focus outside of the focus trap, but remains at the last element. Going back works fine until it gets to the first focusable item in the focus trap and then it won't cycle to the last.

I am just wondering if it is possible that I am doing something wrong for it to do this? As I said, it works fine in Firefox, Chrome, and Safari.

edit your demos work fine in Edge so it must be something that I am doing, but I am not sure what.

Thanks!

Release notes

Could you please add release notes when you create releases? It makes it easier for consumers to update the dependency in a more informed way if they can read the release notes.

Can we avoid hijacking tab?

Right now this library hijacks Tab. It relies on tabbable to determine the tabbable nodes within the focus-trap, and their order, then moves focus accordingly when you hit Tab, instead of letting the browser do its default thing.

There are a couple of issues with this approach:

  • tabbable does not (and probably cannot) perfectly match the browser's default behavior. The main symptoms of this are that tabbable is not going to pick up on iframes or shadow DOM — so in a focus-trap those elements won't receive focus; and it also can't handle radio sets.
  • It seems unfortunate: it would be nice if we didn't have to hijack default browser behavior but could somehow just rein it in a little.

If we could find an approach that fixes those issues and still works in all the ways focus-trap currently works — that would be great. I'm opening this issue to hear any ideas anyone has for solving this problem. Input welcome!


I tried experimenting a bit with an alternative approach today. I used tabbable only to grab the first and last tabbable nodes in the trap. Instead of listening to Tab events, I listened to focusout events: if event.relatedTarget comes before the previously focused node (so you're shift+tabbing), I'd focus the last tabbable node; if event.relatedTarget comes after, I'd focus the first tabbable node. I ran into 2 bad roadblocks:

  • This seemed promising on a few of the demos, but then crashed and burned with demo 3. When tabindex is deliberately set anywhere on the page it messes up the whole approach.
  • The first and last focusable items in the trap still need to be recognizable to tabbable.

Allow `initialFocus` to be false

It would be great if we could set initialFocus (or perhaps a new option) to false in order to keep focus on whatever element had it prior to the focus trap being activated. And then the next tab event would go to the first focusable element inside the trap.

My use case is a header with several icons, each of which open a popover. When a popover opens, I want to set a focus trap on the popover element, but I want to keep focus on the icon in the header until tab is pressed again. Does that make sense?

I might take a stab at implementing this myself if I have time.

input.select() should not be called for already focused input fields

Hi,

because of issue #3 the tryFocus function calls node.select() if node is an input field.

Unfortunately this destroys the current selection, if the input field is already focused.

Therefore I suggest to change the first line of the tryFocus function from:

if (!node || !node.focus) return;

to:

if (!node || !node.focus || node === document.activeElement) return;

This would prevent, that the selection of an already focused input field is destroyed.

Use native Object.assign instead of xtend

xtend can easily be replaced with Object.assign

This reduce the dependencies and possible also avoid duplicated versions where some package don't use the same version range for the xtend package

// immutable
Object.assign({}, a, b)

// mutable
Object.assign(a, b)

Does not work on elements within a shadow root

I am having an issue getting this to work within a native web component that uses shadow DOM. When creating a focus trap on an element inside of a shadow root, an exception is thrown.

The exception is actually thrown from tabbable when it is traversing the parent nodes and comes across the shadow root element and tries to call getComputedStyle(node) on it. By adding a check within tabbables isOff function to stop traversing when it hits a node of instance ShadowRoot everything appears to work correctly until the tab key is pressed. I can report this in the tabbable repository.

Now on to the issue in focus-trap. Events that originate from within the shadow dom have their target set to the host custom element. This causes a problem when focus-trap is checking for the element within its tabbable elements collection. It can be fixed by using the first element in e.composedPath() to get the target instead since that will work in both light and shadow dom scenarios.

Anywhere that e.target is being used, something like var target = e.composedPath()[0]; would have to be added, and then use that target variable in place of e.target.

I wanted to bring this up to you, and see if it's something that is known, or if you had any other thoughts or workarounds. I can create a PR for this as well if you'd like since I've got it working in my local environment.

Trap Focus for nested Modals isn't working

Let's assume you have two nested modals. When you open the first modal, the second inner modal is hidden at this time. Until then everything works just fine with your focus trap implementation. But when you open the second inner modal the following error message appears:

Uncaught Error: You can't have a focus-trap without at least one focusable element"

The problem is that you cache the display state of all tabbable elements and their parents inside your dependency 'tabbable'. So when i first run the focusTrap.activate method on the outer modal, the display state for all tabbable elements of the inner modal are set to hidden.

My current workaround is to delete the property 'tabbableCacheIndex' on the related tabbable elements and their parents before i run the focusTrap.activate method the second time.
I really like to use your 'focus-trap' - module so let me know if i could help.

Non-modal mode

Right now when a focus-trap open it's essentially in "modal" mode, meaning that all interaction with the outside application is block.

For building non-modal dialogs, it would be nice to have a "non-modal" mode. Basically it could be a standard focus-trap but clicking outside of it would deactivate it.

Focus blinking on collapsing of the inner trap

You can see this behavior on the sixth demo. After inner trap collapsing focus goes to the initial item and then to the "activate inner trap" button.
This behavior visible even on such simple implementation. Just imagine some React components with lots of inputs, buttons and focus animations. Looks horrible with this blinking.

Uncaught Error: `initialFocus` refers to no known node

Hi! Case for React App:

state = { isLoading: true };

componentDidMount() {
   if (// some logic) this.setState({ isLoading: false });
}

render () {
   if (this.state.isLoading) {
      return <React-aria-modal-first/>
   } else {
      return <React-aria-modal-second/>
}

So i have two react-aria-modals, and on the first Render i always show <React-aria-modal-first/> and on the second Render (if condition's result in componentDidMount is true) i need to show <React-aria-modal-second/>, but due to delay before the element is focused (https://github.com/davidtheclark/focus-trap/blob/master/index.js#L141) i always get "Uncaught Error: initialFocus refers to no known node" because second render in React happens faster (first modal has been already removed from the DOM) than focus-function on first modal is executed. But in this case i don't want to see this error because there is no problems in my app or React

Focus trap breaks native radio form flow

Just ran into an issue using this on a project with modals that have a form with radio buttons. The standard functionality for a radio button group is that when I tab into a radio form, it will focus in on the first unselected radio button in that group. When I then press tab again, the focus should leave the radio group entirely and hit the next element (in this case a submit button).

However if the same form is within a modal with focus trap, I am then forced to focus on each individual radio button options within the radio group instead of skipping directly to the submit button on TAB.

This creates an inconsistent experience between forms and may lead to confusion for non-sighted users who rely on keyboard controls to navigate through a site.

https://www.w3.org/TR/wai-aria-practices/#radiobutton

Activating a trap updates the tabbable nodes twice

In my experience, the bottleneck of activating a focus-trap is the tabbable library when the trap contains lots of tabbable nodes.
When activating a trap, the list of tabbable nodes is updated twice: one directly in activate and one inside addListeners. Would it be possible to combine those to update the list only once ?

Needs to select() inputs that receive focus

Because the plugin is using event.preventDefault() to control focus, it is preventing the default behavior of selecting an input when that input receives focus from a tab.

This is easily restored by changing the tryFocus function to:

function tryFocus(node) {
  if (!node || !node.focus) return;
  node.focus();
  if (node.tagName.toLowerCase() === 'input') node.select();
}

Can't move focus into iframe

I tried using this with a modal that has a YouTube embed, but I was unable to tab into the iframe when it's active. Is this a known issue or am I doing something wrong?

Precompiled version?

I'm trying to use this on a site that doesn't use Node/NPM; is there any chance you could put up a pre-compiled version for these types of use cases?

Focusing on first focus-able element by default is a major accessibility issue

I created a pull request for this issue here: #55

The current implementation of focus-trap has a major accessibility issue that can easily be resolved by making the focus-trap container the default element that receives focus when the focus trap is activated.

For example, have a look at demo 1 on the demo site.

This is what the UX is like for a screen reader user using NVDA trying to use demo 1 running the current focus-trap default implementation:

  1. Focus on activate button
  2. "activate trap 1 [button]"
  3. Press enter
  4. "with [link]"

Now this is what it is like if you accept my pull request:

  1. Focus on activate button
  2. "activate trap 1 [button]"
  3. Press enter
  4. "Here is a focus trap with [link] some [link] focusable [link] parts. Deactivate trap 1"

See how much more accessible this default functionality is for screen reader users?

Not working inside another focus trap and custom elements

Hi I'm using the focus trap api to trap focus within a custom element where it works.

The code was coverted to coffescript for the project here https://github.com/puranjayjain/chrome-color-picker

However, when triggering the focus trap within another element within it the focus trap occurs on the new element and removes from the old element(as expected) but the focus is locked on one element inside it and won't change.

I tried changing the other element's tabindex to 0, 1, 2 but it won't change no matter what.

The relevant lines of code are
https://github.com/puranjayjain/chrome-color-picker/blob/master/lib/chrome-color-picker.coffee#L930

and other places in this file where the setTrap function was used

Position lost after focus on tabindex="-1"

Hi @davidtheclark,
I'm currently using your focus-trap and it's working totally fine. Unfortunately I ran into a problem just now:
My use case is a modal with a user help which consists of a table of contents, some headings and blocks of information. A click in the table of contents leads to a focus on the corresponding heading with the help of tabindex="-1". So far, so good. Now I want to tab to next tabbable element - in this case the first following block of information - but that doesn't work like I'd planned. The reason is that elements with tabindex="-1" are not part of the tabbableNodes-Array and therefore can't be found in the context of the modal. The consquence is that the focus is set back to the first element of the array instead of jumping to the next expected one. The user loses his position in the modal and gets irritated.
Furthermore is the use of tabindex="0" no option because the headings shouldn't be tabbable in general. The tabindex is just for the focus (because of accessibilty reasons).

Now I want to know if this sounds like a valid use case your focus-trap could handle? Unfortunately that would mean to make changes in your tabbable-module and in this one. Elements with tabindex="-1" have to be part of the tabbableNodes (maybe using a function parameter like registerNegativeIndeces=true?) and afterwards the handleTab-function needs to ignore all those elements. Would that be possible?

Thank you 💖

This library has proved immensely helpful in adding more friendly screen reader UX to a recent web project. Thank you so much, David, for your work on focus-trap and tabbable. You may see a focus-trap-vue popping up soon enough if I can manage my time wisely!

All the best,
Aaron

Not compatible with ES2015 / ES6 (import)

Hmm, I would be great if you could use this package in ES2015 / ES6 land (ie. with Rollup).

Currently only seems to be able to be used with require(), which isn't compatible.

Needs to be able to be imported...

import x2js from 'x2js';
-or even-
import * as x2js from 'x2js';

Any chance of that happening? :)

Also typings? :D

Allow custom keys

Should tab and escape keys be configurable? Example use case would be to allow the use of up and down keys instead of tab and shift-tab.

set tab index to -1 onDeactivate

Is there a built in way to have all focusable elements receive a tabIndex of -1 onDeactivate?
This would be useful when you have, for example, a navbar which is not unmounted from the dom when hidden, i.e. translated off screen. If we don't set tabIndex to -1 when it is hidden, focus ends up hidden offscreen.

One suggestion for how we can go about adding this feature is by adding a function which runs before the trap is deactivated which sets the tabIndex if options.autoDisableFocus is true, this is how I am currently doing it in my code but it defeats the purpose of using this 'library' in the first place.

Sorry if there is already an inbuilt way to do this, if there is I could not find it. Im glad to help integrate the feature if you would like to add it.

Feature request: Exclude-Focus

First of all: Thanks for your great work! I really like your focus-trap-react and react-aria-modal.

When saving a formular, I show an overlay only over the formular, so that the user can still use other elements of the page. Unfortunately he can also tab "behind" the overlay and change something in the formular. Would it be possible to make a reversed-focus-trap, which makes it impossible for their children to receive focus / jumps to the next element outside of the reversed-focus-trap?

Thanks in advance!

Allow `initialFocus` to be false. Or maybe add another property

Is it really necessary setting up the focus when unpausing\activating focus-trap?
I mean if you wanna be sure that focus is inside focus-trap why just don't check for it? And if it's really somewhere outside, focus the initial element.

Potential use case (not counting previous issues):

If you have some modal divs inside your focus-trap that are being rendered outside of focus-trap (hints, popovers etc.) then you have to pause focus-trap every time they are appearing. And you don't want focus to jump from the current position to the initial position every time you are closing popover, hint or whatever. Of course in case if your popovers/hints have nothing to focus inside.

p.s. sorry for English

No-refocus on first element when unpause called

The documentation states Focus is forced into the trap just as described for focusTrap.activate(). I have found this to not be the case. Is the actual intent that the application will focus on the first tabbable element on unpause? If so, I will create steps for reproduction.

Feature Request: Pass event which deactivated focus trap into onDeactivate handler

Hi @davidtheclark,

Thanks for the continued support on this library. Ive got a usecase where Id like to conditionally show a focus ring around a element based on whether focus trap was deactivated with a keyboard event. So essentially mouse and touch events should not show the focus indicator. Ive implemented a "onDeactivate" handler which focuses correctly on my element, however, I dont have a reference to the event that triggered the deactivation, so can deduce whether I should or should not have a focus ring. My solution is using CSS in JS so alot of the styles are triggered based on JS logic and not via pure CSS.

One suggestion I have is to pass in the original event (or maybe just the event type) as an additional argument to the onDeactivate handler so that a user of focus trap can do some app specific logic based on that original event.

Is there any other way to do this? Or is there an existing path forward which Im not aware of?

Problems Overriding onDeactivate

Hi,

I am having issues overriding the deactivate options. If I call deactivate as follows:

 focusTrap.deactivate({
    returnFocus: false,
    onDeactivate: function() {
       // do some specific stuff
    }
});

I find that the code in onDeactivate above isn't being called, and the returnFocus setting is also being ignored.

Am I doing something wrong?

Thanks in advance!

Unable to preventDefault inside passive event listener on mobile devices

[Intervention] Unable to preventDefault inside passive event listener due to target being treated as passive. See <URL>
That is what Google Chrome's (v71) console says if we leave option clickOutsideDeactivates: false on mobile device.

As i know, some mobile browser vendors intervent default event listeners behavior with aggressive { passive: true } option in addEventListener.

That console message can be seen in Chrome Devtools mobile device mode on.

Feature request: ability to disabled initialFocus option

Hi,

I would like to request an option that allows you to disable the call to initialFocus, as I have code that deals with that elsewhere.
Adding an option such as noInitialFocus could allow this.

For example in addListeners:

 if (getNodeForOption('noInitialFocus') === null) {
   tryFocus(firstFocusNode());
}

I have created a local copy that does just this which suits my needs. Happy to submit a PR if you think the addition of this feature is worth it.

Thanks for providing this!

Create a new release that includes PR #69 changes.

As mentioned in material-components/material-components-web#4104 we need the change referenced in #69 in order for material-components-web and material-components-web-react to correctly render the drawer when server side rendering react components.

As such I wanted to inquire if we can create a new release that contains said PR that has been merged in ~1 month ago. That way material-components-web can update their package.json, and material-components-web-react as well.

Thanks for your understanding, let me know If there is anything I can do to help get this done.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.