focus-trap 6.2.3 → 6.5.1

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.d.ts CHANGED
@@ -4,19 +4,65 @@ declare module 'focus-trap' {
4
4
  * `document.querySelector()` to find the DOM node), or a function that
5
5
  * returns a DOM node.
6
6
  */
7
- export type FocusTarget = HTMLElement | string | { (): HTMLElement };
7
+ export type FocusTarget = HTMLElement | SVGElement | string | { (): HTMLElement | SVGElement };
8
8
 
9
- type MouseEventToBoolean = (event: MouseEvent) => boolean
9
+ type MouseEventToBoolean = (event: MouseEvent | TouchEvent) => boolean
10
10
 
11
11
  export interface Options {
12
12
  /**
13
- * A function that will be called when the focus trap activates.
13
+ * A function that will be called **before** sending focus to the
14
+ * target element upon activation.
14
15
  */
15
16
  onActivate?: () => void;
17
+
18
+ /**
19
+ * A function that will be called **after** focus has been sent to the
20
+ * target element upon activation.
21
+ */
22
+ onPostActivate?: () => void
23
+
24
+ /**
25
+ * A function for determining if it is safe to send focus to the focus trap
26
+ * or not.
27
+ *
28
+ * It should return a promise that only resolves once all the listed `containers`
29
+ * are able to receive focus.
30
+ *
31
+ * The purpose of this is to prevent early focus-trap activation on animated
32
+ * dialogs that fade in and out. When a dialog fades in, there is a brief delay
33
+ * between the activation of the trap and the trap element being focusable.
34
+ */
35
+ checkCanFocusTrap?: (containers: Array<HTMLElement | SVGElement>) => Promise<void>
36
+
16
37
  /**
17
- * A function that will be called when the focus trap deactivates.
38
+ * A function that will be called **before** sending focus to the
39
+ * trigger element upon deactivation.
18
40
  */
19
41
  onDeactivate?: () => void;
42
+
43
+ /**
44
+ * A function that will be called after the trap is deactivated, after `onDeactivate`.
45
+ * If `returnFocus` was set, it will be called **after** focus has been sent to the trigger
46
+ * element upon deactivation; otherwise, it will be called after deactivation completes.
47
+ */
48
+ onPostDeactivate?: () => void
49
+ /**
50
+ * A function for determining if it is safe to send focus back to the `trigger` element.
51
+ *
52
+ * It should return a promise that only resolves once `trigger` is focusable.
53
+ *
54
+ * The purpose of this is to prevent the focus being sent to an animated trigger element too early.
55
+ * If a trigger element fades in upon trap deactivation, there is a brief delay between the deactivation
56
+ * of the trap and when the trigger element is focusable.
57
+ *
58
+ * `trigger` will be either the node that had focus prior to the trap being activated,
59
+ * or the result of the `setReturnFocus` option, if configured.
60
+ *
61
+ * This handler is **not** called if the `returnFocusOnDeactivate` configuration option
62
+ * (or the `returnFocus` deactivation option) is falsy.
63
+ */
64
+ checkCanReturnFocus?: (trigger: HTMLElement | SVGElement) => Promise<void>
65
+
20
66
  /**
21
67
  * By default, when a focus trap is activated the first element in the
22
68
  * focus trap's tab order will receive focus. With this option you can
@@ -51,19 +97,20 @@ declare module 'focus-trap' {
51
97
  */
52
98
  escapeDeactivates?: boolean;
53
99
  /**
54
- * Default: `false`. If `true`, a click outside the focus trap will
55
- * deactivate the focus trap and allow the click event to do its thing.
56
- * This option **takes precedence** over `allowOutsideClick` when it's set
57
- * to `true`.
100
+ * If `true` or returns `true`, a click outside the focus trap will
101
+ * deactivate the focus trap and allow the click event to do its thing (i.e.
102
+ * to pass-through to the element that was clicked). This option **takes
103
+ * precedence** over `allowOutsideClick` when it's set to `true`, causing
104
+ * that option to be ignored. Default: `false`.
58
105
  */
59
- clickOutsideDeactivates?: boolean;
106
+ clickOutsideDeactivates?: boolean | MouseEventToBoolean;
60
107
  /**
61
108
  * If set and is or returns `true`, a click outside the focus trap will not
62
109
  * be prevented, even when `clickOutsideDeactivates` is `false`. When
63
110
  * `clickOutsideDeactivates` is `true`, this option is **ignored** (i.e.
64
111
  * if it's a function, it will not be called). Use this option to control
65
112
  * if (and even which) clicks are allowed outside the trap in conjunction
66
- * with `clickOutsideDeactivates: false`.
113
+ * with `clickOutsideDeactivates: false`. Default: `false`.
67
114
  */
68
115
  allowOutsideClick?: boolean | MouseEventToBoolean;
69
116
  /**
@@ -80,9 +127,9 @@ declare module 'focus-trap' {
80
127
  delayInitialFocus?: boolean;
81
128
  }
82
129
 
83
- type ActivateOptions = Pick<Options, 'onActivate'>;
130
+ type ActivateOptions = Pick<Options, 'onActivate' | 'onPostActivate' | 'checkCanFocusTrap'>;
84
131
 
85
- interface DeactivateOptions extends Pick<Options, 'onDeactivate'> {
132
+ interface DeactivateOptions extends Pick<Options, 'onDeactivate' | 'onPostDeactivate' | 'checkCanReturnFocus'> {
86
133
  returnFocus?: boolean;
87
134
  }
88
135
 
@@ -91,7 +138,7 @@ declare module 'focus-trap' {
91
138
  deactivate(deactivateOptions?: DeactivateOptions): FocusTrap;
92
139
  pause(): FocusTrap;
93
140
  unpause(): FocusTrap;
94
- updateContainerElements(containerElements: HTMLElement | string | Array<HTMLElement | string>): FocusTrap;
141
+ updateContainerElements(containerElements: HTMLElement | SVGElement | string | Array<HTMLElement | SVGElement | string>): FocusTrap;
95
142
  }
96
143
 
97
144
  /**
@@ -102,7 +149,7 @@ declare module 'focus-trap' {
102
149
  * find the element.
103
150
  */
104
151
  export function createFocusTrap(
105
- element: HTMLElement | string | Array<HTMLElement | string>,
152
+ element: HTMLElement | SVGElement | string | Array<HTMLElement | SVGElement | string>,
106
153
  userOptions?: Options
107
154
  ): FocusTrap;
108
155
  }
package/index.js CHANGED
@@ -73,6 +73,17 @@ const findIndex = function (arr, fn) {
73
73
  return idx;
74
74
  };
75
75
 
76
+ /**
77
+ * Get an option's value when it could be a plain value, or a handler that provides
78
+ * the value.
79
+ * @param {*} value Option's value to check.
80
+ * @param {...*} [params] Any parameters to pass to the handler, if `value` is a function.
81
+ * @returns {*} The `value`, or the handler's returned value.
82
+ */
83
+ const valueOrHandler = function (value, ...params) {
84
+ return typeof value === 'function' ? value(...params) : value;
85
+ };
86
+
76
87
  const createFocusTrap = function (elements, userOptions) {
77
88
  const doc = document;
78
89
 
@@ -93,7 +104,7 @@ const createFocusTrap = function (elements, userOptions) {
93
104
  // is active, but the trap should never get to a state where there isn't at least one group
94
105
  // with at least one tabbable node in it (that would lead to an error condition that would
95
106
  // result in an error being thrown)
96
- // @type {Array<{ firstTabbableNode: HTMLElement|null, lastTabbableNode: HTMLElement|null }>}
107
+ // @type {Array<{ container: HTMLElement, firstTabbableNode: HTMLElement|null, lastTabbableNode: HTMLElement|null }>}
97
108
  tabbableGroups: [],
98
109
 
99
110
  nodeFocusedBeforeActivation: null,
@@ -104,6 +115,13 @@ const createFocusTrap = function (elements, userOptions) {
104
115
 
105
116
  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
106
117
 
118
+ const getOption = (configOverrideOptions, optionName, configOptionName) => {
119
+ return configOverrideOptions &&
120
+ configOverrideOptions[optionName] !== undefined
121
+ ? configOverrideOptions[optionName]
122
+ : config[configOptionName || optionName];
123
+ };
124
+
107
125
  const containersContain = function (element) {
108
126
  return state.containers.some((container) => container.contains(element));
109
127
  };
@@ -163,6 +181,7 @@ const createFocusTrap = function (elements, userOptions) {
163
181
 
164
182
  if (tabbableNodes.length > 0) {
165
183
  return {
184
+ container,
166
185
  firstTabbableNode: tabbableNodes[0],
167
186
  lastTabbableNode: tabbableNodes[tabbableNodes.length - 1],
168
187
  };
@@ -214,7 +233,7 @@ const createFocusTrap = function (elements, userOptions) {
214
233
  return;
215
234
  }
216
235
 
217
- if (config.clickOutsideDeactivates) {
236
+ if (valueOrHandler(config.clickOutsideDeactivates, e)) {
218
237
  // immediately deactivate the trap
219
238
  trap.deactivate({
220
239
  // if, on deactivation, we should return focus to the node originally-focused
@@ -236,12 +255,7 @@ const createFocusTrap = function (elements, userOptions) {
236
255
  // This is needed for mobile devices.
237
256
  // (If we'll only let `click` events through,
238
257
  // then on mobile they will be blocked anyways if `touchstart` is blocked.)
239
- if (
240
- config.allowOutsideClick &&
241
- (typeof config.allowOutsideClick === 'boolean'
242
- ? config.allowOutsideClick
243
- : config.allowOutsideClick(e))
244
- ) {
258
+ if (valueOrHandler(config.allowOutsideClick, e)) {
245
259
  // allow the click outside the trap to take place
246
260
  return;
247
261
  }
@@ -275,13 +289,48 @@ const createFocusTrap = function (elements, userOptions) {
275
289
  let destinationNode = null;
276
290
 
277
291
  if (state.tabbableGroups.length > 0) {
278
- if (e.shiftKey) {
279
- const startOfGroupIndex = findIndex(
292
+ // make sure the target is actually contained in a group
293
+ // NOTE: the target may also be the container itself if it's tabbable
294
+ // with tabIndex='-1' and was given initial focus
295
+ const containerIndex = findIndex(state.tabbableGroups, ({ container }) =>
296
+ container.contains(e.target)
297
+ );
298
+
299
+ if (containerIndex < 0) {
300
+ // target not found in any group: quite possible focus has escaped the trap,
301
+ // so bring it back in to...
302
+ if (e.shiftKey) {
303
+ // ...the last node in the last group
304
+ destinationNode =
305
+ state.tabbableGroups[state.tabbableGroups.length - 1]
306
+ .lastTabbableNode;
307
+ } else {
308
+ // ...the first node in the first group
309
+ destinationNode = state.tabbableGroups[0].firstTabbableNode;
310
+ }
311
+ } else if (e.shiftKey) {
312
+ // REVERSE
313
+
314
+ // is the target the first tabbable node in a group?
315
+ let startOfGroupIndex = findIndex(
280
316
  state.tabbableGroups,
281
317
  ({ firstTabbableNode }) => e.target === firstTabbableNode
282
318
  );
283
319
 
320
+ if (
321
+ startOfGroupIndex < 0 &&
322
+ state.tabbableGroups[containerIndex].container === e.target
323
+ ) {
324
+ // an exception case where the target is the container itself, in which
325
+ // case, we should handle shift+tab as if focus were on the container's
326
+ // first tabbable node, and go to the last tabbable node of the LAST group
327
+ startOfGroupIndex = containerIndex;
328
+ }
329
+
284
330
  if (startOfGroupIndex >= 0) {
331
+ // YES: then shift+tab should go to the last tabbable node in the
332
+ // previous group (and wrap around to the last tabbable node of
333
+ // the LAST group if it's the first tabbable node of the FIRST group)
285
334
  const destinationGroupIndex =
286
335
  startOfGroupIndex === 0
287
336
  ? state.tabbableGroups.length - 1
@@ -291,12 +340,28 @@ const createFocusTrap = function (elements, userOptions) {
291
340
  destinationNode = destinationGroup.lastTabbableNode;
292
341
  }
293
342
  } else {
294
- const lastOfGroupIndex = findIndex(
343
+ // FORWARD
344
+
345
+ // is the target the last tabbable node in a group?
346
+ let lastOfGroupIndex = findIndex(
295
347
  state.tabbableGroups,
296
348
  ({ lastTabbableNode }) => e.target === lastTabbableNode
297
349
  );
298
350
 
351
+ if (
352
+ lastOfGroupIndex < 0 &&
353
+ state.tabbableGroups[containerIndex].container === e.target
354
+ ) {
355
+ // an exception case where the target is the container itself, in which
356
+ // case, we should handle tab as if focus were on the container's
357
+ // last tabbable node, and go to the first tabbable node of the FIRST group
358
+ lastOfGroupIndex = containerIndex;
359
+ }
360
+
299
361
  if (lastOfGroupIndex >= 0) {
362
+ // YES: then tab should go to the first tabbable node in the next
363
+ // group (and wrap around to the first tabbable node of the FIRST
364
+ // group if it's the last tabbable node of the LAST group)
300
365
  const destinationGroupIndex =
301
366
  lastOfGroupIndex === state.tabbableGroups.length - 1
302
367
  ? 0
@@ -314,6 +379,7 @@ const createFocusTrap = function (elements, userOptions) {
314
379
  e.preventDefault();
315
380
  tryFocus(destinationNode);
316
381
  }
382
+ // else, let the browser take care of [shift+]tab and move the focus
317
383
  };
318
384
 
319
385
  const checkKey = function (e) {
@@ -330,7 +396,7 @@ const createFocusTrap = function (elements, userOptions) {
330
396
  };
331
397
 
332
398
  const checkClick = function (e) {
333
- if (config.clickOutsideDeactivates) {
399
+ if (valueOrHandler(config.clickOutsideDeactivates, e)) {
334
400
  return;
335
401
  }
336
402
 
@@ -338,12 +404,7 @@ const createFocusTrap = function (elements, userOptions) {
338
404
  return;
339
405
  }
340
406
 
341
- if (
342
- config.allowOutsideClick &&
343
- (typeof config.allowOutsideClick === 'boolean'
344
- ? config.allowOutsideClick
345
- : config.allowOutsideClick(e))
346
- ) {
407
+ if (valueOrHandler(config.allowOutsideClick, e)) {
347
408
  return;
348
409
  }
349
410
 
@@ -416,21 +477,41 @@ const createFocusTrap = function (elements, userOptions) {
416
477
  return this;
417
478
  }
418
479
 
419
- updateTabbableNodes();
480
+ const onActivate = getOption(activateOptions, 'onActivate');
481
+ const onPostActivate = getOption(activateOptions, 'onPostActivate');
482
+ const checkCanFocusTrap = getOption(activateOptions, 'checkCanFocusTrap');
483
+
484
+ if (!checkCanFocusTrap) {
485
+ updateTabbableNodes();
486
+ }
420
487
 
421
488
  state.active = true;
422
489
  state.paused = false;
423
490
  state.nodeFocusedBeforeActivation = doc.activeElement;
424
491
 
425
- const onActivate =
426
- activateOptions && activateOptions.onActivate
427
- ? activateOptions.onActivate
428
- : config.onActivate;
429
492
  if (onActivate) {
430
493
  onActivate();
431
494
  }
432
495
 
433
- addListeners();
496
+ const finishActivation = () => {
497
+ if (checkCanFocusTrap) {
498
+ updateTabbableNodes();
499
+ }
500
+ addListeners();
501
+ if (onPostActivate) {
502
+ onPostActivate();
503
+ }
504
+ };
505
+
506
+ if (checkCanFocusTrap) {
507
+ checkCanFocusTrap(state.containers.concat()).then(
508
+ finishActivation,
509
+ finishActivation
510
+ );
511
+ return this;
512
+ }
513
+
514
+ finishActivation();
434
515
  return this;
435
516
  },
436
517
 
@@ -447,25 +528,42 @@ const createFocusTrap = function (elements, userOptions) {
447
528
 
448
529
  activeFocusTraps.deactivateTrap(trap);
449
530
 
450
- const onDeactivate =
451
- deactivateOptions && deactivateOptions.onDeactivate !== undefined
452
- ? deactivateOptions.onDeactivate
453
- : config.onDeactivate;
531
+ const onDeactivate = getOption(deactivateOptions, 'onDeactivate');
532
+ const onPostDeactivate = getOption(deactivateOptions, 'onPostDeactivate');
533
+ const checkCanReturnFocus = getOption(
534
+ deactivateOptions,
535
+ 'checkCanReturnFocus'
536
+ );
537
+
454
538
  if (onDeactivate) {
455
539
  onDeactivate();
456
540
  }
457
541
 
458
- const returnFocus =
459
- deactivateOptions && deactivateOptions.returnFocus !== undefined
460
- ? deactivateOptions.returnFocus
461
- : config.returnFocusOnDeactivate;
542
+ const returnFocus = getOption(
543
+ deactivateOptions,
544
+ 'returnFocus',
545
+ 'returnFocusOnDeactivate'
546
+ );
462
547
 
463
- if (returnFocus) {
464
- delay(function () {
465
- tryFocus(getReturnFocusNode(state.nodeFocusedBeforeActivation));
548
+ const finishDeactivation = () => {
549
+ delay(() => {
550
+ if (returnFocus) {
551
+ tryFocus(getReturnFocusNode(state.nodeFocusedBeforeActivation));
552
+ }
553
+ if (onPostDeactivate) {
554
+ onPostDeactivate();
555
+ }
466
556
  });
557
+ };
558
+
559
+ if (returnFocus && checkCanReturnFocus) {
560
+ checkCanReturnFocus(
561
+ getReturnFocusNode(state.nodeFocusedBeforeActivation)
562
+ ).then(finishDeactivation, finishDeactivation);
563
+ return this;
467
564
  }
468
565
 
566
+ finishDeactivation();
469
567
  return this;
470
568
  },
471
569
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "focus-trap",
3
- "version": "6.2.3",
3
+ "version": "6.5.1",
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
- "lint": "eslint \"*.js\" \"demo/**/*.js\" \"cypress/**/*.js\"",
20
+ "demo-bundle": "browserify docs/js/index.js -o docs/demo-bundle.js",
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\"",
24
25
  "clean": "rm -rf ./dist",
25
- "compile:esm": "BUILD_ENV=esm BABEL_ENV=esm rollup -c",
26
- "compile:cjs": "BUILD_ENV=cjs BABEL_ENV=es5 rollup -c",
27
- "compile:umd": "BUILD_ENV=umd BABEL_ENV=es5 rollup -c",
26
+ "compile:esm": "cross-env BUILD_ENV=esm BABEL_ENV=esm rollup -c",
27
+ "compile:cjs": "cross-env BUILD_ENV=cjs BABEL_ENV=es5 rollup -c",
28
+ "compile:umd": "cross-env BUILD_ENV=umd BABEL_ENV=es5 rollup -c",
28
29
  "compile": "yarn compile:esm && yarn compile:cjs && yarn compile:umd",
29
30
  "build": "yarn clean && yarn compile",
30
- "start": "yarn compile:cjs && budo demo/js/index.js:demo-bundle.js --dir demo --live -- -t babelify",
31
+ "start": "yarn compile:cjs && budo docs/js/index.js:demo-bundle.js --dir docs --live -- -t babelify",
31
32
  "test:types": "tsc index.d.ts",
32
33
  "test:unit": "echo \"No unit tests to run!\"",
33
34
  "test:cypress": "start-server-and-test start 9966 'cypress open'",
34
- "test:cypress-ci": "start-server-and-test start 9966 'cypress run --browser $CYPRESS_BROWSER --headless'",
35
- "test": "yarn format:check && yarn lint && yarn test:unit && yarn test:types && CYPRESS_BROWSER=chrome yarn test:cypress-ci",
35
+ "test:cypress:ci": "start-server-and-test start 9966 'cypress run --browser $CYPRESS_BROWSER --headless'",
36
+ "test:chrome": "CYPRESS_BROWSER=chrome yarn test:cypress:ci",
37
+ "test": "yarn format:check && yarn lint && yarn test:unit && yarn test:types && CYPRESS_BROWSER=chrome yarn test:cypress:ci",
36
38
  "prepare": "yarn build",
37
39
  "release": "yarn build && changeset publish"
38
40
  },
@@ -58,34 +60,36 @@
58
60
  },
59
61
  "homepage": "https://github.com/focus-trap/focus-trap#readme",
60
62
  "dependencies": {
61
- "tabbable": "^5.1.4"
63
+ "tabbable": "^5.2.0"
62
64
  },
63
65
  "devDependencies": {
64
- "@babel/cli": "^7.12.8",
65
- "@babel/core": "^7.12.9",
66
- "@babel/preset-env": "^7.12.7",
67
- "@changesets/cli": "^2.12.0",
68
- "@rollup/plugin-babel": "^5.2.2",
69
- "@rollup/plugin-commonjs": "^17.0.0",
70
- "@rollup/plugin-node-resolve": "^11.0.0",
71
- "@testing-library/cypress": "^7.0.2",
66
+ "@babel/cli": "^7.14.5",
67
+ "@babel/core": "^7.14.6",
68
+ "@babel/preset-env": "^7.14.5",
69
+ "@changesets/cli": "^2.16.0",
70
+ "@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",
72
74
  "@types/jquery": "^3.5.5",
73
- "all-contributors-cli": "^6.19.0",
75
+ "all-contributors-cli": "^6.20.0",
74
76
  "babel-eslint": "^10.1.0",
75
77
  "babel-loader": "^8.2.2",
76
78
  "babelify": "^10.0.0",
77
79
  "browserify": "^17.0.0",
78
80
  "budo": "^11.6.4",
79
- "cypress": "^6.1.0",
81
+ "cross-env": "^7.0.3",
82
+ "cypress": "^7.5.0",
80
83
  "cypress-plugin-tab": "^1.0.5",
81
- "eslint": "^7.15.0",
82
- "eslint-config-prettier": "^7.0.0",
83
- "eslint-plugin-cypress": "^2.11.2",
84
- "prettier": "^2.2.1",
85
- "rollup": "^2.34.2",
84
+ "eslint": "^7.28.0",
85
+ "eslint-config-prettier": "^8.3.0",
86
+ "eslint-plugin-cypress": "^2.11.3",
87
+ "onchange": "^7.1.0",
88
+ "prettier": "^2.3.1",
89
+ "rollup": "^2.51.2",
86
90
  "rollup-plugin-sourcemaps": "^0.6.3",
87
91
  "rollup-plugin-terser": "^7.0.1",
88
- "start-server-and-test": "^1.11.6",
89
- "typescript": "^4.1.2"
92
+ "start-server-and-test": "^1.12.5",
93
+ "typescript": "^4.3.2"
90
94
  }
91
95
  }