focus-trap 6.6.1 → 6.7.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
@@ -82,8 +82,21 @@ 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
+ const doc = userOptions.document || document;
87
100
 
88
101
  const config = {
89
102
  returnFocusOnDeactivate: true,
@@ -125,28 +138,51 @@ const createFocusTrap = function (elements, userOptions) {
125
138
  };
126
139
 
127
140
  const containersContain = function (element) {
128
- return state.containers.some((container) => container.contains(element));
141
+ return !!(
142
+ element &&
143
+ state.containers.some((container) => container.contains(element))
144
+ );
129
145
  };
130
146
 
131
- const getNodeForOption = function (optionName) {
132
- const optionValue = config[optionName];
133
- if (!optionValue) {
134
- return null;
135
- }
147
+ /**
148
+ * Gets the node for the given option, which is expected to be an option that
149
+ * can be either a DOM node, a string that is a selector to get a node, `false`
150
+ * (if a node is explicitly NOT given), or a function that returns any of these
151
+ * values.
152
+ * @param {string} optionName
153
+ * @returns {undefined | false | HTMLElement | SVGElement} Returns
154
+ * `undefined` if the option is not specified; `false` if the option
155
+ * resolved to `false` (node explicitly not given); otherwise, the resolved
156
+ * DOM node.
157
+ * @throws {Error} If the option is set, not `false`, and is not, or does not
158
+ * resolve to a node.
159
+ */
160
+ const getNodeForOption = function (optionName, ...params) {
161
+ let optionValue = config[optionName];
136
162
 
137
- let node = optionValue;
163
+ if (typeof optionValue === 'function') {
164
+ optionValue = optionValue(...params);
165
+ }
138
166
 
139
- if (typeof optionValue === 'string') {
140
- node = doc.querySelector(optionValue);
141
- if (!node) {
142
- throw new Error(`\`${optionName}\` refers to no known node`);
167
+ if (!optionValue) {
168
+ if (optionValue === undefined || optionValue === false) {
169
+ return optionValue;
143
170
  }
171
+ // else, empty string (invalid), null (invalid), 0 (invalid)
172
+
173
+ throw new Error(
174
+ `\`${optionName}\` was specified but was not a node, or did not return a node`
175
+ );
144
176
  }
145
177
 
146
- if (typeof optionValue === 'function') {
147
- node = optionValue();
178
+ let node = optionValue; // could be HTMLElement, SVGElement, or non-empty string at this point
179
+
180
+ if (typeof optionValue === 'string') {
181
+ node = doc.querySelector(optionValue); // resolve to node, or null if fails
148
182
  if (!node) {
149
- throw new Error(`\`${optionName}\` did not return a node`);
183
+ throw new Error(
184
+ `\`${optionName}\` as selector refers to no known node`
185
+ );
150
186
  }
151
187
  }
152
188
 
@@ -154,22 +190,25 @@ const createFocusTrap = function (elements, userOptions) {
154
190
  };
155
191
 
156
192
  const getInitialFocusNode = function () {
157
- let node;
193
+ let node = getNodeForOption('initialFocus');
158
194
 
159
- // false indicates we want no initialFocus at all
160
- if (getOption({}, 'initialFocus') === false) {
195
+ // false explicitly indicates we want no initialFocus at all
196
+ if (node === false) {
161
197
  return false;
162
198
  }
163
199
 
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');
200
+ if (node === undefined) {
201
+ // option not specified: use fallback options
202
+ if (containersContain(doc.activeElement)) {
203
+ node = doc.activeElement;
204
+ } else {
205
+ const firstTabbableGroup = state.tabbableGroups[0];
206
+ const firstTabbableNode =
207
+ firstTabbableGroup && firstTabbableGroup.firstTabbableNode;
208
+
209
+ // NOTE: `fallbackFocus` option function cannot return `false` (not supported)
210
+ node = firstTabbableNode || getNodeForOption('fallbackFocus');
211
+ }
173
212
  }
174
213
 
175
214
  if (!node) {
@@ -201,7 +240,7 @@ const createFocusTrap = function (elements, userOptions) {
201
240
  // throw if no groups have tabbable nodes and we don't have a fallback focus node either
202
241
  if (
203
242
  state.tabbableGroups.length <= 0 &&
204
- !getNodeForOption('fallbackFocus')
243
+ !getNodeForOption('fallbackFocus') // returning false not supported for this option
205
244
  ) {
206
245
  throw new Error(
207
246
  'Your focus-trap must have at least one container with at least one tabbable node in it at all times'
@@ -232,15 +271,16 @@ const createFocusTrap = function (elements, userOptions) {
232
271
  };
233
272
 
234
273
  const getReturnFocusNode = function (previousActiveElement) {
235
- const node = getNodeForOption('setReturnFocus');
236
-
237
- return node ? node : previousActiveElement;
274
+ const node = getNodeForOption('setReturnFocus', previousActiveElement);
275
+ return node ? node : node === false ? false : previousActiveElement;
238
276
  };
239
277
 
240
278
  // This needs to be done on mousedown and touchstart instead of click
241
279
  // so that it precedes the focus event.
242
280
  const checkPointerDown = function (e) {
243
- if (containersContain(e.target)) {
281
+ const target = getActualTarget(e);
282
+
283
+ if (containersContain(target)) {
244
284
  // allow the click since it ocurred inside the trap
245
285
  return;
246
286
  }
@@ -259,7 +299,7 @@ const createFocusTrap = function (elements, userOptions) {
259
299
  // that was clicked, whether it's focusable or not; by setting
260
300
  // `returnFocus: true`, we'll attempt to re-focus the node originally-focused
261
301
  // on activation (or the configured `setReturnFocus` node)
262
- returnFocus: config.returnFocusOnDeactivate && !isFocusable(e.target),
302
+ returnFocus: config.returnFocusOnDeactivate && !isFocusable(target),
263
303
  });
264
304
  return;
265
305
  }
@@ -278,11 +318,13 @@ const createFocusTrap = function (elements, userOptions) {
278
318
 
279
319
  // In case focus escapes the trap for some strange reason, pull it back in.
280
320
  const checkFocusIn = function (e) {
281
- const targetContained = containersContain(e.target);
321
+ const target = getActualTarget(e);
322
+ const targetContained = containersContain(target);
323
+
282
324
  // In Firefox when you Tab out of an iframe the Document is briefly focused.
283
- if (targetContained || e.target instanceof Document) {
325
+ if (targetContained || target instanceof Document) {
284
326
  if (targetContained) {
285
- state.mostRecentlyFocusedNode = e.target;
327
+ state.mostRecentlyFocusedNode = target;
286
328
  }
287
329
  } else {
288
330
  // escaped! pull it back in to where it just left
@@ -296,6 +338,7 @@ const createFocusTrap = function (elements, userOptions) {
296
338
  // moment it can end up scrolling the page and causing confusion so we
297
339
  // kind of need to capture the action at the keydown phase.
298
340
  const checkTab = function (e) {
341
+ const target = getActualTarget(e);
299
342
  updateTabbableNodes();
300
343
 
301
344
  let destinationNode = null;
@@ -305,7 +348,7 @@ const createFocusTrap = function (elements, userOptions) {
305
348
  // NOTE: the target may also be the container itself if it's tabbable
306
349
  // with tabIndex='-1' and was given initial focus
307
350
  const containerIndex = findIndex(state.tabbableGroups, ({ container }) =>
308
- container.contains(e.target)
351
+ container.contains(target)
309
352
  );
310
353
 
311
354
  if (containerIndex < 0) {
@@ -326,12 +369,12 @@ const createFocusTrap = function (elements, userOptions) {
326
369
  // is the target the first tabbable node in a group?
327
370
  let startOfGroupIndex = findIndex(
328
371
  state.tabbableGroups,
329
- ({ firstTabbableNode }) => e.target === firstTabbableNode
372
+ ({ firstTabbableNode }) => target === firstTabbableNode
330
373
  );
331
374
 
332
375
  if (
333
376
  startOfGroupIndex < 0 &&
334
- state.tabbableGroups[containerIndex].container === e.target
377
+ state.tabbableGroups[containerIndex].container === target
335
378
  ) {
336
379
  // an exception case where the target is the container itself, in which
337
380
  // case, we should handle shift+tab as if focus were on the container's
@@ -357,12 +400,12 @@ const createFocusTrap = function (elements, userOptions) {
357
400
  // is the target the last tabbable node in a group?
358
401
  let lastOfGroupIndex = findIndex(
359
402
  state.tabbableGroups,
360
- ({ lastTabbableNode }) => e.target === lastTabbableNode
403
+ ({ lastTabbableNode }) => target === lastTabbableNode
361
404
  );
362
405
 
363
406
  if (
364
407
  lastOfGroupIndex < 0 &&
365
- state.tabbableGroups[containerIndex].container === e.target
408
+ state.tabbableGroups[containerIndex].container === target
366
409
  ) {
367
410
  // an exception case where the target is the container itself, in which
368
411
  // case, we should handle tab as if focus were on the container's
@@ -384,6 +427,7 @@ const createFocusTrap = function (elements, userOptions) {
384
427
  }
385
428
  }
386
429
  } else {
430
+ // NOTE: the fallbackFocus option does not support returning false to opt-out
387
431
  destinationNode = getNodeForOption('fallbackFocus');
388
432
  }
389
433
 
@@ -397,7 +441,7 @@ const createFocusTrap = function (elements, userOptions) {
397
441
  const checkKey = function (e) {
398
442
  if (
399
443
  isEscapeEvent(e) &&
400
- valueOrHandler(config.escapeDeactivates) !== false
444
+ valueOrHandler(config.escapeDeactivates, e) !== false
401
445
  ) {
402
446
  e.preventDefault();
403
447
  trap.deactivate();
@@ -415,7 +459,9 @@ const createFocusTrap = function (elements, userOptions) {
415
459
  return;
416
460
  }
417
461
 
418
- if (containersContain(e.target)) {
462
+ const target = getActualTarget(e);
463
+
464
+ if (containersContain(target)) {
419
465
  return;
420
466
  }
421
467
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "focus-trap",
3
- "version": "6.6.1",
3
+ "version": "6.7.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",
@@ -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.15.7",
69
+ "@babel/core": "^7.15.5",
70
+ "@babel/preset-env": "^7.15.6",
71
+ "@changesets/cli": "^2.17.0",
70
72
  "@rollup/plugin-babel": "^5.3.0",
71
73
  "@rollup/plugin-commonjs": "^20.0.0",
72
- "@rollup/plugin-node-resolve": "^13.0.4",
73
- "@testing-library/cypress": "^8.0.0",
74
+ "@rollup/plugin-node-resolve": "^13.0.5",
75
+ "@testing-library/cypress": "^8.0.1",
74
76
  "@types/jquery": "^3.5.6",
75
77
  "all-contributors-cli": "^6.20.0",
76
78
  "babel-eslint": "^10.1.0",
77
79
  "babel-loader": "^8.2.2",
78
- "babelify": "^10.0.0",
79
- "browserify": "^17.0.0",
80
- "budo": "^11.6.4",
81
80
  "cross-env": "^7.0.3",
82
- "cypress": "^8.2.0",
81
+ "cypress": "^8.4.1",
83
82
  "cypress-plugin-tab": "^1.0.5",
84
83
  "eslint": "^7.32.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.4.1",
88
+ "rollup": "^2.57.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.13.1",
93
- "typescript": "^4.3.5"
94
+ "start-server-and-test": "^1.14.0",
95
+ "typescript": "^4.4.3"
94
96
  }
95
97
  }