focus-trap 6.6.1 → 6.7.3

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/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { tabbable, isFocusable } from 'tabbable';
1
+ import { tabbable, focusable, isFocusable, isTabbable } from 'tabbable';
2
2
 
3
3
  const activeFocusTraps = (function () {
4
4
  const trapQueue = [];
@@ -82,8 +82,23 @@ const valueOrHandler = function (value, ...params) {
82
82
  return typeof value === 'function' ? value(...params) : value;
83
83
  };
84
84
 
85
+ const getActualTarget = function (event) {
86
+ // NOTE: If the trap is _inside_ a shadow DOM, event.target will always be the
87
+ // shadow host. However, event.target.composedPath() will be an array of
88
+ // nodes "clicked" from inner-most (the actual element inside the shadow) to
89
+ // outer-most (the host HTML document). If we have access to composedPath(),
90
+ // then use its first element; otherwise, fall back to event.target (and
91
+ // this only works for an _open_ shadow DOM; otherwise,
92
+ // composedPath()[0] === event.target always).
93
+ return event.target.shadowRoot && typeof event.composedPath === 'function'
94
+ ? event.composedPath()[0]
95
+ : event.target;
96
+ };
97
+
85
98
  const createFocusTrap = function (elements, userOptions) {
86
- const doc = document;
99
+ // SSR: a live trap shouldn't be created in this type of environment so this
100
+ // should be safe code to execute if the `document` option isn't specified
101
+ const doc = userOptions?.document || document;
87
102
 
88
103
  const config = {
89
104
  returnFocusOnDeactivate: true,
@@ -102,7 +117,12 @@ const createFocusTrap = function (elements, userOptions) {
102
117
  // is active, but the trap should never get to a state where there isn't at least one group
103
118
  // with at least one tabbable node in it (that would lead to an error condition that would
104
119
  // result in an error being thrown)
105
- // @type {Array<{ container: HTMLElement, firstTabbableNode: HTMLElement|null, lastTabbableNode: HTMLElement|null }>}
120
+ // @type {Array<{
121
+ // container: HTMLElement,
122
+ // firstTabbableNode: HTMLElement|null,
123
+ // lastTabbableNode: HTMLElement|null,
124
+ // nextTabbableNode: (node: HTMLElement, forward: boolean) => HTMLElement|undefined
125
+ // }>}
106
126
  tabbableGroups: [],
107
127
 
108
128
  nodeFocusedBeforeActivation: null,
@@ -125,28 +145,51 @@ const createFocusTrap = function (elements, userOptions) {
125
145
  };
126
146
 
127
147
  const containersContain = function (element) {
128
- return state.containers.some((container) => container.contains(element));
148
+ return !!(
149
+ element &&
150
+ state.containers.some((container) => container.contains(element))
151
+ );
129
152
  };
130
153
 
131
- const getNodeForOption = function (optionName) {
132
- const optionValue = config[optionName];
133
- if (!optionValue) {
134
- return null;
135
- }
154
+ /**
155
+ * Gets the node for the given option, which is expected to be an option that
156
+ * can be either a DOM node, a string that is a selector to get a node, `false`
157
+ * (if a node is explicitly NOT given), or a function that returns any of these
158
+ * values.
159
+ * @param {string} optionName
160
+ * @returns {undefined | false | HTMLElement | SVGElement} Returns
161
+ * `undefined` if the option is not specified; `false` if the option
162
+ * resolved to `false` (node explicitly not given); otherwise, the resolved
163
+ * DOM node.
164
+ * @throws {Error} If the option is set, not `false`, and is not, or does not
165
+ * resolve to a node.
166
+ */
167
+ const getNodeForOption = function (optionName, ...params) {
168
+ let optionValue = config[optionName];
136
169
 
137
- let node = optionValue;
170
+ if (typeof optionValue === 'function') {
171
+ optionValue = optionValue(...params);
172
+ }
138
173
 
139
- if (typeof optionValue === 'string') {
140
- node = doc.querySelector(optionValue);
141
- if (!node) {
142
- throw new Error(`\`${optionName}\` refers to no known node`);
174
+ if (!optionValue) {
175
+ if (optionValue === undefined || optionValue === false) {
176
+ return optionValue;
143
177
  }
178
+ // else, empty string (invalid), null (invalid), 0 (invalid)
179
+
180
+ throw new Error(
181
+ `\`${optionName}\` was specified but was not a node, or did not return a node`
182
+ );
144
183
  }
145
184
 
146
- if (typeof optionValue === 'function') {
147
- node = optionValue();
185
+ let node = optionValue; // could be HTMLElement, SVGElement, or non-empty string at this point
186
+
187
+ if (typeof optionValue === 'string') {
188
+ node = doc.querySelector(optionValue); // resolve to node, or null if fails
148
189
  if (!node) {
149
- throw new Error(`\`${optionName}\` did not return a node`);
190
+ throw new Error(
191
+ `\`${optionName}\` as selector refers to no known node`
192
+ );
150
193
  }
151
194
  }
152
195
 
@@ -154,22 +197,25 @@ const createFocusTrap = function (elements, userOptions) {
154
197
  };
155
198
 
156
199
  const getInitialFocusNode = function () {
157
- let node;
200
+ let node = getNodeForOption('initialFocus');
158
201
 
159
- // false indicates we want no initialFocus at all
160
- if (getOption({}, 'initialFocus') === false) {
202
+ // false explicitly indicates we want no initialFocus at all
203
+ if (node === false) {
161
204
  return false;
162
205
  }
163
206
 
164
- if (getNodeForOption('initialFocus') !== null) {
165
- node = getNodeForOption('initialFocus');
166
- } else if (containersContain(doc.activeElement)) {
167
- node = doc.activeElement;
168
- } else {
169
- const firstTabbableGroup = state.tabbableGroups[0];
170
- const firstTabbableNode =
171
- firstTabbableGroup && firstTabbableGroup.firstTabbableNode;
172
- node = firstTabbableNode || getNodeForOption('fallbackFocus');
207
+ if (node === undefined) {
208
+ // option not specified: use fallback options
209
+ if (containersContain(doc.activeElement)) {
210
+ node = doc.activeElement;
211
+ } else {
212
+ const firstTabbableGroup = state.tabbableGroups[0];
213
+ const firstTabbableNode =
214
+ firstTabbableGroup && firstTabbableGroup.firstTabbableNode;
215
+
216
+ // NOTE: `fallbackFocus` option function cannot return `false` (not supported)
217
+ node = firstTabbableNode || getNodeForOption('fallbackFocus');
218
+ }
173
219
  }
174
220
 
175
221
  if (!node) {
@@ -186,11 +232,46 @@ const createFocusTrap = function (elements, userOptions) {
186
232
  .map((container) => {
187
233
  const tabbableNodes = tabbable(container);
188
234
 
235
+ // NOTE: if we have tabbable nodes, we must have focusable nodes; focusable nodes
236
+ // are a superset of tabbable nodes
237
+ const focusableNodes = focusable(container);
238
+
189
239
  if (tabbableNodes.length > 0) {
190
240
  return {
191
241
  container,
192
242
  firstTabbableNode: tabbableNodes[0],
193
243
  lastTabbableNode: tabbableNodes[tabbableNodes.length - 1],
244
+
245
+ /**
246
+ * Finds the __tabbable__ node that follows the given node in the specified direction,
247
+ * in this container, if any.
248
+ * @param {HTMLElement} node
249
+ * @param {boolean} [forward] True if going in forward tab order; false if going
250
+ * in reverse.
251
+ * @returns {HTMLElement|undefined} The next tabbable node, if any.
252
+ */
253
+ nextTabbableNode(node, forward = true) {
254
+ // NOTE: If tabindex is positive (in order to manipulate the tab order separate
255
+ // from the DOM order), this __will not work__ because the list of focusableNodes,
256
+ // while it contains tabbable nodes, does not sort its nodes in any order other
257
+ // than DOM order, because it can't: Where would you place focusable (but not
258
+ // tabbable) nodes in that order? They have no order, because they aren't tabbale...
259
+ // Support for positive tabindex is already broken and hard to manage (possibly
260
+ // not supportable, TBD), so this isn't going to make things worse than they
261
+ // already are, and at least makes things better for the majority of cases where
262
+ // tabindex is either 0/unset or negative.
263
+ // FYI, positive tabindex issue: https://github.com/focus-trap/focus-trap/issues/375
264
+ const nodeIdx = focusableNodes.findIndex((n) => n === node);
265
+ if (forward) {
266
+ return focusableNodes
267
+ .slice(nodeIdx + 1)
268
+ .find((n) => isTabbable(n));
269
+ }
270
+ return focusableNodes
271
+ .slice(0, nodeIdx)
272
+ .reverse()
273
+ .find((n) => isTabbable(n));
274
+ },
194
275
  };
195
276
  }
196
277
 
@@ -201,7 +282,7 @@ const createFocusTrap = function (elements, userOptions) {
201
282
  // throw if no groups have tabbable nodes and we don't have a fallback focus node either
202
283
  if (
203
284
  state.tabbableGroups.length <= 0 &&
204
- !getNodeForOption('fallbackFocus')
285
+ !getNodeForOption('fallbackFocus') // returning false not supported for this option
205
286
  ) {
206
287
  throw new Error(
207
288
  'Your focus-trap must have at least one container with at least one tabbable node in it at all times'
@@ -232,15 +313,16 @@ const createFocusTrap = function (elements, userOptions) {
232
313
  };
233
314
 
234
315
  const getReturnFocusNode = function (previousActiveElement) {
235
- const node = getNodeForOption('setReturnFocus');
236
-
237
- return node ? node : previousActiveElement;
316
+ const node = getNodeForOption('setReturnFocus', previousActiveElement);
317
+ return node ? node : node === false ? false : previousActiveElement;
238
318
  };
239
319
 
240
320
  // This needs to be done on mousedown and touchstart instead of click
241
321
  // so that it precedes the focus event.
242
322
  const checkPointerDown = function (e) {
243
- if (containersContain(e.target)) {
323
+ const target = getActualTarget(e);
324
+
325
+ if (containersContain(target)) {
244
326
  // allow the click since it ocurred inside the trap
245
327
  return;
246
328
  }
@@ -259,7 +341,7 @@ const createFocusTrap = function (elements, userOptions) {
259
341
  // that was clicked, whether it's focusable or not; by setting
260
342
  // `returnFocus: true`, we'll attempt to re-focus the node originally-focused
261
343
  // on activation (or the configured `setReturnFocus` node)
262
- returnFocus: config.returnFocusOnDeactivate && !isFocusable(e.target),
344
+ returnFocus: config.returnFocusOnDeactivate && !isFocusable(target),
263
345
  });
264
346
  return;
265
347
  }
@@ -278,11 +360,13 @@ const createFocusTrap = function (elements, userOptions) {
278
360
 
279
361
  // In case focus escapes the trap for some strange reason, pull it back in.
280
362
  const checkFocusIn = function (e) {
281
- const targetContained = containersContain(e.target);
363
+ const target = getActualTarget(e);
364
+ const targetContained = containersContain(target);
365
+
282
366
  // In Firefox when you Tab out of an iframe the Document is briefly focused.
283
- if (targetContained || e.target instanceof Document) {
367
+ if (targetContained || target instanceof Document) {
284
368
  if (targetContained) {
285
- state.mostRecentlyFocusedNode = e.target;
369
+ state.mostRecentlyFocusedNode = target;
286
370
  }
287
371
  } else {
288
372
  // escaped! pull it back in to where it just left
@@ -296,17 +380,20 @@ const createFocusTrap = function (elements, userOptions) {
296
380
  // moment it can end up scrolling the page and causing confusion so we
297
381
  // kind of need to capture the action at the keydown phase.
298
382
  const checkTab = function (e) {
383
+ const target = getActualTarget(e);
299
384
  updateTabbableNodes();
300
385
 
301
386
  let destinationNode = null;
302
387
 
303
388
  if (state.tabbableGroups.length > 0) {
304
389
  // make sure the target is actually contained in a group
305
- // NOTE: the target may also be the container itself if it's tabbable
390
+ // NOTE: the target may also be the container itself if it's focusable
306
391
  // with tabIndex='-1' and was given initial focus
307
392
  const containerIndex = findIndex(state.tabbableGroups, ({ container }) =>
308
- container.contains(e.target)
393
+ container.contains(target)
309
394
  );
395
+ const containerGroup =
396
+ containerIndex >= 0 ? state.tabbableGroups[containerIndex] : undefined;
310
397
 
311
398
  if (containerIndex < 0) {
312
399
  // target not found in any group: quite possible focus has escaped the trap,
@@ -326,14 +413,20 @@ const createFocusTrap = function (elements, userOptions) {
326
413
  // is the target the first tabbable node in a group?
327
414
  let startOfGroupIndex = findIndex(
328
415
  state.tabbableGroups,
329
- ({ firstTabbableNode }) => e.target === firstTabbableNode
416
+ ({ firstTabbableNode }) => target === firstTabbableNode
330
417
  );
331
418
 
332
419
  if (
333
420
  startOfGroupIndex < 0 &&
334
- state.tabbableGroups[containerIndex].container === e.target
421
+ (containerGroup.container === target ||
422
+ (isFocusable(target) &&
423
+ !isTabbable(target) &&
424
+ !containerGroup.nextTabbableNode(target, false)))
335
425
  ) {
336
- // an exception case where the target is the container itself, in which
426
+ // an exception case where the target is either the container itself, or
427
+ // a non-tabbable node that was given focus (i.e. tabindex is negative
428
+ // and user clicked on it or node was programmatically given focus)
429
+ // and is not followed by any other tabbable node, in which
337
430
  // case, we should handle shift+tab as if focus were on the container's
338
431
  // first tabbable node, and go to the last tabbable node of the LAST group
339
432
  startOfGroupIndex = containerIndex;
@@ -357,14 +450,20 @@ const createFocusTrap = function (elements, userOptions) {
357
450
  // is the target the last tabbable node in a group?
358
451
  let lastOfGroupIndex = findIndex(
359
452
  state.tabbableGroups,
360
- ({ lastTabbableNode }) => e.target === lastTabbableNode
453
+ ({ lastTabbableNode }) => target === lastTabbableNode
361
454
  );
362
455
 
363
456
  if (
364
457
  lastOfGroupIndex < 0 &&
365
- state.tabbableGroups[containerIndex].container === e.target
458
+ (containerGroup.container === target ||
459
+ (isFocusable(target) &&
460
+ !isTabbable(target) &&
461
+ !containerGroup.nextTabbableNode(target)))
366
462
  ) {
367
- // an exception case where the target is the container itself, in which
463
+ // an exception case where the target is the container itself, or
464
+ // a non-tabbable node that was given focus (i.e. tabindex is negative
465
+ // and user clicked on it or node was programmatically given focus)
466
+ // and is not followed by any other tabbable node, in which
368
467
  // case, we should handle tab as if focus were on the container's
369
468
  // last tabbable node, and go to the first tabbable node of the FIRST group
370
469
  lastOfGroupIndex = containerIndex;
@@ -384,6 +483,7 @@ const createFocusTrap = function (elements, userOptions) {
384
483
  }
385
484
  }
386
485
  } else {
486
+ // NOTE: the fallbackFocus option does not support returning false to opt-out
387
487
  destinationNode = getNodeForOption('fallbackFocus');
388
488
  }
389
489
 
@@ -397,7 +497,7 @@ const createFocusTrap = function (elements, userOptions) {
397
497
  const checkKey = function (e) {
398
498
  if (
399
499
  isEscapeEvent(e) &&
400
- valueOrHandler(config.escapeDeactivates) !== false
500
+ valueOrHandler(config.escapeDeactivates, e) !== false
401
501
  ) {
402
502
  e.preventDefault();
403
503
  trap.deactivate();
@@ -415,7 +515,9 @@ const createFocusTrap = function (elements, userOptions) {
415
515
  return;
416
516
  }
417
517
 
418
- if (containersContain(e.target)) {
518
+ const target = getActualTarget(e);
519
+
520
+ if (containersContain(target)) {
419
521
  return;
420
522
  }
421
523
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "focus-trap",
3
- "version": "6.6.1",
3
+ "version": "6.7.3",
4
4
  "description": "Trap focus within a DOM node.",
5
5
  "main": "dist/focus-trap.js",
6
6
  "module": "dist/focus-trap.esm.js",
@@ -17,7 +17,7 @@
17
17
  "dist"
18
18
  ],
19
19
  "scripts": {
20
- "demo-bundle": "browserify docs/js/index.js -o docs/demo-bundle.js",
20
+ "demo-bundle": "yarn compile:demo",
21
21
  "format": "prettier --write \"{*,src/**/*,test/**/*,docs/js/**/*,.github/workflows/*,cypress/**/*}.+(js|yml)\"",
22
22
  "format:check": "prettier --check \"{*,src/**/*,test/**/*,docs/js/**/*,.github/workflows/*,cypress/**/*}.+(js|yml)\"",
23
23
  "format:watch": "onchange \"{*,src/**/*,test/**/*,docs/js/**/*,.github/workflows/*,cypress/**/*}.+(js|yml)\" -- prettier --write {{changed}}",
@@ -26,13 +26,15 @@
26
26
  "compile:esm": "cross-env BUILD_ENV=esm BABEL_ENV=esm rollup -c",
27
27
  "compile:cjs": "cross-env BUILD_ENV=cjs BABEL_ENV=es5 rollup -c",
28
28
  "compile:umd": "cross-env BUILD_ENV=umd BABEL_ENV=es5 rollup -c",
29
+ "compile:demo": "cross-env BUILD_ENV=demo BABEL_ENV=es5 rollup -c",
29
30
  "compile": "yarn compile:esm && yarn compile:cjs && yarn compile:umd",
30
31
  "build": "yarn clean && yarn compile",
31
- "start": "yarn compile:cjs && budo docs/js/index.js:demo-bundle.js --dir docs --live -- -t babelify",
32
+ "start": "yarn compile:demo --watch --environment SERVE,RELOAD,IS_CYPRESS_ENV:''",
33
+ "start:cypress": "yarn compile:demo --environment SERVE,IS_CYPRESS_ENV:\"$CYPRESS_BROWSER\"",
32
34
  "test:types": "tsc index.d.ts",
33
35
  "test:unit": "echo \"No unit tests to run!\"",
34
- "test:cypress": "start-server-and-test start 9966 'cypress open'",
35
- "test:cypress:ci": "start-server-and-test start 9966 'cypress run --browser $CYPRESS_BROWSER --headless'",
36
+ "test:cypress": "CYPRESS_BROWSER=ANY start-server-and-test start:cypress 9966 'cypress open'",
37
+ "test:cypress:ci": "start-server-and-test start:cypress 9966 'cypress run --browser $CYPRESS_BROWSER --headless'",
36
38
  "test:chrome": "CYPRESS_BROWSER=chrome yarn test:cypress:ci",
37
39
  "test": "yarn format:check && yarn lint && yarn test:unit && yarn test:types && CYPRESS_BROWSER=chrome yarn test:cypress:ci",
38
40
  "prepare": "yarn build",
@@ -63,33 +65,33 @@
63
65
  "tabbable": "^5.2.1"
64
66
  },
65
67
  "devDependencies": {
66
- "@babel/cli": "^7.14.8",
67
- "@babel/core": "^7.15.0",
68
- "@babel/preset-env": "^7.15.0",
69
- "@changesets/cli": "^2.16.0",
68
+ "@babel/cli": "^7.17.0",
69
+ "@babel/core": "^7.17.2",
70
+ "@babel/eslint-parser": "^7.17.0",
71
+ "@babel/preset-env": "^7.16.11",
72
+ "@changesets/cli": "^2.20.0",
70
73
  "@rollup/plugin-babel": "^5.3.0",
71
- "@rollup/plugin-commonjs": "^20.0.0",
72
- "@rollup/plugin-node-resolve": "^13.0.4",
73
- "@testing-library/cypress": "^8.0.0",
74
- "@types/jquery": "^3.5.6",
74
+ "@rollup/plugin-commonjs": "^21.0.1",
75
+ "@rollup/plugin-node-resolve": "^13.1.3",
76
+ "@testing-library/cypress": "^8.0.2",
77
+ "@types/jquery": "^3.5.13",
75
78
  "all-contributors-cli": "^6.20.0",
76
- "babel-eslint": "^10.1.0",
77
- "babel-loader": "^8.2.2",
78
- "babelify": "^10.0.0",
79
- "browserify": "^17.0.0",
80
- "budo": "^11.6.4",
79
+ "babel-loader": "^8.2.3",
81
80
  "cross-env": "^7.0.3",
82
- "cypress": "^8.2.0",
81
+ "cypress": "^9.4.1",
83
82
  "cypress-plugin-tab": "^1.0.5",
84
- "eslint": "^7.32.0",
83
+ "eslint": "^8.8.0",
85
84
  "eslint-config-prettier": "^8.3.0",
86
- "eslint-plugin-cypress": "^2.11.3",
85
+ "eslint-plugin-cypress": "^2.12.1",
87
86
  "onchange": "^7.1.0",
88
- "prettier": "^2.3.2",
89
- "rollup": "^2.56.2",
87
+ "prettier": "^2.5.1",
88
+ "rollup": "^2.67.1",
89
+ "rollup-plugin-inject-process-env": "^1.3.1",
90
+ "rollup-plugin-livereload": "^2.0.5",
91
+ "rollup-plugin-serve": "^1.1.0",
90
92
  "rollup-plugin-sourcemaps": "^0.6.3",
91
93
  "rollup-plugin-terser": "^7.0.1",
92
- "start-server-and-test": "^1.13.1",
93
- "typescript": "^4.3.5"
94
+ "start-server-and-test": "^1.14.0",
95
+ "typescript": "^4.5.5"
94
96
  }
95
97
  }