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 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 [`focus-trap`'s `createOptions`](https://github.com/focus-trap/focus-trap#focustrap--createfocustrapelement-createoptions).
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).
@@ -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; // TODO: These issues are related to older React features which we'll likely need
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); // We need to hijack the returnFocusOnDeactivate option,
50
- // because React can move focus into the element before we arrived at
51
- // this lifecycle hook (e.g. with autoFocus inputs). So the component
52
- // captures the previouslyFocusedElement in componentWillMount,
53
- // then (optionally) returns focus to it in componentWillUnmount.
54
-
55
- _this.tailoredFocusTrapOptions = {
56
- returnFocusOnDeactivate: false
57
- }; // because of the above, we maintain our own flag for this option, and
58
- // default it to `true` because that's focus-trap's default
59
-
60
- _this.returnFocusOnDeactivate = true;
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.returnFocusOnDeactivate = !!focusTrapOptions[optionName];
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
- if (optionName === 'onPostDeactivate') {
74
- _this.onPostDeactivate = focusTrapOptions[optionName];
75
- continue;
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
- _this.tailoredFocusTrapOptions[optionName] = focusTrapOptions[optionName];
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.tailoredFocusTrapOptions[optionName];
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
- var _this2 = this;
157
-
158
- var _this$tailoredFocusTr = this.tailoredFocusTrapOptions,
159
- checkCanReturnFocus = _this$tailoredFocusTr.checkCanReturnFocus,
160
- _this$tailoredFocusTr2 = _this$tailoredFocusTr.preventScroll,
161
- preventScroll = _this$tailoredFocusTr2 === void 0 ? false : _this$tailoredFocusTr2;
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
- if (this.focusTrap) {
193
+ this.focusTrap.deactivate({
164
194
  // NOTE: we never let the trap return the focus since we do that ourselves
165
- this.focusTrap.deactivate({
166
- returnFocus: false
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 = (returnFocusNode === null || returnFocusNode === void 0 ? void 0 : returnFocusNode.focus) && _this2.returnFocusOnDeactivate;
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
- /** Returns focus to the element that had focus when the trap was activated. */
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()).then(finishDeactivation, finishDeactivation);
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.tailoredFocusTrapOptions);
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.9.2",
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.0",
59
- "@babel/core": "^7.17.2",
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.16.11",
64
+ "@babel/preset-env": "^7.17.10",
63
65
  "@babel/preset-react": "^7.16.7",
64
- "@changesets/cli": "^2.20.0",
66
+ "@changesets/cli": "^2.22.0",
65
67
  "@testing-library/cypress": "^8.0.2",
66
- "@testing-library/dom": "^8.11.3",
67
- "@testing-library/jest-dom": "^5.16.2",
68
- "@testing-library/react": "^12.1.2",
69
- "@testing-library/user-event": "^13.5.0",
70
- "@types/jquery": "^3.5.13",
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": "^27.5.1",
74
+ "babel-jest": "^28.0.3",
73
75
  "babelify": "^10.0.0",
74
76
  "browserify": "^17.0.0",
75
- "budo": "^11.6.4",
76
- "cypress": "^9.4.1",
77
+ "budo": "^11.7.0",
78
+ "cypress": "^9.6.0",
77
79
  "cypress-plugin-tab": "^1.0.5",
78
- "eslint": "^8.8.0",
79
- "eslint-config-prettier": "^8.3.0",
80
+ "eslint": "^8.14.0",
81
+ "eslint-config-prettier": "^8.5.0",
80
82
  "eslint-plugin-cypress": "^2.12.1",
81
- "eslint-plugin-react": "^7.28.0",
82
- "jest": "^27.5.1",
83
- "jest-watch-typeahead": "^1.0.0",
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.5.1",
89
+ "prettier": "^2.6.2",
86
90
  "prop-types": "^15.8.1",
87
- "react": "^17.0.2",
88
- "react-dom": "^17.0.2",
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.5.5"
95
+ "typescript": "^4.6.4"
92
96
  },
93
97
  "dependencies": {
94
- "focus-trap": "^6.7.3"
98
+ "focus-trap": "^6.9.1"
95
99
  },
96
100
  "peerDependencies": {
97
101
  "prop-types": "^15.8.1",
@@ -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
- // We need to hijack the returnFocusOnDeactivate option,
16
- // because React can move focus into the element before we arrived at
17
- // this lifecycle hook (e.g. with autoFocus inputs). So the component
18
- // captures the previouslyFocusedElement in componentWillMount,
19
- // then (optionally) returns focus to it in componentWillUnmount.
20
- this.tailoredFocusTrapOptions = {
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
- // because of the above, we maintain our own flag for this option, and
25
- // default it to `true` because that's focus-trap's default
26
- this.returnFocusOnDeactivate = true;
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 (optionName === 'returnFocusOnDeactivate') {
35
- this.returnFocusOnDeactivate = !!focusTrapOptions[optionName];
36
- continue;
37
- }
38
-
39
- if (optionName === 'onPostDeactivate') {
40
- this.onPostDeactivate = focusTrapOptions[optionName];
41
- continue;
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.tailoredFocusTrapOptions[optionName] = focusTrapOptions[optionName];
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.tailoredFocusTrapOptions[optionName];
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
- const { checkCanReturnFocus, preventScroll = false } =
112
- this.tailoredFocusTrapOptions;
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
- if (this.focusTrap) {
159
+ this.focusTrap.deactivate({
115
160
  // NOTE: we never let the trap return the focus since we do that ourselves
116
- this.focusTrap.deactivate({ returnFocus: false });
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
- returnFocusNode?.focus && this.returnFocusOnDeactivate;
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
- /** Returns focus to the element that had focus when the trap was activated. */
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
- checkCanReturnFocus(this.getReturnFocusNode()).then(
138
- finishDeactivation,
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.tailoredFocusTrapOptions
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([