focus-trap-react 8.9.2 → 8.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +21 -3
- package/dist/focus-trap-react.js +131 -42
- package/package.json +27 -23
- package/src/focus-trap-react.js +143 -34
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 8.11.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 040813a: Bumps focus-trap to v6.9.1 to pick-up a fix to tabbable in v5.3.2 regarding the `displayCheck=full` (default) option behavior that caused issues with detached nodes.
|
|
8
|
+
|
|
9
|
+
## 8.11.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 7495680: Bump focus-trap to v6.9.0 to get bug fixes and new features to help fix some bugs.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 7495680: Fix onDeactivate, onPostDeactivate, and checkCanReturnFocus options not being called consistently on deactivation.
|
|
18
|
+
- 7495680: Fix focus not being allowed to remain on outside node post-deactivation when `clickOutsideDeactivates` is true or returns true.
|
|
19
|
+
|
|
20
|
+
## 8.10.0
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- 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.
|
|
25
|
+
- ⚠️ 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.
|
|
26
|
+
|
|
3
27
|
## 8.9.2
|
|
4
28
|
|
|
5
29
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -68,7 +68,8 @@ Here's one more simple example:
|
|
|
68
68
|
|
|
69
69
|
```js
|
|
70
70
|
const React = require('react');
|
|
71
|
-
const ReactDOM = require('react-dom');
|
|
71
|
+
const ReactDOM = require('react-dom'); // React 16-17
|
|
72
|
+
const { createRoot } = require('react-dom/client'); // React 18
|
|
72
73
|
const FocusTrap = require('focus-trap-react');
|
|
73
74
|
|
|
74
75
|
class Demo extends React.Component {
|
|
@@ -132,7 +133,8 @@ class Demo extends React.Component {
|
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
ReactDOM.render(<Demo />, document.getElementById('root'));
|
|
136
|
+
ReactDOM.render(<Demo />, document.getElementById('root')); // React 16-17
|
|
137
|
+
createRoot(document.getElementById('root')).render(<Demo />); // React 18
|
|
136
138
|
```
|
|
137
139
|
|
|
138
140
|
### Props
|
|
@@ -141,7 +143,9 @@ ReactDOM.render(<Demo />, document.getElementById('root'));
|
|
|
141
143
|
|
|
142
144
|
Type: `Object`, optional
|
|
143
145
|
|
|
144
|
-
Pass any of the options available in
|
|
146
|
+
Pass any of the options available in focus-trap's [createOptions](https://github.com/focus-trap/focus-trap#createoptions).
|
|
147
|
+
|
|
148
|
+
> ⚠️ See notes about __[testing in JSDom](#testing-in-jsdom)__ (e.g. using Jest) if that's what you currently use.
|
|
145
149
|
|
|
146
150
|
#### active
|
|
147
151
|
|
|
@@ -169,6 +173,20 @@ If `containerElements` is subsequently updated (i.e. after the trap has been cre
|
|
|
169
173
|
|
|
170
174
|
Using `containerElements` does require the use of React refs which, by nature, will require at least one state update in order to get the resolved elements into the prop, resulting in at least one additional render. In the normal case, this is likely more than acceptable, but if you really want to optimize things, then you could consider [using focus-trap directly](https://codesandbox.io/s/focus-trapreact-containerelements-demos-v5ydi) (see `Trap2.js`).
|
|
171
175
|
|
|
176
|
+
## Help
|
|
177
|
+
|
|
178
|
+
### Testing in JSDom
|
|
179
|
+
|
|
180
|
+
> ⚠️ 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).
|
|
181
|
+
>
|
|
182
|
+
> This topic is just here to help with what we know may affect your tests.
|
|
183
|
+
|
|
184
|
+
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.
|
|
185
|
+
|
|
186
|
+
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 `focusTrapOptions.tabbableOptions.displayCheck: 'none'` option.
|
|
187
|
+
|
|
188
|
+
See [Testing focus-trap in JSDom](https://github.com/focus-trap/focus-trap#testing-in-jsdom) for more details.
|
|
189
|
+
|
|
172
190
|
## Contributing
|
|
173
191
|
|
|
174
192
|
See [CONTRIBUTING](CONTRIBUTING.md).
|
package/dist/focus-trap-react.js
CHANGED
|
@@ -29,7 +29,10 @@ var ReactDOM = require('react-dom');
|
|
|
29
29
|
var PropTypes = require('prop-types');
|
|
30
30
|
|
|
31
31
|
var _require = require('focus-trap'),
|
|
32
|
-
createFocusTrap = _require.createFocusTrap;
|
|
32
|
+
createFocusTrap = _require.createFocusTrap;
|
|
33
|
+
|
|
34
|
+
var _require2 = require('tabbable'),
|
|
35
|
+
isFocusable = _require2.isFocusable; // TODO: These issues are related to older React features which we'll likely need
|
|
33
36
|
// to fix in order to move the code forward to the next major version of React.
|
|
34
37
|
// @see https://github.com/davidtheclark/focus-trap-react/issues/77
|
|
35
38
|
|
|
@@ -46,18 +49,43 @@ var FocusTrap = /*#__PURE__*/function (_React$Component) {
|
|
|
46
49
|
|
|
47
50
|
_classCallCheck(this, FocusTrap);
|
|
48
51
|
|
|
49
|
-
_this = _super.call(this, props);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
52
|
+
_this = _super.call(this, props);
|
|
53
|
+
_this.handleDeactivate = _this.handleDeactivate.bind(_assertThisInitialized(_this));
|
|
54
|
+
_this.handlePostDeactivate = _this.handlePostDeactivate.bind(_assertThisInitialized(_this));
|
|
55
|
+
_this.handleClickOutsideDeactivates = _this.handleClickOutsideDeactivates.bind(_assertThisInitialized(_this)); // focus-trap options used internally when creating the trap
|
|
56
|
+
|
|
57
|
+
_this.internalOptions = {
|
|
58
|
+
// We need to hijack the returnFocusOnDeactivate option,
|
|
59
|
+
// because React can move focus into the element before we arrived at
|
|
60
|
+
// this lifecycle hook (e.g. with autoFocus inputs). So the component
|
|
61
|
+
// captures the previouslyFocusedElement in componentWillMount,
|
|
62
|
+
// then (optionally) returns focus to it in componentWillUnmount.
|
|
63
|
+
returnFocusOnDeactivate: false,
|
|
64
|
+
// the rest of these are also related to deactivation of the trap, and we
|
|
65
|
+
// need to use them and control them as well
|
|
66
|
+
checkCanReturnFocus: null,
|
|
67
|
+
onDeactivate: _this.handleDeactivate,
|
|
68
|
+
onPostDeactivate: _this.handlePostDeactivate,
|
|
69
|
+
// we need to special-case this setting as well so that we can know if we should
|
|
70
|
+
// NOT return focus if the trap gets auto-deactivated as the result of an
|
|
71
|
+
// outside click (otherwise, we'll always think we should return focus because
|
|
72
|
+
// of how we manage that flag internally here)
|
|
73
|
+
clickOutsideDeactivates: _this.handleClickOutsideDeactivates
|
|
74
|
+
}; // original options provided by the consumer
|
|
75
|
+
|
|
76
|
+
_this.originalOptions = {
|
|
77
|
+
// because of the above `tailoredFocusTrapOptions`, we maintain our own flag for
|
|
78
|
+
// this option, and default it to `true` because that's focus-trap's default
|
|
79
|
+
returnFocusOnDeactivate: true,
|
|
80
|
+
// because of the above `tailoredFocusTrapOptions`, we keep these separate since
|
|
81
|
+
// they're part of the deactivation process which we configure (internally) to
|
|
82
|
+
// be shared between focus-trap and focus-trap-react
|
|
83
|
+
onDeactivate: null,
|
|
84
|
+
onPostDeactivate: null,
|
|
85
|
+
checkCanReturnFocus: null,
|
|
86
|
+
// the user's setting, defaulted to false since focus-trap defaults this to false
|
|
87
|
+
clickOutsideDeactivates: false
|
|
88
|
+
};
|
|
61
89
|
var focusTrapOptions = props.focusTrapOptions;
|
|
62
90
|
|
|
63
91
|
for (var optionName in focusTrapOptions) {
|
|
@@ -65,22 +93,22 @@ var FocusTrap = /*#__PURE__*/function (_React$Component) {
|
|
|
65
93
|
continue;
|
|
66
94
|
}
|
|
67
95
|
|
|
68
|
-
if (optionName === 'returnFocusOnDeactivate') {
|
|
69
|
-
_this.
|
|
70
|
-
continue;
|
|
96
|
+
if (optionName === 'returnFocusOnDeactivate' || optionName === 'onDeactivate' || optionName === 'onPostDeactivate' || optionName === 'checkCanReturnFocus' || optionName === 'clickOutsideDeactivates') {
|
|
97
|
+
_this.originalOptions[optionName] = focusTrapOptions[optionName];
|
|
98
|
+
continue; // exclude from tailoredFocusTrapOptions
|
|
71
99
|
}
|
|
72
100
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
101
|
+
_this.internalOptions[optionName] = focusTrapOptions[optionName];
|
|
102
|
+
} // if set, `{ target: Node, allowDeactivation: boolean }` where `target` is the outside
|
|
103
|
+
// node that was clicked, and `allowDeactivation` is the result of the consumer's
|
|
104
|
+
// option (stored in `this.originalOptions.clickOutsideDeactivates`, which may be a
|
|
105
|
+
// function) whether to allow or deny auto-deactivation on click on this outside node
|
|
106
|
+
|
|
77
107
|
|
|
78
|
-
|
|
79
|
-
} // elements from which to create the focus trap on mount; if a child is used
|
|
108
|
+
_this.outsideClick = null; // elements from which to create the focus trap on mount; if a child is used
|
|
80
109
|
// instead of the `containerElements` prop, we'll get the child's related
|
|
81
110
|
// element when the trap renders and then is declared 'mounted'
|
|
82
111
|
|
|
83
|
-
|
|
84
112
|
_this.focusTrapElements = props.containerElements || []; // now we remember what the currently focused element is, not relying on focus-trap
|
|
85
113
|
|
|
86
114
|
_this.updatePreviousElement();
|
|
@@ -105,7 +133,7 @@ var FocusTrap = /*#__PURE__*/function (_React$Component) {
|
|
|
105
133
|
}, {
|
|
106
134
|
key: "getNodeForOption",
|
|
107
135
|
value: function getNodeForOption(optionName) {
|
|
108
|
-
var optionValue = this.
|
|
136
|
+
var optionValue = this.internalOptions[optionName];
|
|
109
137
|
|
|
110
138
|
if (!optionValue) {
|
|
111
139
|
return null;
|
|
@@ -153,40 +181,97 @@ var FocusTrap = /*#__PURE__*/function (_React$Component) {
|
|
|
153
181
|
}, {
|
|
154
182
|
key: "deactivateTrap",
|
|
155
183
|
value: function deactivateTrap() {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
184
|
+
// NOTE: it's possible the focus trap has already been deactivated without our knowing it,
|
|
185
|
+
// especially if the user set the `clickOutsideDeactivates: true` option on the trap,
|
|
186
|
+
// and the mouse was clicked on some element outside the trap; at that point, focus-trap
|
|
187
|
+
// will initiate its auto-deactivation process, which will call our own
|
|
188
|
+
// handleDeactivate(), which will call into this method
|
|
189
|
+
if (!this.focusTrap || !this.focusTrap.active) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
162
192
|
|
|
163
|
-
|
|
193
|
+
this.focusTrap.deactivate({
|
|
164
194
|
// NOTE: we never let the trap return the focus since we do that ourselves
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
195
|
+
returnFocus: false,
|
|
196
|
+
// we'll call this in our own post deactivate handler so make sure the trap doesn't
|
|
197
|
+
// do it prematurely
|
|
198
|
+
checkCanReturnFocus: null,
|
|
199
|
+
// let it call the user's original deactivate handler, if any, instead of
|
|
200
|
+
// our own which calls back into this function
|
|
201
|
+
onDeactivate: this.originalOptions.onDeactivate // NOTE: for post deactivate, don't specify anything so that it calls the
|
|
202
|
+
// onPostDeactivate handler specified on `this.internalOptions`
|
|
203
|
+
// which will always be our own `handlePostDeactivate()` handler, which
|
|
204
|
+
// will finish things off by calling the user's provided onPostDeactivate
|
|
205
|
+
// handler, if any, at the right time
|
|
206
|
+
// onPostDeactivate: NOTHING
|
|
207
|
+
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}, {
|
|
211
|
+
key: "handleClickOutsideDeactivates",
|
|
212
|
+
value: function handleClickOutsideDeactivates(event) {
|
|
213
|
+
// use consumer's option (or call their handler) as the permission or denial
|
|
214
|
+
var allowDeactivation = typeof this.originalOptions.clickOutsideDeactivates === 'function' ? this.originalOptions.clickOutsideDeactivates.call(null, event) // call out of context
|
|
215
|
+
: this.originalOptions.clickOutsideDeactivates; // boolean
|
|
216
|
+
|
|
217
|
+
if (allowDeactivation) {
|
|
218
|
+
// capture the outside target that was clicked so we can use it in the deactivation
|
|
219
|
+
// process since the consumer allowed it to cause auto-deactivation
|
|
220
|
+
this.outsideClick = {
|
|
221
|
+
target: event.target,
|
|
222
|
+
allowDeactivation: allowDeactivation
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return allowDeactivation;
|
|
227
|
+
}
|
|
228
|
+
}, {
|
|
229
|
+
key: "handleDeactivate",
|
|
230
|
+
value: function handleDeactivate() {
|
|
231
|
+
if (this.originalOptions.onDeactivate) {
|
|
232
|
+
this.originalOptions.onDeactivate.call(null); // call user's handler out of context
|
|
168
233
|
}
|
|
169
234
|
|
|
235
|
+
this.deactivateTrap();
|
|
236
|
+
}
|
|
237
|
+
}, {
|
|
238
|
+
key: "handlePostDeactivate",
|
|
239
|
+
value: function handlePostDeactivate() {
|
|
240
|
+
var _this2 = this;
|
|
241
|
+
|
|
170
242
|
var finishDeactivation = function finishDeactivation() {
|
|
171
243
|
var returnFocusNode = _this2.getReturnFocusNode();
|
|
172
244
|
|
|
173
|
-
var canReturnFocus = (
|
|
245
|
+
var canReturnFocus = !!( // did the consumer allow it?
|
|
246
|
+
_this2.originalOptions.returnFocusOnDeactivate && // can we actually focus the node?
|
|
247
|
+
returnFocusNode !== null && returnFocusNode !== void 0 && returnFocusNode.focus && ( // was there an outside click that allowed deactivation?
|
|
248
|
+
!_this2.outsideClick || // did the consumer allow deactivation when the outside node was clicked?
|
|
249
|
+
_this2.outsideClick.allowDeactivation && // is the outside node NOT focusable (implying that it did NOT receive focus
|
|
250
|
+
// as a result of the click-through) -- in which case do NOT restore focus
|
|
251
|
+
// to `returnFocusNode` because focus should remain on the outside node
|
|
252
|
+
!isFocusable(_this2.outsideClick.target, _this2.internalOptions.tabbableOptions)) // if no, the restore focus to `returnFocusNode` at this point
|
|
253
|
+
);
|
|
254
|
+
var _this2$internalOption = _this2.internalOptions.preventScroll,
|
|
255
|
+
preventScroll = _this2$internalOption === void 0 ? false : _this2$internalOption;
|
|
174
256
|
|
|
175
257
|
if (canReturnFocus) {
|
|
176
|
-
|
|
258
|
+
// return focus to the element that had focus when the trap was activated
|
|
177
259
|
returnFocusNode.focus({
|
|
178
260
|
preventScroll: preventScroll
|
|
179
261
|
});
|
|
180
262
|
}
|
|
181
263
|
|
|
182
|
-
if (_this2.onPostDeactivate) {
|
|
183
|
-
_this2.onPostDeactivate.call(null); // don't call it in context of "this"
|
|
264
|
+
if (_this2.originalOptions.onPostDeactivate) {
|
|
265
|
+
_this2.originalOptions.onPostDeactivate.call(null); // don't call it in context of "this"
|
|
184
266
|
|
|
185
267
|
}
|
|
268
|
+
|
|
269
|
+
_this2.outsideClick = null; // reset: no longer needed
|
|
186
270
|
};
|
|
187
271
|
|
|
188
|
-
if (checkCanReturnFocus) {
|
|
189
|
-
checkCanReturnFocus(this.getReturnFocusNode())
|
|
272
|
+
if (this.originalOptions.checkCanReturnFocus) {
|
|
273
|
+
this.originalOptions.checkCanReturnFocus.call(null, this.getReturnFocusNode()) // call out of context
|
|
274
|
+
.then(finishDeactivation, finishDeactivation);
|
|
190
275
|
} else {
|
|
191
276
|
finishDeactivation();
|
|
192
277
|
}
|
|
@@ -203,7 +288,7 @@ var FocusTrap = /*#__PURE__*/function (_React$Component) {
|
|
|
203
288
|
|
|
204
289
|
if (nodesExist) {
|
|
205
290
|
// eslint-disable-next-line react/prop-types -- _createFocusTrap is an internal prop
|
|
206
|
-
this.focusTrap = this.props._createFocusTrap(focusTrapElementDOMNodes, this.
|
|
291
|
+
this.focusTrap = this.props._createFocusTrap(focusTrapElementDOMNodes, this.internalOptions);
|
|
207
292
|
|
|
208
293
|
if (this.props.active) {
|
|
209
294
|
this.focusTrap.activate();
|
|
@@ -339,7 +424,11 @@ FocusTrap.propTypes = {
|
|
|
339
424
|
returnFocusOnDeactivate: PropTypes.bool,
|
|
340
425
|
setReturnFocus: PropTypes.oneOfType([PropTypes.instanceOf(ElementType), PropTypes.string, PropTypes.func]),
|
|
341
426
|
allowOutsideClick: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]),
|
|
342
|
-
preventScroll: PropTypes.bool
|
|
427
|
+
preventScroll: PropTypes.bool,
|
|
428
|
+
tabbableOptions: PropTypes.shape({
|
|
429
|
+
displayCheck: PropTypes.oneOf(['full', 'non-zero-area', 'none']),
|
|
430
|
+
getShadowRoot: PropTypes.oneOfType([PropTypes.bool, PropTypes.func])
|
|
431
|
+
})
|
|
343
432
|
}),
|
|
344
433
|
containerElements: PropTypes.arrayOf(PropTypes.instanceOf(ElementType)),
|
|
345
434
|
children: PropTypes.oneOfType([PropTypes.element, // React element
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "focus-trap-react",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.11.1",
|
|
4
4
|
"description": "A React component that traps focus.",
|
|
5
5
|
"main": "dist/focus-trap-react.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -26,8 +26,10 @@
|
|
|
26
26
|
"test:coverage": "jest --coverage",
|
|
27
27
|
"test:cypress": "start-server-and-test start 9966 'cypress open'",
|
|
28
28
|
"test:cypress:ci": "start-server-and-test start 9966 'cypress run --browser $CYPRESS_BROWSER --headless'",
|
|
29
|
+
"test:chrome": "CYPRESS_BROWSER=chrome yarn test:cypress:ci",
|
|
29
30
|
"test": "yarn format:check && yarn lint && yarn test:unit && yarn test:types && CYPRESS_BROWSER=chrome yarn test:cypress:ci",
|
|
30
31
|
"prepare": "yarn build",
|
|
32
|
+
"prepublishOnly": "yarn test && yarn build",
|
|
31
33
|
"release": "yarn build && changeset publish"
|
|
32
34
|
},
|
|
33
35
|
"repository": {
|
|
@@ -55,43 +57,45 @@
|
|
|
55
57
|
},
|
|
56
58
|
"homepage": "https://github.com/focus-trap/focus-trap-react#readme",
|
|
57
59
|
"devDependencies": {
|
|
58
|
-
"@babel/cli": "^7.17.
|
|
59
|
-
"@babel/core": "^7.17.
|
|
60
|
+
"@babel/cli": "^7.17.10",
|
|
61
|
+
"@babel/core": "^7.17.10",
|
|
60
62
|
"@babel/eslint-parser": "^7.17.0",
|
|
61
63
|
"@babel/plugin-proposal-class-properties": "^7.16.5",
|
|
62
|
-
"@babel/preset-env": "^7.
|
|
64
|
+
"@babel/preset-env": "^7.17.10",
|
|
63
65
|
"@babel/preset-react": "^7.16.7",
|
|
64
|
-
"@changesets/cli": "^2.
|
|
66
|
+
"@changesets/cli": "^2.22.0",
|
|
65
67
|
"@testing-library/cypress": "^8.0.2",
|
|
66
|
-
"@testing-library/dom": "^8.
|
|
67
|
-
"@testing-library/jest-dom": "^5.16.
|
|
68
|
-
"@testing-library/react": "^
|
|
69
|
-
"@testing-library/user-event": "^
|
|
70
|
-
"@types/jquery": "^3.5.
|
|
68
|
+
"@testing-library/dom": "^8.13.0",
|
|
69
|
+
"@testing-library/jest-dom": "^5.16.4",
|
|
70
|
+
"@testing-library/react": "^13.2.0",
|
|
71
|
+
"@testing-library/user-event": "^14.1.1",
|
|
72
|
+
"@types/jquery": "^3.5.14",
|
|
71
73
|
"all-contributors-cli": "^6.20.0",
|
|
72
|
-
"babel-jest": "^
|
|
74
|
+
"babel-jest": "^28.0.3",
|
|
73
75
|
"babelify": "^10.0.0",
|
|
74
76
|
"browserify": "^17.0.0",
|
|
75
|
-
"budo": "^11.
|
|
76
|
-
"cypress": "^9.
|
|
77
|
+
"budo": "^11.7.0",
|
|
78
|
+
"cypress": "^9.6.0",
|
|
77
79
|
"cypress-plugin-tab": "^1.0.5",
|
|
78
|
-
"eslint": "^8.
|
|
79
|
-
"eslint-config-prettier": "^8.
|
|
80
|
+
"eslint": "^8.14.0",
|
|
81
|
+
"eslint-config-prettier": "^8.5.0",
|
|
80
82
|
"eslint-plugin-cypress": "^2.12.1",
|
|
81
|
-
"eslint-plugin-
|
|
82
|
-
"
|
|
83
|
-
"jest
|
|
83
|
+
"eslint-plugin-jest": "^26.1.5",
|
|
84
|
+
"eslint-plugin-react": "^7.29.4",
|
|
85
|
+
"jest": "^28.0.3",
|
|
86
|
+
"jest-environment-jsdom": "^28.0.2",
|
|
87
|
+
"jest-watch-typeahead": "^1.1.0",
|
|
84
88
|
"onchange": "^7.1.0",
|
|
85
|
-
"prettier": "^2.
|
|
89
|
+
"prettier": "^2.6.2",
|
|
86
90
|
"prop-types": "^15.8.1",
|
|
87
|
-
"react": "^
|
|
88
|
-
"react-dom": "^
|
|
91
|
+
"react": "^18.1.0",
|
|
92
|
+
"react-dom": "^18.1.0",
|
|
89
93
|
"regenerator-runtime": "^0.13.9",
|
|
90
94
|
"start-server-and-test": "^1.14.0",
|
|
91
|
-
"typescript": "^4.
|
|
95
|
+
"typescript": "^4.6.4"
|
|
92
96
|
},
|
|
93
97
|
"dependencies": {
|
|
94
|
-
"focus-trap": "^6.
|
|
98
|
+
"focus-trap": "^6.9.1"
|
|
95
99
|
},
|
|
96
100
|
"peerDependencies": {
|
|
97
101
|
"prop-types": "^15.8.1",
|
package/src/focus-trap-react.js
CHANGED
|
@@ -2,6 +2,7 @@ const React = require('react');
|
|
|
2
2
|
const ReactDOM = require('react-dom');
|
|
3
3
|
const PropTypes = require('prop-types');
|
|
4
4
|
const { createFocusTrap } = require('focus-trap');
|
|
5
|
+
const { isFocusable } = require('tabbable');
|
|
5
6
|
|
|
6
7
|
// TODO: These issues are related to older React features which we'll likely need
|
|
7
8
|
// to fix in order to move the code forward to the next major version of React.
|
|
@@ -12,18 +13,49 @@ class FocusTrap extends React.Component {
|
|
|
12
13
|
constructor(props) {
|
|
13
14
|
super(props);
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
this.handleDeactivate = this.handleDeactivate.bind(this);
|
|
17
|
+
this.handlePostDeactivate = this.handlePostDeactivate.bind(this);
|
|
18
|
+
this.handleClickOutsideDeactivates =
|
|
19
|
+
this.handleClickOutsideDeactivates.bind(this);
|
|
20
|
+
|
|
21
|
+
// focus-trap options used internally when creating the trap
|
|
22
|
+
this.internalOptions = {
|
|
23
|
+
// We need to hijack the returnFocusOnDeactivate option,
|
|
24
|
+
// because React can move focus into the element before we arrived at
|
|
25
|
+
// this lifecycle hook (e.g. with autoFocus inputs). So the component
|
|
26
|
+
// captures the previouslyFocusedElement in componentWillMount,
|
|
27
|
+
// then (optionally) returns focus to it in componentWillUnmount.
|
|
21
28
|
returnFocusOnDeactivate: false,
|
|
29
|
+
|
|
30
|
+
// the rest of these are also related to deactivation of the trap, and we
|
|
31
|
+
// need to use them and control them as well
|
|
32
|
+
checkCanReturnFocus: null,
|
|
33
|
+
onDeactivate: this.handleDeactivate,
|
|
34
|
+
onPostDeactivate: this.handlePostDeactivate,
|
|
35
|
+
|
|
36
|
+
// we need to special-case this setting as well so that we can know if we should
|
|
37
|
+
// NOT return focus if the trap gets auto-deactivated as the result of an
|
|
38
|
+
// outside click (otherwise, we'll always think we should return focus because
|
|
39
|
+
// of how we manage that flag internally here)
|
|
40
|
+
clickOutsideDeactivates: this.handleClickOutsideDeactivates,
|
|
22
41
|
};
|
|
23
42
|
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
43
|
+
// original options provided by the consumer
|
|
44
|
+
this.originalOptions = {
|
|
45
|
+
// because of the above `tailoredFocusTrapOptions`, we maintain our own flag for
|
|
46
|
+
// this option, and default it to `true` because that's focus-trap's default
|
|
47
|
+
returnFocusOnDeactivate: true,
|
|
48
|
+
|
|
49
|
+
// because of the above `tailoredFocusTrapOptions`, we keep these separate since
|
|
50
|
+
// they're part of the deactivation process which we configure (internally) to
|
|
51
|
+
// be shared between focus-trap and focus-trap-react
|
|
52
|
+
onDeactivate: null,
|
|
53
|
+
onPostDeactivate: null,
|
|
54
|
+
checkCanReturnFocus: null,
|
|
55
|
+
|
|
56
|
+
// the user's setting, defaulted to false since focus-trap defaults this to false
|
|
57
|
+
clickOutsideDeactivates: false,
|
|
58
|
+
};
|
|
27
59
|
|
|
28
60
|
const { focusTrapOptions } = props;
|
|
29
61
|
for (const optionName in focusTrapOptions) {
|
|
@@ -31,19 +63,26 @@ class FocusTrap extends React.Component {
|
|
|
31
63
|
continue;
|
|
32
64
|
}
|
|
33
65
|
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
66
|
+
if (
|
|
67
|
+
optionName === 'returnFocusOnDeactivate' ||
|
|
68
|
+
optionName === 'onDeactivate' ||
|
|
69
|
+
optionName === 'onPostDeactivate' ||
|
|
70
|
+
optionName === 'checkCanReturnFocus' ||
|
|
71
|
+
optionName === 'clickOutsideDeactivates'
|
|
72
|
+
) {
|
|
73
|
+
this.originalOptions[optionName] = focusTrapOptions[optionName];
|
|
74
|
+
continue; // exclude from tailoredFocusTrapOptions
|
|
42
75
|
}
|
|
43
76
|
|
|
44
|
-
this.
|
|
77
|
+
this.internalOptions[optionName] = focusTrapOptions[optionName];
|
|
45
78
|
}
|
|
46
79
|
|
|
80
|
+
// if set, `{ target: Node, allowDeactivation: boolean }` where `target` is the outside
|
|
81
|
+
// node that was clicked, and `allowDeactivation` is the result of the consumer's
|
|
82
|
+
// option (stored in `this.originalOptions.clickOutsideDeactivates`, which may be a
|
|
83
|
+
// function) whether to allow or deny auto-deactivation on click on this outside node
|
|
84
|
+
this.outsideClick = null;
|
|
85
|
+
|
|
47
86
|
// elements from which to create the focus trap on mount; if a child is used
|
|
48
87
|
// instead of the `containerElements` prop, we'll get the child's related
|
|
49
88
|
// element when the trap renders and then is declared 'mounted'
|
|
@@ -69,7 +108,7 @@ class FocusTrap extends React.Component {
|
|
|
69
108
|
|
|
70
109
|
// TODO: Need more test coverage for this function
|
|
71
110
|
getNodeForOption(optionName) {
|
|
72
|
-
const optionValue = this.
|
|
111
|
+
const optionValue = this.internalOptions[optionName];
|
|
73
112
|
if (!optionValue) {
|
|
74
113
|
return null;
|
|
75
114
|
}
|
|
@@ -108,36 +147,102 @@ class FocusTrap extends React.Component {
|
|
|
108
147
|
}
|
|
109
148
|
|
|
110
149
|
deactivateTrap() {
|
|
111
|
-
|
|
112
|
-
|
|
150
|
+
// NOTE: it's possible the focus trap has already been deactivated without our knowing it,
|
|
151
|
+
// especially if the user set the `clickOutsideDeactivates: true` option on the trap,
|
|
152
|
+
// and the mouse was clicked on some element outside the trap; at that point, focus-trap
|
|
153
|
+
// will initiate its auto-deactivation process, which will call our own
|
|
154
|
+
// handleDeactivate(), which will call into this method
|
|
155
|
+
if (!this.focusTrap || !this.focusTrap.active) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
113
158
|
|
|
114
|
-
|
|
159
|
+
this.focusTrap.deactivate({
|
|
115
160
|
// NOTE: we never let the trap return the focus since we do that ourselves
|
|
116
|
-
|
|
161
|
+
returnFocus: false,
|
|
162
|
+
// we'll call this in our own post deactivate handler so make sure the trap doesn't
|
|
163
|
+
// do it prematurely
|
|
164
|
+
checkCanReturnFocus: null,
|
|
165
|
+
// let it call the user's original deactivate handler, if any, instead of
|
|
166
|
+
// our own which calls back into this function
|
|
167
|
+
onDeactivate: this.originalOptions.onDeactivate,
|
|
168
|
+
// NOTE: for post deactivate, don't specify anything so that it calls the
|
|
169
|
+
// onPostDeactivate handler specified on `this.internalOptions`
|
|
170
|
+
// which will always be our own `handlePostDeactivate()` handler, which
|
|
171
|
+
// will finish things off by calling the user's provided onPostDeactivate
|
|
172
|
+
// handler, if any, at the right time
|
|
173
|
+
// onPostDeactivate: NOTHING
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
handleClickOutsideDeactivates(event) {
|
|
178
|
+
// use consumer's option (or call their handler) as the permission or denial
|
|
179
|
+
const allowDeactivation =
|
|
180
|
+
typeof this.originalOptions.clickOutsideDeactivates === 'function'
|
|
181
|
+
? this.originalOptions.clickOutsideDeactivates.call(null, event) // call out of context
|
|
182
|
+
: this.originalOptions.clickOutsideDeactivates; // boolean
|
|
183
|
+
|
|
184
|
+
if (allowDeactivation) {
|
|
185
|
+
// capture the outside target that was clicked so we can use it in the deactivation
|
|
186
|
+
// process since the consumer allowed it to cause auto-deactivation
|
|
187
|
+
this.outsideClick = {
|
|
188
|
+
target: event.target,
|
|
189
|
+
allowDeactivation,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return allowDeactivation;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
handleDeactivate() {
|
|
197
|
+
if (this.originalOptions.onDeactivate) {
|
|
198
|
+
this.originalOptions.onDeactivate.call(null); // call user's handler out of context
|
|
117
199
|
}
|
|
200
|
+
this.deactivateTrap();
|
|
201
|
+
}
|
|
118
202
|
|
|
203
|
+
handlePostDeactivate() {
|
|
119
204
|
const finishDeactivation = () => {
|
|
120
205
|
const returnFocusNode = this.getReturnFocusNode();
|
|
121
|
-
const canReturnFocus =
|
|
122
|
-
|
|
206
|
+
const canReturnFocus = !!(
|
|
207
|
+
// did the consumer allow it?
|
|
208
|
+
(
|
|
209
|
+
this.originalOptions.returnFocusOnDeactivate &&
|
|
210
|
+
// can we actually focus the node?
|
|
211
|
+
returnFocusNode?.focus &&
|
|
212
|
+
// was there an outside click that allowed deactivation?
|
|
213
|
+
(!this.outsideClick ||
|
|
214
|
+
// did the consumer allow deactivation when the outside node was clicked?
|
|
215
|
+
(this.outsideClick.allowDeactivation &&
|
|
216
|
+
// is the outside node NOT focusable (implying that it did NOT receive focus
|
|
217
|
+
// as a result of the click-through) -- in which case do NOT restore focus
|
|
218
|
+
// to `returnFocusNode` because focus should remain on the outside node
|
|
219
|
+
!isFocusable(
|
|
220
|
+
this.outsideClick.target,
|
|
221
|
+
this.internalOptions.tabbableOptions
|
|
222
|
+
)))
|
|
223
|
+
)
|
|
224
|
+
// if no, the restore focus to `returnFocusNode` at this point
|
|
225
|
+
);
|
|
226
|
+
const { preventScroll = false } = this.internalOptions;
|
|
123
227
|
|
|
124
228
|
if (canReturnFocus) {
|
|
125
|
-
|
|
229
|
+
// return focus to the element that had focus when the trap was activated
|
|
126
230
|
returnFocusNode.focus({
|
|
127
231
|
preventScroll,
|
|
128
232
|
});
|
|
129
233
|
}
|
|
130
234
|
|
|
131
|
-
if (this.onPostDeactivate) {
|
|
132
|
-
this.onPostDeactivate.call(null); // don't call it in context of "this"
|
|
235
|
+
if (this.originalOptions.onPostDeactivate) {
|
|
236
|
+
this.originalOptions.onPostDeactivate.call(null); // don't call it in context of "this"
|
|
133
237
|
}
|
|
238
|
+
|
|
239
|
+
this.outsideClick = null; // reset: no longer needed
|
|
134
240
|
};
|
|
135
241
|
|
|
136
|
-
if (checkCanReturnFocus) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
finishDeactivation
|
|
140
|
-
);
|
|
242
|
+
if (this.originalOptions.checkCanReturnFocus) {
|
|
243
|
+
this.originalOptions.checkCanReturnFocus
|
|
244
|
+
.call(null, this.getReturnFocusNode()) // call out of context
|
|
245
|
+
.then(finishDeactivation, finishDeactivation);
|
|
141
246
|
} else {
|
|
142
247
|
finishDeactivation();
|
|
143
248
|
}
|
|
@@ -157,7 +262,7 @@ class FocusTrap extends React.Component {
|
|
|
157
262
|
// eslint-disable-next-line react/prop-types -- _createFocusTrap is an internal prop
|
|
158
263
|
this.focusTrap = this.props._createFocusTrap(
|
|
159
264
|
focusTrapElementDOMNodes,
|
|
160
|
-
this.
|
|
265
|
+
this.internalOptions
|
|
161
266
|
);
|
|
162
267
|
|
|
163
268
|
if (this.props.active) {
|
|
@@ -311,6 +416,10 @@ FocusTrap.propTypes = {
|
|
|
311
416
|
]),
|
|
312
417
|
allowOutsideClick: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]),
|
|
313
418
|
preventScroll: PropTypes.bool,
|
|
419
|
+
tabbableOptions: PropTypes.shape({
|
|
420
|
+
displayCheck: PropTypes.oneOf(['full', 'non-zero-area', 'none']),
|
|
421
|
+
getShadowRoot: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]),
|
|
422
|
+
}),
|
|
314
423
|
}),
|
|
315
424
|
containerElements: PropTypes.arrayOf(PropTypes.instanceOf(ElementType)),
|
|
316
425
|
children: PropTypes.oneOfType([
|