focus-trap 6.6.0 → 6.7.2

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, 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,
@@ -125,28 +140,51 @@ const createFocusTrap = function (elements, userOptions) {
125
140
  };
126
141
 
127
142
  const containersContain = function (element) {
128
- return state.containers.some((container) => container.contains(element));
143
+ return !!(
144
+ element &&
145
+ state.containers.some((container) => container.contains(element))
146
+ );
129
147
  };
130
148
 
131
- const getNodeForOption = function (optionName) {
132
- const optionValue = config[optionName];
133
- if (!optionValue) {
134
- return null;
135
- }
149
+ /**
150
+ * Gets the node for the given option, which is expected to be an option that
151
+ * can be either a DOM node, a string that is a selector to get a node, `false`
152
+ * (if a node is explicitly NOT given), or a function that returns any of these
153
+ * values.
154
+ * @param {string} optionName
155
+ * @returns {undefined | false | HTMLElement | SVGElement} Returns
156
+ * `undefined` if the option is not specified; `false` if the option
157
+ * resolved to `false` (node explicitly not given); otherwise, the resolved
158
+ * DOM node.
159
+ * @throws {Error} If the option is set, not `false`, and is not, or does not
160
+ * resolve to a node.
161
+ */
162
+ const getNodeForOption = function (optionName, ...params) {
163
+ let optionValue = config[optionName];
136
164
 
137
- let node = optionValue;
165
+ if (typeof optionValue === 'function') {
166
+ optionValue = optionValue(...params);
167
+ }
138
168
 
139
- if (typeof optionValue === 'string') {
140
- node = doc.querySelector(optionValue);
141
- if (!node) {
142
- throw new Error(`\`${optionName}\` refers to no known node`);
169
+ if (!optionValue) {
170
+ if (optionValue === undefined || optionValue === false) {
171
+ return optionValue;
143
172
  }
173
+ // else, empty string (invalid), null (invalid), 0 (invalid)
174
+
175
+ throw new Error(
176
+ `\`${optionName}\` was specified but was not a node, or did not return a node`
177
+ );
144
178
  }
145
179
 
146
- if (typeof optionValue === 'function') {
147
- node = optionValue();
180
+ let node = optionValue; // could be HTMLElement, SVGElement, or non-empty string at this point
181
+
182
+ if (typeof optionValue === 'string') {
183
+ node = doc.querySelector(optionValue); // resolve to node, or null if fails
148
184
  if (!node) {
149
- throw new Error(`\`${optionName}\` did not return a node`);
185
+ throw new Error(
186
+ `\`${optionName}\` as selector refers to no known node`
187
+ );
150
188
  }
151
189
  }
152
190
 
@@ -154,22 +192,25 @@ const createFocusTrap = function (elements, userOptions) {
154
192
  };
155
193
 
156
194
  const getInitialFocusNode = function () {
157
- let node;
195
+ let node = getNodeForOption('initialFocus');
158
196
 
159
- // false indicates we want no initialFocus at all
160
- if (getOption({}, 'initialFocus') === false) {
197
+ // false explicitly indicates we want no initialFocus at all
198
+ if (node === false) {
161
199
  return false;
162
200
  }
163
201
 
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');
202
+ if (node === undefined) {
203
+ // option not specified: use fallback options
204
+ if (containersContain(doc.activeElement)) {
205
+ node = doc.activeElement;
206
+ } else {
207
+ const firstTabbableGroup = state.tabbableGroups[0];
208
+ const firstTabbableNode =
209
+ firstTabbableGroup && firstTabbableGroup.firstTabbableNode;
210
+
211
+ // NOTE: `fallbackFocus` option function cannot return `false` (not supported)
212
+ node = firstTabbableNode || getNodeForOption('fallbackFocus');
213
+ }
173
214
  }
174
215
 
175
216
  if (!node) {
@@ -201,7 +242,7 @@ const createFocusTrap = function (elements, userOptions) {
201
242
  // throw if no groups have tabbable nodes and we don't have a fallback focus node either
202
243
  if (
203
244
  state.tabbableGroups.length <= 0 &&
204
- !getNodeForOption('fallbackFocus')
245
+ !getNodeForOption('fallbackFocus') // returning false not supported for this option
205
246
  ) {
206
247
  throw new Error(
207
248
  'Your focus-trap must have at least one container with at least one tabbable node in it at all times'
@@ -232,15 +273,16 @@ const createFocusTrap = function (elements, userOptions) {
232
273
  };
233
274
 
234
275
  const getReturnFocusNode = function (previousActiveElement) {
235
- const node = getNodeForOption('setReturnFocus');
236
-
237
- return node ? node : previousActiveElement;
276
+ const node = getNodeForOption('setReturnFocus', previousActiveElement);
277
+ return node ? node : node === false ? false : previousActiveElement;
238
278
  };
239
279
 
240
280
  // This needs to be done on mousedown and touchstart instead of click
241
281
  // so that it precedes the focus event.
242
282
  const checkPointerDown = function (e) {
243
- if (containersContain(e.target)) {
283
+ const target = getActualTarget(e);
284
+
285
+ if (containersContain(target)) {
244
286
  // allow the click since it ocurred inside the trap
245
287
  return;
246
288
  }
@@ -259,7 +301,7 @@ const createFocusTrap = function (elements, userOptions) {
259
301
  // that was clicked, whether it's focusable or not; by setting
260
302
  // `returnFocus: true`, we'll attempt to re-focus the node originally-focused
261
303
  // on activation (or the configured `setReturnFocus` node)
262
- returnFocus: config.returnFocusOnDeactivate && !isFocusable(e.target),
304
+ returnFocus: config.returnFocusOnDeactivate && !isFocusable(target),
263
305
  });
264
306
  return;
265
307
  }
@@ -278,11 +320,13 @@ const createFocusTrap = function (elements, userOptions) {
278
320
 
279
321
  // In case focus escapes the trap for some strange reason, pull it back in.
280
322
  const checkFocusIn = function (e) {
281
- const targetContained = containersContain(e.target);
323
+ const target = getActualTarget(e);
324
+ const targetContained = containersContain(target);
325
+
282
326
  // In Firefox when you Tab out of an iframe the Document is briefly focused.
283
- if (targetContained || e.target instanceof Document) {
327
+ if (targetContained || target instanceof Document) {
284
328
  if (targetContained) {
285
- state.mostRecentlyFocusedNode = e.target;
329
+ state.mostRecentlyFocusedNode = target;
286
330
  }
287
331
  } else {
288
332
  // escaped! pull it back in to where it just left
@@ -296,16 +340,17 @@ const createFocusTrap = function (elements, userOptions) {
296
340
  // moment it can end up scrolling the page and causing confusion so we
297
341
  // kind of need to capture the action at the keydown phase.
298
342
  const checkTab = function (e) {
343
+ const target = getActualTarget(e);
299
344
  updateTabbableNodes();
300
345
 
301
346
  let destinationNode = null;
302
347
 
303
348
  if (state.tabbableGroups.length > 0) {
304
349
  // make sure the target is actually contained in a group
305
- // NOTE: the target may also be the container itself if it's tabbable
350
+ // NOTE: the target may also be the container itself if it's focusable
306
351
  // with tabIndex='-1' and was given initial focus
307
352
  const containerIndex = findIndex(state.tabbableGroups, ({ container }) =>
308
- container.contains(e.target)
353
+ container.contains(target)
309
354
  );
310
355
 
311
356
  if (containerIndex < 0) {
@@ -326,14 +371,17 @@ const createFocusTrap = function (elements, userOptions) {
326
371
  // is the target the first tabbable node in a group?
327
372
  let startOfGroupIndex = findIndex(
328
373
  state.tabbableGroups,
329
- ({ firstTabbableNode }) => e.target === firstTabbableNode
374
+ ({ firstTabbableNode }) => target === firstTabbableNode
330
375
  );
331
376
 
332
377
  if (
333
378
  startOfGroupIndex < 0 &&
334
- state.tabbableGroups[containerIndex].container === e.target
379
+ (state.tabbableGroups[containerIndex].container === target ||
380
+ (isFocusable(target) && !isTabbable(target)))
335
381
  ) {
336
- // an exception case where the target is the container itself, in which
382
+ // an exception case where the target is either the container itself, or
383
+ // a non-tabbable node that was given focus (i.e. tabindex is negative
384
+ // and user clicked on it or node was programmatically given focus), in which
337
385
  // case, we should handle shift+tab as if focus were on the container's
338
386
  // first tabbable node, and go to the last tabbable node of the LAST group
339
387
  startOfGroupIndex = containerIndex;
@@ -357,14 +405,17 @@ const createFocusTrap = function (elements, userOptions) {
357
405
  // is the target the last tabbable node in a group?
358
406
  let lastOfGroupIndex = findIndex(
359
407
  state.tabbableGroups,
360
- ({ lastTabbableNode }) => e.target === lastTabbableNode
408
+ ({ lastTabbableNode }) => target === lastTabbableNode
361
409
  );
362
410
 
363
411
  if (
364
412
  lastOfGroupIndex < 0 &&
365
- state.tabbableGroups[containerIndex].container === e.target
413
+ (state.tabbableGroups[containerIndex].container === target ||
414
+ (isFocusable(target) && !isTabbable(target)))
366
415
  ) {
367
- // an exception case where the target is the container itself, in which
416
+ // an exception case where the target is the container itself, or
417
+ // a non-tabbable node that was given focus (i.e. tabindex is negative
418
+ // and user clicked on it or node was programmatically given focus), in which
368
419
  // case, we should handle tab as if focus were on the container's
369
420
  // last tabbable node, and go to the first tabbable node of the FIRST group
370
421
  lastOfGroupIndex = containerIndex;
@@ -384,6 +435,7 @@ const createFocusTrap = function (elements, userOptions) {
384
435
  }
385
436
  }
386
437
  } else {
438
+ // NOTE: the fallbackFocus option does not support returning false to opt-out
387
439
  destinationNode = getNodeForOption('fallbackFocus');
388
440
  }
389
441
 
@@ -397,7 +449,7 @@ const createFocusTrap = function (elements, userOptions) {
397
449
  const checkKey = function (e) {
398
450
  if (
399
451
  isEscapeEvent(e) &&
400
- valueOrHandler(config.escapeDeactivates) !== false
452
+ valueOrHandler(config.escapeDeactivates, e) !== false
401
453
  ) {
402
454
  e.preventDefault();
403
455
  trap.deactivate();
@@ -415,7 +467,9 @@ const createFocusTrap = function (elements, userOptions) {
415
467
  return;
416
468
  }
417
469
 
418
- if (containersContain(e.target)) {
470
+ const target = getActualTarget(e);
471
+
472
+ if (containersContain(target)) {
419
473
  return;
420
474
  }
421
475
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "focus-trap",
3
- "version": "6.6.0",
3
+ "version": "6.7.2",
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",
@@ -60,36 +62,36 @@
60
62
  },
61
63
  "homepage": "https://github.com/focus-trap/focus-trap#readme",
62
64
  "dependencies": {
63
- "tabbable": "^5.2.0"
65
+ "tabbable": "^5.2.1"
64
66
  },
65
67
  "devDependencies": {
66
- "@babel/cli": "^7.14.5",
67
- "@babel/core": "^7.14.6",
68
- "@babel/preset-env": "^7.14.7",
69
- "@changesets/cli": "^2.16.0",
68
+ "@babel/cli": "^7.16.8",
69
+ "@babel/core": "^7.16.7",
70
+ "@babel/eslint-parser": "^7.16.5",
71
+ "@babel/preset-env": "^7.16.8",
72
+ "@changesets/cli": "^2.19.0",
70
73
  "@rollup/plugin-babel": "^5.3.0",
71
- "@rollup/plugin-commonjs": "^19.0.0",
72
- "@rollup/plugin-node-resolve": "^13.0.0",
73
- "@testing-library/cypress": "^7.0.6",
74
- "@types/jquery": "^3.5.5",
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": "^7.6.0",
81
+ "cypress": "^9.2.1",
83
82
  "cypress-plugin-tab": "^1.0.5",
84
- "eslint": "^7.29.0",
83
+ "eslint": "^8.6.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.52.4",
87
+ "prettier": "^2.5.1",
88
+ "rollup": "^2.63.0",
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.12.5",
93
- "typescript": "^4.3.4"
94
+ "start-server-and-test": "^1.14.0",
95
+ "typescript": "^4.5.4"
94
96
  }
95
97
  }