focus-trap 6.8.0-beta.0 → 6.8.0

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, isTabbable } from 'tabbable';
1
+ import { tabbable, focusable, isFocusable, isTabbable } from 'tabbable';
2
2
 
3
3
  const activeFocusTraps = (function () {
4
4
  const trapQueue = [];
@@ -108,16 +108,29 @@ const createFocusTrap = function (elements, userOptions) {
108
108
  };
109
109
 
110
110
  const state = {
111
+ // containers given to createFocusTrap()
111
112
  // @type {Array<HTMLElement>}
112
113
  containers: [],
113
114
 
114
- // list of objects identifying the first and last tabbable nodes in all containers/groups in
115
- // the trap
115
+ // list of objects identifying tabbable nodes in `containers` in the trap
116
116
  // NOTE: it's possible that a group has no tabbable nodes if nodes get removed while the trap
117
117
  // is active, but the trap should never get to a state where there isn't at least one group
118
118
  // with at least one tabbable node in it (that would lead to an error condition that would
119
119
  // result in an error being thrown)
120
- // @type {Array<{ container: HTMLElement, firstTabbableNode: HTMLElement|null, lastTabbableNode: HTMLElement|null }>}
120
+ // @type {Array<{
121
+ // container: HTMLElement,
122
+ // tabbableNodes: Array<HTMLElement>, // empty if none
123
+ // focusableNodes: Array<HTMLElement>, // empty if none
124
+ // firstTabbableNode: HTMLElement|null,
125
+ // lastTabbableNode: HTMLElement|null,
126
+ // nextTabbableNode: (node: HTMLElement, forward: boolean) => HTMLElement|undefined
127
+ // }>}
128
+ containerGroups: [], // same order/length as `containers` list
129
+
130
+ // references to objects in `containerGroups`, but only those that actually have
131
+ // tabbable nodes in them
132
+ // NOTE: same order as `containers` and `containerGroups`, but __not necessarily__
133
+ // the same length
121
134
  tabbableGroups: [],
122
135
 
123
136
  nodeFocusedBeforeActivation: null,
@@ -147,10 +160,25 @@ const createFocusTrap = function (elements, userOptions) {
147
160
  : config[configOptionName || optionName];
148
161
  };
149
162
 
150
- const containersContain = function (element) {
151
- return !!(
152
- element &&
153
- state.containers.some((container) => container.contains(element))
163
+ /**
164
+ * Finds the index of the container that contains the element.
165
+ * @param {HTMLElement} element
166
+ * @returns {number} Index of the container in either `state.containers` or
167
+ * `state.containerGroups` (the order/length of these lists are the same); -1
168
+ * if the element isn't found.
169
+ */
170
+ const findContainerIndex = function (element) {
171
+ // NOTE: search `containerGroups` because it's possible a group contains no tabbable
172
+ // nodes, but still contains focusable nodes (e.g. if they all have `tabindex=-1`)
173
+ // and we still need to find the element in there
174
+ return state.containerGroups.findIndex(
175
+ ({ container, tabbableNodes }) =>
176
+ container.contains(element) ||
177
+ // fall back to explicit tabbable search which will take into consideration any
178
+ // web components if the `tabbableOptions.getShadowRoot` option was used for
179
+ // the trap, enabling shadow DOM support in tabbable (`Node.contains()` doesn't
180
+ // look inside web components even if open)
181
+ tabbableNodes.find((node) => node === element)
154
182
  );
155
183
  };
156
184
 
@@ -209,7 +237,7 @@ const createFocusTrap = function (elements, userOptions) {
209
237
 
210
238
  if (node === undefined) {
211
239
  // option not specified: use fallback options
212
- if (containersContain(doc.activeElement)) {
240
+ if (findContainerIndex(doc.activeElement) >= 0) {
213
241
  node = doc.activeElement;
214
242
  } else {
215
243
  const firstTabbableGroup = state.tabbableGroups[0];
@@ -231,23 +259,66 @@ const createFocusTrap = function (elements, userOptions) {
231
259
  };
232
260
 
233
261
  const updateTabbableNodes = function () {
234
- state.tabbableGroups = state.containers
235
- .map((container) => {
236
- const tabbableNodes = tabbable(container, {
237
- getShadowRoot: config.tabbableOptions?.getShadowRoot,
238
- });
262
+ state.containerGroups = state.containers.map((container) => {
263
+ const tabbableNodes = tabbable(container, {
264
+ getShadowRoot: config.tabbableOptions?.getShadowRoot,
265
+ });
239
266
 
240
- if (tabbableNodes.length > 0) {
241
- return {
242
- container,
243
- firstTabbableNode: tabbableNodes[0],
244
- lastTabbableNode: tabbableNodes[tabbableNodes.length - 1],
245
- };
246
- }
267
+ // NOTE: if we have tabbable nodes, we must have focusable nodes; focusable nodes
268
+ // are a superset of tabbable nodes
269
+ const focusableNodes = focusable(container, {
270
+ getShadowRoot: config.tabbableOptions?.getShadowRoot,
271
+ });
247
272
 
248
- return undefined;
249
- })
250
- .filter((group) => !!group); // remove groups with no tabbable nodes
273
+ return {
274
+ container,
275
+ tabbableNodes,
276
+ focusableNodes,
277
+ firstTabbableNode: tabbableNodes.length > 0 ? tabbableNodes[0] : null,
278
+ lastTabbableNode:
279
+ tabbableNodes.length > 0
280
+ ? tabbableNodes[tabbableNodes.length - 1]
281
+ : null,
282
+
283
+ /**
284
+ * Finds the __tabbable__ node that follows the given node in the specified direction,
285
+ * in this container, if any.
286
+ * @param {HTMLElement} node
287
+ * @param {boolean} [forward] True if going in forward tab order; false if going
288
+ * in reverse.
289
+ * @returns {HTMLElement|undefined} The next tabbable node, if any.
290
+ */
291
+ nextTabbableNode(node, forward = true) {
292
+ // NOTE: If tabindex is positive (in order to manipulate the tab order separate
293
+ // from the DOM order), this __will not work__ because the list of focusableNodes,
294
+ // while it contains tabbable nodes, does not sort its nodes in any order other
295
+ // than DOM order, because it can't: Where would you place focusable (but not
296
+ // tabbable) nodes in that order? They have no order, because they aren't tabbale...
297
+ // Support for positive tabindex is already broken and hard to manage (possibly
298
+ // not supportable, TBD), so this isn't going to make things worse than they
299
+ // already are, and at least makes things better for the majority of cases where
300
+ // tabindex is either 0/unset or negative.
301
+ // FYI, positive tabindex issue: https://github.com/focus-trap/focus-trap/issues/375
302
+ const nodeIdx = focusableNodes.findIndex((n) => n === node);
303
+ if (nodeIdx < 0) {
304
+ return undefined;
305
+ }
306
+
307
+ if (forward) {
308
+ return focusableNodes.slice(nodeIdx + 1).find((n) => isTabbable(n));
309
+ }
310
+
311
+ return focusableNodes
312
+ .slice(0, nodeIdx)
313
+ .reverse()
314
+ .find((n) => isTabbable(n));
315
+ },
316
+ };
317
+ });
318
+
319
+ state.tabbableGroups = state.containerGroups.filter(
320
+ (group) => group.tabbableNodes.length > 0
321
+ );
251
322
 
252
323
  // throw if no groups have tabbable nodes and we don't have a fallback focus node either
253
324
  if (
@@ -292,7 +363,7 @@ const createFocusTrap = function (elements, userOptions) {
292
363
  const checkPointerDown = function (e) {
293
364
  const target = getActualTarget(e);
294
365
 
295
- if (containersContain(target)) {
366
+ if (findContainerIndex(target) >= 0) {
296
367
  // allow the click since it ocurred inside the trap
297
368
  return;
298
369
  }
@@ -331,7 +402,7 @@ const createFocusTrap = function (elements, userOptions) {
331
402
  // In case focus escapes the trap for some strange reason, pull it back in.
332
403
  const checkFocusIn = function (e) {
333
404
  const target = getActualTarget(e);
334
- const targetContained = containersContain(target);
405
+ const targetContained = findContainerIndex(target) >= 0;
335
406
 
336
407
  // In Firefox when you Tab out of an iframe the Document is briefly focused.
337
408
  if (targetContained || target instanceof Document) {
@@ -359,9 +430,9 @@ const createFocusTrap = function (elements, userOptions) {
359
430
  // make sure the target is actually contained in a group
360
431
  // NOTE: the target may also be the container itself if it's focusable
361
432
  // with tabIndex='-1' and was given initial focus
362
- const containerIndex = findIndex(state.tabbableGroups, ({ container }) =>
363
- container.contains(target)
364
- );
433
+ const containerIndex = findContainerIndex(target);
434
+ const containerGroup =
435
+ containerIndex >= 0 ? state.containerGroups[containerIndex] : undefined;
365
436
 
366
437
  if (containerIndex < 0) {
367
438
  // target not found in any group: quite possible focus has escaped the trap,
@@ -386,12 +457,15 @@ const createFocusTrap = function (elements, userOptions) {
386
457
 
387
458
  if (
388
459
  startOfGroupIndex < 0 &&
389
- (state.tabbableGroups[containerIndex].container === target ||
390
- (isFocusable(target) && !isTabbable(target)))
460
+ (containerGroup.container === target ||
461
+ (isFocusable(target) &&
462
+ !isTabbable(target) &&
463
+ !containerGroup.nextTabbableNode(target, false)))
391
464
  ) {
392
465
  // an exception case where the target is either the container itself, or
393
466
  // a non-tabbable node that was given focus (i.e. tabindex is negative
394
- // and user clicked on it or node was programmatically given focus), in which
467
+ // and user clicked on it or node was programmatically given focus)
468
+ // and is not followed by any other tabbable node, in which
395
469
  // case, we should handle shift+tab as if focus were on the container's
396
470
  // first tabbable node, and go to the last tabbable node of the LAST group
397
471
  startOfGroupIndex = containerIndex;
@@ -420,12 +494,15 @@ const createFocusTrap = function (elements, userOptions) {
420
494
 
421
495
  if (
422
496
  lastOfGroupIndex < 0 &&
423
- (state.tabbableGroups[containerIndex].container === target ||
424
- (isFocusable(target) && !isTabbable(target)))
497
+ (containerGroup.container === target ||
498
+ (isFocusable(target) &&
499
+ !isTabbable(target) &&
500
+ !containerGroup.nextTabbableNode(target)))
425
501
  ) {
426
502
  // an exception case where the target is the container itself, or
427
503
  // 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), in which
504
+ // and user clicked on it or node was programmatically given focus)
505
+ // and is not followed by any other tabbable node, in which
429
506
  // case, we should handle tab as if focus were on the container's
430
507
  // last tabbable node, and go to the first tabbable node of the FIRST group
431
508
  lastOfGroupIndex = containerIndex;
@@ -479,7 +556,7 @@ const createFocusTrap = function (elements, userOptions) {
479
556
 
480
557
  const target = getActualTarget(e);
481
558
 
482
- if (containersContain(target)) {
559
+ if (findContainerIndex(target) >= 0) {
483
560
  return;
484
561
  }
485
562
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "focus-trap",
3
- "version": "6.8.0-beta.0",
3
+ "version": "6.8.0",
4
4
  "description": "Trap focus within a DOM node.",
5
5
  "main": "dist/focus-trap.js",
6
6
  "module": "dist/focus-trap.esm.js",
@@ -38,6 +38,7 @@
38
38
  "test:chrome": "CYPRESS_BROWSER=chrome yarn test:cypress:ci",
39
39
  "test": "yarn format:check && yarn lint && yarn test:unit && yarn test:types && CYPRESS_BROWSER=chrome yarn test:cypress:ci",
40
40
  "prepare": "yarn build",
41
+ "prepublishOnly": "yarn test && yarn build",
41
42
  "release": "yarn build && changeset publish"
42
43
  },
43
44
  "repository": {
@@ -62,36 +63,36 @@
62
63
  },
63
64
  "homepage": "https://github.com/focus-trap/focus-trap#readme",
64
65
  "dependencies": {
65
- "tabbable": "5.3.0-beta.0"
66
+ "tabbable": "5.3.0"
66
67
  },
67
68
  "devDependencies": {
68
- "@babel/cli": "^7.16.8",
69
- "@babel/core": "^7.16.12",
70
- "@babel/eslint-parser": "^7.16.5",
69
+ "@babel/cli": "^7.17.6",
70
+ "@babel/core": "^7.17.9",
71
+ "@babel/eslint-parser": "^7.17.0",
71
72
  "@babel/preset-env": "^7.16.11",
72
- "@changesets/cli": "^2.20.0",
73
- "@rollup/plugin-babel": "^5.3.0",
74
- "@rollup/plugin-commonjs": "^21.0.1",
75
- "@rollup/plugin-node-resolve": "^13.1.3",
73
+ "@changesets/cli": "^2.22.0",
74
+ "@rollup/plugin-babel": "^5.3.1",
75
+ "@rollup/plugin-commonjs": "^21.1.0",
76
+ "@rollup/plugin-node-resolve": "^13.2.1",
76
77
  "@testing-library/cypress": "^8.0.2",
77
- "@types/jquery": "^3.5.13",
78
+ "@types/jquery": "^3.5.14",
78
79
  "all-contributors-cli": "^6.20.0",
79
- "babel-loader": "^8.2.3",
80
+ "babel-loader": "^8.2.5",
80
81
  "cross-env": "^7.0.3",
81
- "cypress": "^9.3.1",
82
+ "cypress": "^9.5.4",
82
83
  "cypress-plugin-tab": "^1.0.5",
83
- "eslint": "^8.7.0",
84
- "eslint-config-prettier": "^8.3.0",
84
+ "eslint": "^8.13.0",
85
+ "eslint-config-prettier": "^8.5.0",
85
86
  "eslint-plugin-cypress": "^2.12.1",
86
87
  "onchange": "^7.1.0",
87
- "prettier": "^2.5.1",
88
- "rollup": "^2.66.1",
88
+ "prettier": "^2.6.2",
89
+ "rollup": "^2.70.2",
89
90
  "rollup-plugin-inject-process-env": "^1.3.1",
90
91
  "rollup-plugin-livereload": "^2.0.5",
91
92
  "rollup-plugin-serve": "^1.1.0",
92
93
  "rollup-plugin-sourcemaps": "^0.6.3",
93
94
  "rollup-plugin-terser": "^7.0.1",
94
95
  "start-server-and-test": "^1.14.0",
95
- "typescript": "^4.5.5"
96
+ "typescript": "^4.6.3"
96
97
  }
97
98
  }