focus-trap 6.5.0 → 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
@@ -1,7 +1,5 @@
1
1
  import { tabbable, isFocusable } from 'tabbable';
2
2
 
3
- let activeFocusDelay;
4
-
5
3
  const activeFocusTraps = (function () {
6
4
  const trapQueue = [];
7
5
  return {
@@ -84,8 +82,21 @@ const valueOrHandler = function (value, ...params) {
84
82
  return typeof value === 'function' ? value(...params) : value;
85
83
  };
86
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
+
87
98
  const createFocusTrap = function (elements, userOptions) {
88
- const doc = document;
99
+ const doc = userOptions.document || document;
89
100
 
90
101
  const config = {
91
102
  returnFocusOnDeactivate: true,
@@ -111,6 +122,10 @@ const createFocusTrap = function (elements, userOptions) {
111
122
  mostRecentlyFocusedNode: null,
112
123
  active: false,
113
124
  paused: false,
125
+
126
+ // timer ID for when delayInitialFocus is true and initial focus in this trap
127
+ // has been delayed during activation
128
+ delayInitialFocusTimer: undefined,
114
129
  };
115
130
 
116
131
  let trap; // eslint-disable-line prefer-const -- some private functions reference it, and its methods reference private functions, so we must declare here and define later
@@ -123,28 +138,51 @@ const createFocusTrap = function (elements, userOptions) {
123
138
  };
124
139
 
125
140
  const containersContain = function (element) {
126
- return state.containers.some((container) => container.contains(element));
141
+ return !!(
142
+ element &&
143
+ state.containers.some((container) => container.contains(element))
144
+ );
127
145
  };
128
146
 
129
- const getNodeForOption = function (optionName) {
130
- const optionValue = config[optionName];
131
- if (!optionValue) {
132
- return null;
133
- }
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];
134
162
 
135
- let node = optionValue;
163
+ if (typeof optionValue === 'function') {
164
+ optionValue = optionValue(...params);
165
+ }
136
166
 
137
- if (typeof optionValue === 'string') {
138
- node = doc.querySelector(optionValue);
139
- if (!node) {
140
- throw new Error(`\`${optionName}\` refers to no known node`);
167
+ if (!optionValue) {
168
+ if (optionValue === undefined || optionValue === false) {
169
+ return optionValue;
141
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
+ );
142
176
  }
143
177
 
144
- if (typeof optionValue === 'function') {
145
- 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
146
182
  if (!node) {
147
- throw new Error(`\`${optionName}\` did not return a node`);
183
+ throw new Error(
184
+ `\`${optionName}\` as selector refers to no known node`
185
+ );
148
186
  }
149
187
  }
150
188
 
@@ -152,17 +190,25 @@ const createFocusTrap = function (elements, userOptions) {
152
190
  };
153
191
 
154
192
  const getInitialFocusNode = function () {
155
- let node;
193
+ let node = getNodeForOption('initialFocus');
156
194
 
157
- if (getNodeForOption('initialFocus') !== null) {
158
- node = getNodeForOption('initialFocus');
159
- } else if (containersContain(doc.activeElement)) {
160
- node = doc.activeElement;
161
- } else {
162
- const firstTabbableGroup = state.tabbableGroups[0];
163
- const firstTabbableNode =
164
- firstTabbableGroup && firstTabbableGroup.firstTabbableNode;
165
- node = firstTabbableNode || getNodeForOption('fallbackFocus');
195
+ // false explicitly indicates we want no initialFocus at all
196
+ if (node === false) {
197
+ return false;
198
+ }
199
+
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
+ }
166
212
  }
167
213
 
168
214
  if (!node) {
@@ -194,7 +240,7 @@ const createFocusTrap = function (elements, userOptions) {
194
240
  // throw if no groups have tabbable nodes and we don't have a fallback focus node either
195
241
  if (
196
242
  state.tabbableGroups.length <= 0 &&
197
- !getNodeForOption('fallbackFocus')
243
+ !getNodeForOption('fallbackFocus') // returning false not supported for this option
198
244
  ) {
199
245
  throw new Error(
200
246
  'Your focus-trap must have at least one container with at least one tabbable node in it at all times'
@@ -203,9 +249,14 @@ const createFocusTrap = function (elements, userOptions) {
203
249
  };
204
250
 
205
251
  const tryFocus = function (node) {
252
+ if (node === false) {
253
+ return;
254
+ }
255
+
206
256
  if (node === doc.activeElement) {
207
257
  return;
208
258
  }
259
+
209
260
  if (!node || !node.focus) {
210
261
  tryFocus(getInitialFocusNode());
211
262
  return;
@@ -220,15 +271,16 @@ const createFocusTrap = function (elements, userOptions) {
220
271
  };
221
272
 
222
273
  const getReturnFocusNode = function (previousActiveElement) {
223
- const node = getNodeForOption('setReturnFocus');
224
-
225
- return node ? node : previousActiveElement;
274
+ const node = getNodeForOption('setReturnFocus', previousActiveElement);
275
+ return node ? node : node === false ? false : previousActiveElement;
226
276
  };
227
277
 
228
278
  // This needs to be done on mousedown and touchstart instead of click
229
279
  // so that it precedes the focus event.
230
280
  const checkPointerDown = function (e) {
231
- if (containersContain(e.target)) {
281
+ const target = getActualTarget(e);
282
+
283
+ if (containersContain(target)) {
232
284
  // allow the click since it ocurred inside the trap
233
285
  return;
234
286
  }
@@ -247,7 +299,7 @@ const createFocusTrap = function (elements, userOptions) {
247
299
  // that was clicked, whether it's focusable or not; by setting
248
300
  // `returnFocus: true`, we'll attempt to re-focus the node originally-focused
249
301
  // on activation (or the configured `setReturnFocus` node)
250
- returnFocus: config.returnFocusOnDeactivate && !isFocusable(e.target),
302
+ returnFocus: config.returnFocusOnDeactivate && !isFocusable(target),
251
303
  });
252
304
  return;
253
305
  }
@@ -266,11 +318,13 @@ const createFocusTrap = function (elements, userOptions) {
266
318
 
267
319
  // In case focus escapes the trap for some strange reason, pull it back in.
268
320
  const checkFocusIn = function (e) {
269
- const targetContained = containersContain(e.target);
321
+ const target = getActualTarget(e);
322
+ const targetContained = containersContain(target);
323
+
270
324
  // In Firefox when you Tab out of an iframe the Document is briefly focused.
271
- if (targetContained || e.target instanceof Document) {
325
+ if (targetContained || target instanceof Document) {
272
326
  if (targetContained) {
273
- state.mostRecentlyFocusedNode = e.target;
327
+ state.mostRecentlyFocusedNode = target;
274
328
  }
275
329
  } else {
276
330
  // escaped! pull it back in to where it just left
@@ -284,6 +338,7 @@ const createFocusTrap = function (elements, userOptions) {
284
338
  // moment it can end up scrolling the page and causing confusion so we
285
339
  // kind of need to capture the action at the keydown phase.
286
340
  const checkTab = function (e) {
341
+ const target = getActualTarget(e);
287
342
  updateTabbableNodes();
288
343
 
289
344
  let destinationNode = null;
@@ -293,7 +348,7 @@ const createFocusTrap = function (elements, userOptions) {
293
348
  // NOTE: the target may also be the container itself if it's tabbable
294
349
  // with tabIndex='-1' and was given initial focus
295
350
  const containerIndex = findIndex(state.tabbableGroups, ({ container }) =>
296
- container.contains(e.target)
351
+ container.contains(target)
297
352
  );
298
353
 
299
354
  if (containerIndex < 0) {
@@ -314,12 +369,12 @@ const createFocusTrap = function (elements, userOptions) {
314
369
  // is the target the first tabbable node in a group?
315
370
  let startOfGroupIndex = findIndex(
316
371
  state.tabbableGroups,
317
- ({ firstTabbableNode }) => e.target === firstTabbableNode
372
+ ({ firstTabbableNode }) => target === firstTabbableNode
318
373
  );
319
374
 
320
375
  if (
321
376
  startOfGroupIndex < 0 &&
322
- state.tabbableGroups[containerIndex].container === e.target
377
+ state.tabbableGroups[containerIndex].container === target
323
378
  ) {
324
379
  // an exception case where the target is the container itself, in which
325
380
  // case, we should handle shift+tab as if focus were on the container's
@@ -345,12 +400,12 @@ const createFocusTrap = function (elements, userOptions) {
345
400
  // is the target the last tabbable node in a group?
346
401
  let lastOfGroupIndex = findIndex(
347
402
  state.tabbableGroups,
348
- ({ lastTabbableNode }) => e.target === lastTabbableNode
403
+ ({ lastTabbableNode }) => target === lastTabbableNode
349
404
  );
350
405
 
351
406
  if (
352
407
  lastOfGroupIndex < 0 &&
353
- state.tabbableGroups[containerIndex].container === e.target
408
+ state.tabbableGroups[containerIndex].container === target
354
409
  ) {
355
410
  // an exception case where the target is the container itself, in which
356
411
  // case, we should handle tab as if focus were on the container's
@@ -372,6 +427,7 @@ const createFocusTrap = function (elements, userOptions) {
372
427
  }
373
428
  }
374
429
  } else {
430
+ // NOTE: the fallbackFocus option does not support returning false to opt-out
375
431
  destinationNode = getNodeForOption('fallbackFocus');
376
432
  }
377
433
 
@@ -383,7 +439,10 @@ const createFocusTrap = function (elements, userOptions) {
383
439
  };
384
440
 
385
441
  const checkKey = function (e) {
386
- if (config.escapeDeactivates !== false && isEscapeEvent(e)) {
442
+ if (
443
+ isEscapeEvent(e) &&
444
+ valueOrHandler(config.escapeDeactivates, e) !== false
445
+ ) {
387
446
  e.preventDefault();
388
447
  trap.deactivate();
389
448
  return;
@@ -400,7 +459,9 @@ const createFocusTrap = function (elements, userOptions) {
400
459
  return;
401
460
  }
402
461
 
403
- if (containersContain(e.target)) {
462
+ const target = getActualTarget(e);
463
+
464
+ if (containersContain(target)) {
404
465
  return;
405
466
  }
406
467
 
@@ -426,7 +487,7 @@ const createFocusTrap = function (elements, userOptions) {
426
487
 
427
488
  // Delay ensures that the focused element doesn't capture the event
428
489
  // that caused the focus trap activation.
429
- activeFocusDelay = config.delayInitialFocus
490
+ state.delayInitialFocusTimer = config.delayInitialFocus
430
491
  ? delay(function () {
431
492
  tryFocus(getInitialFocusNode());
432
493
  })
@@ -520,7 +581,8 @@ const createFocusTrap = function (elements, userOptions) {
520
581
  return this;
521
582
  }
522
583
 
523
- clearTimeout(activeFocusDelay);
584
+ clearTimeout(state.delayInitialFocusTimer); // noop if undefined
585
+ state.delayInitialFocusTimer = undefined;
524
586
 
525
587
  removeListeners();
526
588
  state.active = false;
@@ -546,14 +608,14 @@ const createFocusTrap = function (elements, userOptions) {
546
608
  );
547
609
 
548
610
  const finishDeactivation = () => {
549
- if (returnFocus) {
550
- delay(() => {
611
+ delay(() => {
612
+ if (returnFocus) {
551
613
  tryFocus(getReturnFocusNode(state.nodeFocusedBeforeActivation));
552
- if (onPostDeactivate) {
553
- onPostDeactivate();
554
- }
555
- });
556
- }
614
+ }
615
+ if (onPostDeactivate) {
616
+ onPostDeactivate();
617
+ }
618
+ });
557
619
  };
558
620
 
559
621
  if (returnFocus && checkCanReturnFocus) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "focus-trap",
3
- "version": "6.5.0",
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,22 +17,24 @@
17
17
  "dist"
18
18
  ],
19
19
  "scripts": {
20
- "demo-bundle": "browserify demo/js/index.js -o demo/demo-bundle.js",
21
- "format": "prettier --write \"{*,src/**/*,test/**/*,demo/js/**/*,.github/workflows/*,cypress/**/*}.+(js|yml)\"",
22
- "format:check": "prettier --check \"{*,src/**/*,test/**/*,demo/js/**/*,.github/workflows/*,cypress/**/*}.+(js|yml)\"",
23
- "format:watch": "onchange \"{*,src/**/*,test/**/*,demo/js/**/*,.github/workflows/*,cypress/**/*}.+(js|yml)\" -- prettier --write {{changed}}",
24
- "lint": "eslint \"*.js\" \"demo/**/*.js\" \"cypress/**/*.js\"",
20
+ "demo-bundle": "yarn compile:demo",
21
+ "format": "prettier --write \"{*,src/**/*,test/**/*,docs/js/**/*,.github/workflows/*,cypress/**/*}.+(js|yml)\"",
22
+ "format:check": "prettier --check \"{*,src/**/*,test/**/*,docs/js/**/*,.github/workflows/*,cypress/**/*}.+(js|yml)\"",
23
+ "format:watch": "onchange \"{*,src/**/*,test/**/*,docs/js/**/*,.github/workflows/*,cypress/**/*}.+(js|yml)\" -- prettier --write {{changed}}",
24
+ "lint": "eslint \"*.js\" \"docs/js/**/*.js\" \"cypress/**/*.js\"",
25
25
  "clean": "rm -rf ./dist",
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 demo/js/index.js:demo-bundle.js --dir demo --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.3",
67
- "@babel/core": "^7.14.3",
68
- "@babel/preset-env": "^7.14.4",
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
- "@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",
73
+ "@rollup/plugin-commonjs": "^20.0.0",
74
+ "@rollup/plugin-node-resolve": "^13.0.5",
75
+ "@testing-library/cypress": "^8.0.1",
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": "^7.4.0",
81
+ "cypress": "^8.4.1",
83
82
  "cypress-plugin-tab": "^1.0.5",
84
- "eslint": "^7.27.0",
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.0",
89
- "rollup": "^2.50.5",
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.12.3",
93
- "typescript": "^4.3.2"
94
+ "start-server-and-test": "^1.14.0",
95
+ "typescript": "^4.4.3"
94
96
  }
95
97
  }