focus-trap-react
Advanced tools
Comparing version 8.10.0 to 8.11.0
# Changelog | ||
## 8.11.0 | ||
### Minor Changes | ||
- 7495680: Bump focus-trap to v6.9.0 to get bug fixes and new features to help fix some bugs. | ||
### Patch Changes | ||
- 7495680: Fix onDeactivate, onPostDeactivate, and checkCanReturnFocus options not being called consistently on deactivation. | ||
- 7495680: Fix focus not being allowed to remain on outside node post-deactivation when `clickOutsideDeactivates` is true or returns true. | ||
## 8.10.0 | ||
@@ -8,2 +19,3 @@ | ||
- 659d44e: Bumps focus-trap to v6.8.1. The big new feature is opt-in Shadow DOM support in focus-trap (in tabbable), and new tabbable options exposed in a new `focusTrapOptions.tabbableOptions` configuration option. | ||
- ⚠️ This will likely break your tests **if you're using JSDom** (e.g. with Jest). See [testing in JSDom](./README.md#testing-in-jsdom) for more info. | ||
@@ -10,0 +22,0 @@ ## 8.9.2 |
@@ -32,3 +32,6 @@ "use strict"; | ||
var _require = require('focus-trap'), | ||
createFocusTrap = _require.createFocusTrap; // TODO: These issues are related to older React features which we'll likely need | ||
createFocusTrap = _require.createFocusTrap; | ||
var _require2 = require('tabbable'), | ||
isFocusable = _require2.isFocusable; // TODO: These issues are related to older React features which we'll likely need | ||
// to fix in order to move the code forward to the next major version of React. | ||
@@ -50,14 +53,39 @@ // @see https://github.com/davidtheclark/focus-trap-react/issues/77 | ||
_this = _super.call(this, props); // We need to hijack the returnFocusOnDeactivate option, | ||
// because React can move focus into the element before we arrived at | ||
// this lifecycle hook (e.g. with autoFocus inputs). So the component | ||
// captures the previouslyFocusedElement in componentWillMount, | ||
// then (optionally) returns focus to it in componentWillUnmount. | ||
_this = _super.call(this, props); | ||
_this.handleDeactivate = _this.handleDeactivate.bind(_assertThisInitialized(_this)); | ||
_this.handlePostDeactivate = _this.handlePostDeactivate.bind(_assertThisInitialized(_this)); | ||
_this.handleClickOutsideDeactivates = _this.handleClickOutsideDeactivates.bind(_assertThisInitialized(_this)); // focus-trap options used internally when creating the trap | ||
_this.tailoredFocusTrapOptions = { | ||
returnFocusOnDeactivate: false | ||
}; // because of the above, we maintain our own flag for this option, and | ||
// default it to `true` because that's focus-trap's default | ||
_this.internalOptions = { | ||
// We need to hijack the returnFocusOnDeactivate option, | ||
// because React can move focus into the element before we arrived at | ||
// this lifecycle hook (e.g. with autoFocus inputs). So the component | ||
// captures the previouslyFocusedElement in componentWillMount, | ||
// then (optionally) returns focus to it in componentWillUnmount. | ||
returnFocusOnDeactivate: false, | ||
// the rest of these are also related to deactivation of the trap, and we | ||
// need to use them and control them as well | ||
checkCanReturnFocus: null, | ||
onDeactivate: _this.handleDeactivate, | ||
onPostDeactivate: _this.handlePostDeactivate, | ||
// we need to special-case this setting as well so that we can know if we should | ||
// NOT return focus if the trap gets auto-deactivated as the result of an | ||
// outside click (otherwise, we'll always think we should return focus because | ||
// of how we manage that flag internally here) | ||
clickOutsideDeactivates: _this.handleClickOutsideDeactivates | ||
}; // original options provided by the consumer | ||
_this.returnFocusOnDeactivate = true; | ||
_this.originalOptions = { | ||
// because of the above `tailoredFocusTrapOptions`, we maintain our own flag for | ||
// this option, and default it to `true` because that's focus-trap's default | ||
returnFocusOnDeactivate: true, | ||
// because of the above `tailoredFocusTrapOptions`, we keep these separate since | ||
// they're part of the deactivation process which we configure (internally) to | ||
// be shared between focus-trap and focus-trap-react | ||
onDeactivate: null, | ||
onPostDeactivate: null, | ||
checkCanReturnFocus: null, | ||
// the user's setting, defaulted to false since focus-trap defaults this to false | ||
clickOutsideDeactivates: false | ||
}; | ||
var focusTrapOptions = props.focusTrapOptions; | ||
@@ -70,18 +98,18 @@ | ||
if (optionName === 'returnFocusOnDeactivate') { | ||
_this.returnFocusOnDeactivate = !!focusTrapOptions[optionName]; | ||
continue; | ||
if (optionName === 'returnFocusOnDeactivate' || optionName === 'onDeactivate' || optionName === 'onPostDeactivate' || optionName === 'checkCanReturnFocus' || optionName === 'clickOutsideDeactivates') { | ||
_this.originalOptions[optionName] = focusTrapOptions[optionName]; | ||
continue; // exclude from tailoredFocusTrapOptions | ||
} | ||
if (optionName === 'onPostDeactivate') { | ||
_this.onPostDeactivate = focusTrapOptions[optionName]; | ||
continue; | ||
} | ||
_this.internalOptions[optionName] = focusTrapOptions[optionName]; | ||
} // if set, `{ target: Node, allowDeactivation: boolean }` where `target` is the outside | ||
// node that was clicked, and `allowDeactivation` is the result of the consumer's | ||
// option (stored in `this.originalOptions.clickOutsideDeactivates`, which may be a | ||
// function) whether to allow or deny auto-deactivation on click on this outside node | ||
_this.tailoredFocusTrapOptions[optionName] = focusTrapOptions[optionName]; | ||
} // elements from which to create the focus trap on mount; if a child is used | ||
_this.outsideClick = null; // elements from which to create the focus trap on mount; if a child is used | ||
// instead of the `containerElements` prop, we'll get the child's related | ||
// element when the trap renders and then is declared 'mounted' | ||
_this.focusTrapElements = props.containerElements || []; // now we remember what the currently focused element is, not relying on focus-trap | ||
@@ -111,3 +139,3 @@ | ||
value: function getNodeForOption(optionName) { | ||
var optionValue = this.tailoredFocusTrapOptions[optionName]; | ||
var optionValue = this.internalOptions[optionName]; | ||
@@ -160,23 +188,77 @@ if (!optionValue) { | ||
value: function deactivateTrap() { | ||
var _this2 = this; | ||
// NOTE: it's possible the focus trap has already been deactivated without our knowing it, | ||
// especially if the user set the `clickOutsideDeactivates: true` option on the trap, | ||
// and the mouse was clicked on some element outside the trap; at that point, focus-trap | ||
// will initiate its auto-deactivation process, which will call our own | ||
// handleDeactivate(), which will call into this method | ||
if (!this.focusTrap || !this.focusTrap.active) { | ||
return; | ||
} | ||
var _this$tailoredFocusTr = this.tailoredFocusTrapOptions, | ||
checkCanReturnFocus = _this$tailoredFocusTr.checkCanReturnFocus, | ||
_this$tailoredFocusTr2 = _this$tailoredFocusTr.preventScroll, | ||
preventScroll = _this$tailoredFocusTr2 === void 0 ? false : _this$tailoredFocusTr2; | ||
this.focusTrap.deactivate({ | ||
// NOTE: we never let the trap return the focus since we do that ourselves | ||
returnFocus: false, | ||
// we'll call this in our own post deactivate handler so make sure the trap doesn't | ||
// do it prematurely | ||
checkCanReturnFocus: null, | ||
// let it call the user's original deactivate handler, if any, instead of | ||
// our own which calls back into this function | ||
onDeactivate: this.originalOptions.onDeactivate // NOTE: for post deactivate, don't specify anything so that it calls the | ||
// onPostDeactivate handler specified on `this.internalOptions` | ||
// which will always be our own `handlePostDeactivate()` handler, which | ||
// will finish things off by calling the user's provided onPostDeactivate | ||
// handler, if any, at the right time | ||
// onPostDeactivate: NOTHING | ||
if (this.focusTrap) { | ||
// NOTE: we never let the trap return the focus since we do that ourselves | ||
this.focusTrap.deactivate({ | ||
returnFocus: false | ||
}); | ||
}); | ||
} | ||
}, { | ||
key: "handleClickOutsideDeactivates", | ||
value: function handleClickOutsideDeactivates(event) { | ||
// use consumer's option (or call their handler) as the permission or denial | ||
var allowDeactivation = typeof this.originalOptions.clickOutsideDeactivates === 'function' ? this.originalOptions.clickOutsideDeactivates.call(null, event) // call out of context | ||
: this.originalOptions.clickOutsideDeactivates; // boolean | ||
if (allowDeactivation) { | ||
// capture the outside target that was clicked so we can use it in the deactivation | ||
// process since the consumer allowed it to cause auto-deactivation | ||
this.outsideClick = { | ||
target: event.target, | ||
allowDeactivation: allowDeactivation | ||
}; | ||
} | ||
return allowDeactivation; | ||
} | ||
}, { | ||
key: "handleDeactivate", | ||
value: function handleDeactivate() { | ||
if (this.originalOptions.onDeactivate) { | ||
this.originalOptions.onDeactivate.call(null); // call user's handler out of context | ||
} | ||
this.deactivateTrap(); | ||
} | ||
}, { | ||
key: "handlePostDeactivate", | ||
value: function handlePostDeactivate() { | ||
var _this2 = this; | ||
var finishDeactivation = function finishDeactivation() { | ||
var returnFocusNode = _this2.getReturnFocusNode(); | ||
var canReturnFocus = (returnFocusNode === null || returnFocusNode === void 0 ? void 0 : returnFocusNode.focus) && _this2.returnFocusOnDeactivate; | ||
var canReturnFocus = !!( // did the consumer allow it? | ||
_this2.originalOptions.returnFocusOnDeactivate && // can we actually focus the node? | ||
returnFocusNode !== null && returnFocusNode !== void 0 && returnFocusNode.focus && ( // was there an outside click that allowed deactivation? | ||
!_this2.outsideClick || // did the consumer allow deactivation when the outside node was clicked? | ||
_this2.outsideClick.allowDeactivation && // is the outside node NOT focusable (implying that it did NOT receive focus | ||
// as a result of the click-through) -- in which case do NOT restore focus | ||
// to `returnFocusNode` because focus should remain on the outside node | ||
!isFocusable(_this2.outsideClick.target, _this2.internalOptions.tabbableOptions)) // if no, the restore focus to `returnFocusNode` at this point | ||
); | ||
var _this2$internalOption = _this2.internalOptions.preventScroll, | ||
preventScroll = _this2$internalOption === void 0 ? false : _this2$internalOption; | ||
if (canReturnFocus) { | ||
/** Returns focus to the element that had focus when the trap was activated. */ | ||
// return focus to the element that had focus when the trap was activated | ||
returnFocusNode.focus({ | ||
@@ -187,10 +269,13 @@ preventScroll: preventScroll | ||
if (_this2.onPostDeactivate) { | ||
_this2.onPostDeactivate.call(null); // don't call it in context of "this" | ||
if (_this2.originalOptions.onPostDeactivate) { | ||
_this2.originalOptions.onPostDeactivate.call(null); // don't call it in context of "this" | ||
} | ||
_this2.outsideClick = null; // reset: no longer needed | ||
}; | ||
if (checkCanReturnFocus) { | ||
checkCanReturnFocus(this.getReturnFocusNode()).then(finishDeactivation, finishDeactivation); | ||
if (this.originalOptions.checkCanReturnFocus) { | ||
this.originalOptions.checkCanReturnFocus.call(null, this.getReturnFocusNode()) // call out of context | ||
.then(finishDeactivation, finishDeactivation); | ||
} else { | ||
@@ -212,3 +297,3 @@ finishDeactivation(); | ||
// eslint-disable-next-line react/prop-types -- _createFocusTrap is an internal prop | ||
this.focusTrap = this.props._createFocusTrap(focusTrapElementDOMNodes, this.tailoredFocusTrapOptions); | ||
this.focusTrap = this.props._createFocusTrap(focusTrapElementDOMNodes, this.internalOptions); | ||
@@ -215,0 +300,0 @@ if (this.props.active) { |
{ | ||
"name": "focus-trap-react", | ||
"version": "8.10.0", | ||
"version": "8.11.0", | ||
"description": "A React component that traps focus.", | ||
@@ -29,2 +29,3 @@ "main": "dist/focus-trap-react.js", | ||
"test:cypress:ci": "start-server-and-test start 9966 'cypress run --browser $CYPRESS_BROWSER --headless'", | ||
"test:chrome": "CYPRESS_BROWSER=chrome yarn test:cypress:ci", | ||
"test": "yarn format:check && yarn lint && yarn test:unit && yarn test:types && CYPRESS_BROWSER=chrome yarn test:cypress:ci", | ||
@@ -74,9 +75,9 @@ "prepare": "yarn build", | ||
"all-contributors-cli": "^6.20.0", | ||
"babel-jest": "^27.5.1", | ||
"babel-jest": "^28.0.2", | ||
"babelify": "^10.0.0", | ||
"browserify": "^17.0.0", | ||
"budo": "^11.7.0", | ||
"cypress": "^9.5.4", | ||
"cypress": "^9.6.0", | ||
"cypress-plugin-tab": "^1.0.5", | ||
"eslint": "^8.13.0", | ||
"eslint": "^8.14.0", | ||
"eslint-config-prettier": "^8.5.0", | ||
@@ -86,7 +87,7 @@ "eslint-plugin-cypress": "^2.12.1", | ||
"jest": "^27.5.1", | ||
"jest-watch-typeahead": "^1.0.0", | ||
"jest-watch-typeahead": "^1.1.0", | ||
"onchange": "^7.1.0", | ||
"prettier": "^2.6.2", | ||
"prop-types": "^15.8.1", | ||
"react": "^18.0.0", | ||
"react": "^18.1.0", | ||
"react-dom": "^18.0.0", | ||
@@ -98,3 +99,3 @@ "regenerator-runtime": "^0.13.9", | ||
"dependencies": { | ||
"focus-trap": "^6.8.1" | ||
"focus-trap": "^6.9.0" | ||
}, | ||
@@ -101,0 +102,0 @@ "peerDependencies": { |
@@ -71,3 +71,4 @@ # focus-trap-react [![CI](https://github.com/focus-trap/focus-trap-react/workflows/CI/badge.svg?branch=master&event=push)](https://github.com/focus-trap/focus-trap-react/actions?query=workflow:CI+branch:master) [![Codecov](https://img.shields.io/codecov/c/github/focus-trap/focus-trap-react)](https://codecov.io/gh/focus-trap/focus-trap-react) [![license](https://badgen.now.sh/badge/license/MIT)](./LICENSE) | ||
const React = require('react'); | ||
const ReactDOM = require('react-dom'); | ||
const ReactDOM = require('react-dom'); // React 16-17 | ||
const { createRoot } = require('react-dom/client'); // React 18 | ||
const FocusTrap = require('focus-trap-react'); | ||
@@ -136,3 +137,4 @@ | ||
ReactDOM.render(<Demo />, document.getElementById('root')); | ||
ReactDOM.render(<Demo />, document.getElementById('root')); // React 16-17 | ||
createRoot(document.getElementById('root')).render(<Demo />); // React 18 | ||
``` | ||
@@ -139,0 +141,0 @@ |
@@ -5,2 +5,3 @@ const React = require('react'); | ||
const { createFocusTrap } = require('focus-trap'); | ||
const { isFocusable } = require('tabbable'); | ||
@@ -16,15 +17,46 @@ // TODO: These issues are related to older React features which we'll likely need | ||
// We need to hijack the returnFocusOnDeactivate option, | ||
// because React can move focus into the element before we arrived at | ||
// this lifecycle hook (e.g. with autoFocus inputs). So the component | ||
// captures the previouslyFocusedElement in componentWillMount, | ||
// then (optionally) returns focus to it in componentWillUnmount. | ||
this.tailoredFocusTrapOptions = { | ||
this.handleDeactivate = this.handleDeactivate.bind(this); | ||
this.handlePostDeactivate = this.handlePostDeactivate.bind(this); | ||
this.handleClickOutsideDeactivates = | ||
this.handleClickOutsideDeactivates.bind(this); | ||
// focus-trap options used internally when creating the trap | ||
this.internalOptions = { | ||
// We need to hijack the returnFocusOnDeactivate option, | ||
// because React can move focus into the element before we arrived at | ||
// this lifecycle hook (e.g. with autoFocus inputs). So the component | ||
// captures the previouslyFocusedElement in componentWillMount, | ||
// then (optionally) returns focus to it in componentWillUnmount. | ||
returnFocusOnDeactivate: false, | ||
// the rest of these are also related to deactivation of the trap, and we | ||
// need to use them and control them as well | ||
checkCanReturnFocus: null, | ||
onDeactivate: this.handleDeactivate, | ||
onPostDeactivate: this.handlePostDeactivate, | ||
// we need to special-case this setting as well so that we can know if we should | ||
// NOT return focus if the trap gets auto-deactivated as the result of an | ||
// outside click (otherwise, we'll always think we should return focus because | ||
// of how we manage that flag internally here) | ||
clickOutsideDeactivates: this.handleClickOutsideDeactivates, | ||
}; | ||
// because of the above, we maintain our own flag for this option, and | ||
// default it to `true` because that's focus-trap's default | ||
this.returnFocusOnDeactivate = true; | ||
// original options provided by the consumer | ||
this.originalOptions = { | ||
// because of the above `tailoredFocusTrapOptions`, we maintain our own flag for | ||
// this option, and default it to `true` because that's focus-trap's default | ||
returnFocusOnDeactivate: true, | ||
// because of the above `tailoredFocusTrapOptions`, we keep these separate since | ||
// they're part of the deactivation process which we configure (internally) to | ||
// be shared between focus-trap and focus-trap-react | ||
onDeactivate: null, | ||
onPostDeactivate: null, | ||
checkCanReturnFocus: null, | ||
// the user's setting, defaulted to false since focus-trap defaults this to false | ||
clickOutsideDeactivates: false, | ||
}; | ||
const { focusTrapOptions } = props; | ||
@@ -36,15 +68,22 @@ for (const optionName in focusTrapOptions) { | ||
if (optionName === 'returnFocusOnDeactivate') { | ||
this.returnFocusOnDeactivate = !!focusTrapOptions[optionName]; | ||
continue; | ||
if ( | ||
optionName === 'returnFocusOnDeactivate' || | ||
optionName === 'onDeactivate' || | ||
optionName === 'onPostDeactivate' || | ||
optionName === 'checkCanReturnFocus' || | ||
optionName === 'clickOutsideDeactivates' | ||
) { | ||
this.originalOptions[optionName] = focusTrapOptions[optionName]; | ||
continue; // exclude from tailoredFocusTrapOptions | ||
} | ||
if (optionName === 'onPostDeactivate') { | ||
this.onPostDeactivate = focusTrapOptions[optionName]; | ||
continue; | ||
} | ||
this.tailoredFocusTrapOptions[optionName] = focusTrapOptions[optionName]; | ||
this.internalOptions[optionName] = focusTrapOptions[optionName]; | ||
} | ||
// if set, `{ target: Node, allowDeactivation: boolean }` where `target` is the outside | ||
// node that was clicked, and `allowDeactivation` is the result of the consumer's | ||
// option (stored in `this.originalOptions.clickOutsideDeactivates`, which may be a | ||
// function) whether to allow or deny auto-deactivation on click on this outside node | ||
this.outsideClick = null; | ||
// elements from which to create the focus trap on mount; if a child is used | ||
@@ -75,3 +114,3 @@ // instead of the `containerElements` prop, we'll get the child's related | ||
getNodeForOption(optionName) { | ||
const optionValue = this.tailoredFocusTrapOptions[optionName]; | ||
const optionValue = this.internalOptions[optionName]; | ||
if (!optionValue) { | ||
@@ -115,17 +154,82 @@ return null; | ||
deactivateTrap() { | ||
const { checkCanReturnFocus, preventScroll = false } = | ||
this.tailoredFocusTrapOptions; | ||
// NOTE: it's possible the focus trap has already been deactivated without our knowing it, | ||
// especially if the user set the `clickOutsideDeactivates: true` option on the trap, | ||
// and the mouse was clicked on some element outside the trap; at that point, focus-trap | ||
// will initiate its auto-deactivation process, which will call our own | ||
// handleDeactivate(), which will call into this method | ||
if (!this.focusTrap || !this.focusTrap.active) { | ||
return; | ||
} | ||
if (this.focusTrap) { | ||
this.focusTrap.deactivate({ | ||
// NOTE: we never let the trap return the focus since we do that ourselves | ||
this.focusTrap.deactivate({ returnFocus: false }); | ||
returnFocus: false, | ||
// we'll call this in our own post deactivate handler so make sure the trap doesn't | ||
// do it prematurely | ||
checkCanReturnFocus: null, | ||
// let it call the user's original deactivate handler, if any, instead of | ||
// our own which calls back into this function | ||
onDeactivate: this.originalOptions.onDeactivate, | ||
// NOTE: for post deactivate, don't specify anything so that it calls the | ||
// onPostDeactivate handler specified on `this.internalOptions` | ||
// which will always be our own `handlePostDeactivate()` handler, which | ||
// will finish things off by calling the user's provided onPostDeactivate | ||
// handler, if any, at the right time | ||
// onPostDeactivate: NOTHING | ||
}); | ||
} | ||
handleClickOutsideDeactivates(event) { | ||
// use consumer's option (or call their handler) as the permission or denial | ||
const allowDeactivation = | ||
typeof this.originalOptions.clickOutsideDeactivates === 'function' | ||
? this.originalOptions.clickOutsideDeactivates.call(null, event) // call out of context | ||
: this.originalOptions.clickOutsideDeactivates; // boolean | ||
if (allowDeactivation) { | ||
// capture the outside target that was clicked so we can use it in the deactivation | ||
// process since the consumer allowed it to cause auto-deactivation | ||
this.outsideClick = { | ||
target: event.target, | ||
allowDeactivation, | ||
}; | ||
} | ||
return allowDeactivation; | ||
} | ||
handleDeactivate() { | ||
if (this.originalOptions.onDeactivate) { | ||
this.originalOptions.onDeactivate.call(null); // call user's handler out of context | ||
} | ||
this.deactivateTrap(); | ||
} | ||
handlePostDeactivate() { | ||
const finishDeactivation = () => { | ||
const returnFocusNode = this.getReturnFocusNode(); | ||
const canReturnFocus = | ||
returnFocusNode?.focus && this.returnFocusOnDeactivate; | ||
const canReturnFocus = !!( | ||
// did the consumer allow it? | ||
( | ||
this.originalOptions.returnFocusOnDeactivate && | ||
// can we actually focus the node? | ||
returnFocusNode?.focus && | ||
// was there an outside click that allowed deactivation? | ||
(!this.outsideClick || | ||
// did the consumer allow deactivation when the outside node was clicked? | ||
(this.outsideClick.allowDeactivation && | ||
// is the outside node NOT focusable (implying that it did NOT receive focus | ||
// as a result of the click-through) -- in which case do NOT restore focus | ||
// to `returnFocusNode` because focus should remain on the outside node | ||
!isFocusable( | ||
this.outsideClick.target, | ||
this.internalOptions.tabbableOptions | ||
))) | ||
) | ||
// if no, the restore focus to `returnFocusNode` at this point | ||
); | ||
const { preventScroll = false } = this.internalOptions; | ||
if (canReturnFocus) { | ||
/** Returns focus to the element that had focus when the trap was activated. */ | ||
// return focus to the element that had focus when the trap was activated | ||
returnFocusNode.focus({ | ||
@@ -136,12 +240,13 @@ preventScroll, | ||
if (this.onPostDeactivate) { | ||
this.onPostDeactivate.call(null); // don't call it in context of "this" | ||
if (this.originalOptions.onPostDeactivate) { | ||
this.originalOptions.onPostDeactivate.call(null); // don't call it in context of "this" | ||
} | ||
this.outsideClick = null; // reset: no longer needed | ||
}; | ||
if (checkCanReturnFocus) { | ||
checkCanReturnFocus(this.getReturnFocusNode()).then( | ||
finishDeactivation, | ||
finishDeactivation | ||
); | ||
if (this.originalOptions.checkCanReturnFocus) { | ||
this.originalOptions.checkCanReturnFocus | ||
.call(null, this.getReturnFocusNode()) // call out of context | ||
.then(finishDeactivation, finishDeactivation); | ||
} else { | ||
@@ -166,3 +271,3 @@ finishDeactivation(); | ||
focusTrapElementDOMNodes, | ||
this.tailoredFocusTrapOptions | ||
this.internalOptions | ||
); | ||
@@ -169,0 +274,0 @@ |
72314
765
238
Updatedfocus-trap@^6.9.0