focus-trap
Advanced tools
Comparing version 1.1.1 to 2.0.0
# Changelog | ||
## 2.0.0 | ||
- Rewrote the thing, altering the API. Read the new docs please. | ||
- Update `tabbable` to fix handling of traps with changing contents. | ||
## 1.1.1 | ||
@@ -4,0 +9,0 @@ |
253
index.js
var tabbable = require('tabbable'); | ||
var trap; | ||
var tabbableNodes; | ||
var previouslyFocused; | ||
var activeFocusTrap; | ||
var config; | ||
var listeningFocusTrap = null; | ||
function activate(element, options) { | ||
// There can be only one focus trap at a time | ||
if (activeFocusTrap) deactivate({ returnFocus: false }); | ||
activeFocusTrap = true; | ||
function focusTrap(element, userOptions) { | ||
var tabbableNodes = []; | ||
var nodeFocusedBeforeActivation = null; | ||
var active = false; | ||
trap = (typeof element === 'string') | ||
var container = (typeof element === 'string') | ||
? document.querySelector(element) | ||
: element; | ||
config = options || {}; | ||
var config = userOptions || {}; | ||
config.returnFocusOnDeactivate = (userOptions && userOptions.returnFocusOnDeactivate != undefined) | ||
? userOptions.returnFocusOnDeactivate | ||
: true; | ||
config.escapeDeactivates = (userOptions && userOptions.escapeDeactivates != undefined) | ||
? userOptions.escapeDeactivates | ||
: true; | ||
previouslyFocused = document.activeElement; | ||
var trap = { | ||
activate: activate, | ||
deactivate: deactivate, | ||
pause: removeListeners, | ||
unpause: addListeners, | ||
}; | ||
updateTabbableNodes(); | ||
return trap; | ||
tryFocus(firstFocusNode()); | ||
function activate(activateOptions) { | ||
var defaultedActivateOptions = { | ||
onActivate: (activateOptions && activateOptions.onActivate !== undefined) | ||
? activateOptions.onActivate | ||
: config.onActivate, | ||
}; | ||
document.addEventListener('focus', checkFocus, true); | ||
document.addEventListener('click', checkClick, true); | ||
document.addEventListener('mousedown', checkClickInit, true); | ||
document.addEventListener('touchstart', checkClickInit, true); | ||
document.addEventListener('keydown', checkKey, true); | ||
} | ||
active = true; | ||
nodeFocusedBeforeActivation = document.activeElement; | ||
function firstFocusNode() { | ||
var node; | ||
if (defaultedActivateOptions.onActivate) { | ||
defaultedActivateOptions.onActivate(); | ||
} | ||
if (!config.initialFocus) { | ||
node = tabbableNodes[0]; | ||
if (!node) { | ||
throw new Error('You can\'t have a focus-trap without at least one focusable element'); | ||
addListeners(); | ||
return trap; | ||
} | ||
function deactivate(deactivateOptions) { | ||
var defaultedDeactivateOptions = { | ||
returnFocus: (deactivateOptions && deactivateOptions.returnFocus != undefined) | ||
? deactivateOptions.returnFocus | ||
: config.returnFocusOnDeactivate, | ||
onDeactivate: (deactivateOptions && deactivateOptions.onDeactivate !== undefined) | ||
? deactivateOptions.onDeactivate | ||
: config.onDeactivate, | ||
}; | ||
removeListeners(); | ||
if (defaultedDeactivateOptions.onDeactivate) { | ||
defaultedDeactivateOptions.onDeactivate(); | ||
} | ||
return node; | ||
if (defaultedDeactivateOptions.returnFocus) { | ||
setTimeout(function() { | ||
tryFocus(nodeFocusedBeforeActivation); | ||
}, 0); | ||
} | ||
active = false; | ||
return this; | ||
} | ||
if (typeof config.initialFocus === 'string') { | ||
node = document.querySelector(config.initialFocus); | ||
} else { | ||
node = config.initialFocus; | ||
function addListeners() { | ||
if (!active) return; | ||
// There can be only one listening focus trap at a time | ||
if (listeningFocusTrap) { | ||
listeningFocusTrap.unlisten(); | ||
} | ||
listeningFocusTrap = trap; | ||
updateTabbableNodes(); | ||
tryFocus(firstFocusNode()); | ||
document.addEventListener('focus', checkFocus, true); | ||
document.addEventListener('click', checkClick, true); | ||
document.addEventListener('mousedown', checkPointerDown, true); | ||
document.addEventListener('touchstart', checkPointerDown, true); | ||
document.addEventListener('keydown', checkKey, true); | ||
return trap; | ||
} | ||
if (!node) { | ||
throw new Error('The `initialFocus` selector you passed refers to no known node'); | ||
function removeListeners() { | ||
if (!active || !listeningFocusTrap) return; | ||
document.removeEventListener('focus', checkFocus, true); | ||
document.removeEventListener('click', checkClick, true); | ||
document.removeEventListener('mousedown', checkPointerDown, true); | ||
document.removeEventListener('touchstart', checkPointerDown, true); | ||
document.removeEventListener('keydown', checkKey, true); | ||
listeningFocusTrap = null; | ||
return trap; | ||
} | ||
return node; | ||
} | ||
function deactivate(deactivationOptions) { | ||
deactivationOptions = deactivationOptions || {}; | ||
if (!activeFocusTrap) return; | ||
activeFocusTrap = false; | ||
function firstFocusNode() { | ||
var node; | ||
document.removeEventListener('focus', checkFocus, true); | ||
document.removeEventListener('click', checkClick, true); | ||
document.addEventListener('mousedown', checkClickInit, true); | ||
document.addEventListener('touchstart', checkClickInit, true); | ||
document.removeEventListener('keydown', checkKey, true); | ||
if (!config.initialFocus) { | ||
node = tabbableNodes[0]; | ||
if (!node) { | ||
throw new Error('You can\'t have a focus-trap without at least one focusable element'); | ||
} | ||
return node; | ||
} | ||
if (config.onDeactivate) config.onDeactivate(); | ||
node = (typeof config.initialFocus === 'string') | ||
? document.querySelector(config.initialFocus) | ||
: config.initialFocus; | ||
if (!node) { | ||
throw new Error('`initialFocus` refers to no known node'); | ||
} | ||
if (deactivationOptions.returnFocus !== false) { | ||
setTimeout(function() { | ||
tryFocus(previouslyFocused); | ||
}, 0); | ||
return node; | ||
} | ||
} | ||
// This needs to be done on mousedown and touchstart instead of click | ||
// so that it precedes the focus event | ||
function checkClickInit(e) { | ||
if (config.clickOutsideDeactivates) { | ||
deactivate({ returnFocus: false }); | ||
// This needs to be done on mousedown and touchstart instead of click | ||
// so that it precedes the focus event | ||
function checkPointerDown(e) { | ||
if (config.clickOutsideDeactivates) { | ||
deactivate({ returnFocus: false }); | ||
} | ||
} | ||
} | ||
function checkClick(e) { | ||
if (config.clickOutsideDeactivates) return; | ||
if (trap.contains(e.target)) return; | ||
e.preventDefault(); | ||
e.stopImmediatePropagation(); | ||
} | ||
function checkClick(e) { | ||
if (config.clickOutsideDeactivates) return; | ||
if (container.contains(e.target)) return; | ||
e.preventDefault(); | ||
e.stopImmediatePropagation(); | ||
} | ||
function checkFocus(e) { | ||
if (trap.contains(e.target)) return; | ||
e.preventDefault(); | ||
e.stopImmediatePropagation(); | ||
e.target.blur(); | ||
} | ||
function checkKey(e) { | ||
if (e.key === 'Tab' || e.keyCode === 9) { | ||
handleTab(e); | ||
function checkFocus(e) { | ||
if (container.contains(e.target)) return; | ||
e.preventDefault(); | ||
e.stopImmediatePropagation(); | ||
e.target.blur(); | ||
} | ||
if (config.escapeDeactivates !== false && isEscapeEvent(e)) { | ||
deactivate(); | ||
function checkKey(e) { | ||
if (e.key === 'Tab' || e.keyCode === 9) { | ||
handleTab(e); | ||
} | ||
if (config.escapeDeactivates !== false && isEscapeEvent(e)) { | ||
deactivate(); | ||
} | ||
} | ||
} | ||
function handleTab(e) { | ||
e.preventDefault(); | ||
updateTabbableNodes(); | ||
var currentFocusIndex = tabbableNodes.indexOf(e.target); | ||
var lastTabbableNode = tabbableNodes[tabbableNodes.length - 1]; | ||
var firstTabbableNode = tabbableNodes[0]; | ||
if (e.shiftKey) { | ||
if (e.target === firstTabbableNode) { | ||
tryFocus(lastTabbableNode); | ||
return; | ||
function handleTab(e) { | ||
e.preventDefault(); | ||
updateTabbableNodes(); | ||
var currentFocusIndex = tabbableNodes.indexOf(e.target); | ||
var lastTabbableNode = tabbableNodes[tabbableNodes.length - 1]; | ||
var firstTabbableNode = tabbableNodes[0]; | ||
if (e.shiftKey) { | ||
if (e.target === firstTabbableNode || tabbableNodes.indexOf(e.target) === -1) { | ||
return tryFocus(lastTabbableNode); | ||
} | ||
return tryFocus(tabbableNodes[currentFocusIndex - 1]); | ||
} | ||
tryFocus(tabbableNodes[currentFocusIndex - 1]); | ||
return; | ||
if (e.target === lastTabbableNode) return tryFocus(firstTabbableNode); | ||
tryFocus(tabbableNodes[currentFocusIndex + 1]); | ||
} | ||
if (e.target === lastTabbableNode) { | ||
tryFocus(firstTabbableNode); | ||
return; | ||
function updateTabbableNodes() { | ||
tabbableNodes = tabbable(container); | ||
} | ||
tryFocus(tabbableNodes[currentFocusIndex + 1]); | ||
} | ||
function updateTabbableNodes() { | ||
tabbableNodes = tabbable(trap); | ||
function isEscapeEvent(e) { | ||
return e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27; | ||
} | ||
@@ -140,9 +198,2 @@ | ||
function isEscapeEvent(e) { | ||
return e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27; | ||
} | ||
module.exports = { | ||
activate: activate, | ||
deactivate: deactivate, | ||
}; | ||
module.exports = focusTrap; |
{ | ||
"name": "focus-trap", | ||
"version": "1.1.1", | ||
"version": "2.0.0", | ||
"description": "Trap focus within a DOM node.", | ||
@@ -32,3 +32,3 @@ "main": "index.js", | ||
"dependencies": { | ||
"tabbable": "^1.0.0" | ||
"tabbable": "^1.0.3" | ||
}, | ||
@@ -35,0 +35,0 @@ "devDependencies": { |
104
README.md
@@ -5,7 +5,7 @@ # focus-trap | ||
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. | ||
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 try to build an **accessible modal or dropdown menu**. | ||
You will definitely face this challenge when you are try to build **accessible modals or dropdown menus**. | ||
This module is a little **vanilla JS** solution to the problem. | ||
This module is a little **vanilla JS** solution to that problem. | ||
@@ -16,16 +16,18 @@ If you are using React, check out [focus-trap-react](https://github.com/davidtheclark/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! | ||
[Check out the demo.](http://davidtheclark.github.io/focus-trap/demo/) | ||
[Check out the demos.](http://davidtheclark.github.io/focus-trap/demo/) | ||
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](https://github.com/davidtheclark/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*. | ||
- 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](https://github.com/davidtheclark/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. | ||
- 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*. In the case of a modal dialog, for instance, some trigger button probably initiated the activation; so when the modal closes, that trigger button will receive focus. | ||
- 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. | ||
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 | ||
@@ -47,22 +49,58 @@ | ||
The module exposes two functions. | ||
### focusTrap = createFocusTrap(element[, createOptions]); | ||
### focusTrap.activate(element[, options]) | ||
Returns a new focus trap on `element`. | ||
Turn `element` into a focus trap. | ||
`element` can be | ||
- a DOM node (the focus trap itself) or | ||
- a selector string (which will be pass to `document.querySelector()` to find the DOM node). | ||
`element` can be a DOM node (the focus trap itself) or a selector string | ||
(which will be pass to `document.querySelector()` to find the DOM node). | ||
`createOptions`: | ||
Options: | ||
- **onActivate** {function}: A function that will be called when the focus trap activates. | ||
- **onDeactivate** {function}: A function that will be called when the focus trap deactivates, | ||
- **initialFocus** {element|string}: 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). | ||
- **escapeDeactivates** {boolean}: Default: `true`. If `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. | ||
- **clickOutsideDeactivates** {boolean}: Default: `false`. If `true`, a click outside the focus trap will deactivate the focus trap and allow the click event to do its thing. | ||
- ** returnFocusOnDeactivate** {boolean}: Default: `true`. If `false`, when the trap is deactivated, focus will *not* return to the element that had focus before activation. | ||
- **initialFocus** {element|string}: 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 pass to `document.querySelector()` to find the DOM node). | ||
- **onDeactivate** {function}: A function that will be called when the focus trap deactivates. This can be useful if, for example, you want to remove a modal from the screen when the user hits Escape and thereby deactivates the focus trap. | ||
- **escapeDeactivates** {boolean}: Default: `true`. If `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. | ||
- **clickOutsideDeactivates** {boolean}: Default: `false`. If `true`, a click outside the focus trap will deactivate the focus trap and allow the click event to carry on. | ||
### focusTrap.activate() | ||
### focusTrap.deactivate() | ||
Activates the focus trap, adding various event listeners to the document. | ||
Simply deactivate any currently active focus trap. | ||
Returns the `focusTrap`. | ||
### focusTrap.deactivate([deactivateOptions]) | ||
Deactivates the focus trap. | ||
Returns the `focusTrap`. | ||
`deactivateOptions`: | ||
These options are used to override the focus trap's default behavior for this particular deactivation. | ||
- **returnFocus** {boolean}: Default: whatever you chose for `createOptions.returnFocusOnDeactivate`. | ||
- **onDeactivate** {function | null | false}: Default: whatever you chose for `createOptions.onDeactivate`. `null` or `false` are the equivalent of a `noop`. | ||
### focusTrap.pause() | ||
Pause an active focus trap's event listening without deactivating the trap. | ||
If the focus trap has not been activated, nothing happens. | ||
Returns the `focusTrap`. | ||
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. | ||
### focusTrap.unpause() | ||
Unpause an active focus trap. (See `pause()`, above.) | ||
If the focus trap has not been activated or has not been paused, nothing happens. | ||
Returns the `focusTrap`. | ||
## Examples | ||
@@ -75,21 +113,19 @@ | ||
```js | ||
var focusTrap = require('focus-trap'); | ||
var createFocusTrap = require('../../'); | ||
var el = document.getElementById('demo-one'); | ||
var containerOne = document.getElementById('demo-one'); | ||
var focusTrapOne = createFocusTrap('#demo-one', { | ||
onDeactivate: function() { | ||
containerOne.className = 'trap'; | ||
}, | ||
}); | ||
document.getElementById('activate-one').addEventListener('click', function() { | ||
focusTrap.activate(el, { | ||
onDeactivate: removeActiveClass, | ||
}); | ||
el.className = 'trap is-active'; | ||
focusTrapOne.activate(); | ||
containerOne.className = 'trap is-active'; | ||
}); | ||
document.getElementById('deactivate-one').addEventListener('click', function() { | ||
focusTrap.deactivate(); | ||
removeActiveClass(); | ||
focusTrapOne.deactivate(); | ||
}); | ||
function removeActiveClass() { | ||
el.className = 'trap'; | ||
} | ||
``` | ||
@@ -99,6 +135,6 @@ | ||
- *Only one focus trap can be active at a time.* So if Focus Trap X is active and you try to activate Focus Trap Y, *first* Focus Trap X will be deactivated, *then* Focus Trap Y will be activated. | ||
- *Only one focus trap can be listening at a time.* So if you want two focus traps active at a time, one of them has to be paused. | ||
## Development | ||
Because of the nature of the functionality, involving keyboard and click and (especially) focus events, JavaScript unit tests didn't make sense. (If you disagree and can help out, please PR!) So the demo is also the test: run it in browsers and see how it works. | ||
Because of the nature of the functionality, involving keyboard and click and (especially) focus events, JavaScript unit tests didn't make sense. (If you disagree and can help out, please PR!) So the demo is also the test: run it in browsers and see how it works, checking the documented requirements. |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
13811
158
136
Updatedtabbable@^1.0.3