focus-trap 8.1.0 → 8.2.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.js CHANGED
@@ -128,6 +128,7 @@ const createFocusTrap = function (elements, userOptions) {
128
128
  returnFocusOnDeactivate: true,
129
129
  escapeDeactivates: true,
130
130
  delayInitialFocus: true,
131
+ delayReturnFocus: true,
131
132
  isolateSubtrees: false,
132
133
  isKeyForward,
133
134
  isKeyBackward,
@@ -300,6 +301,31 @@ const createFocusTrap = function (elements, userOptions) {
300
301
  return node;
301
302
  };
302
303
 
304
+ /**
305
+ * Gets the current activeElement. If it's a web-component and has open shadow-root
306
+ * it will recursively search inside shadow roots for the "true" activeElement.
307
+ *
308
+ * @param {Document | ShadowRoot} el
309
+ *
310
+ * @returns {HTMLElement|null} The element that currently has the focus. `null` if a focused element isn't found.
311
+ **/
312
+ const getActiveElement = function (el) {
313
+ const activeElement = el.activeElement;
314
+
315
+ if (!activeElement) {
316
+ return null;
317
+ }
318
+
319
+ if (
320
+ activeElement.shadowRoot &&
321
+ activeElement.shadowRoot.activeElement !== null
322
+ ) {
323
+ return getActiveElement(activeElement.shadowRoot);
324
+ }
325
+
326
+ return activeElement;
327
+ };
328
+
303
329
  const getInitialFocusNode = function () {
304
330
  let node = getNodeForOption('initialFocus', { hasFallback: true });
305
331
 
@@ -312,9 +338,11 @@ const createFocusTrap = function (elements, userOptions) {
312
338
  node === undefined ||
313
339
  (node && !isFocusable(node, config.tabbableOptions))
314
340
  ) {
341
+ const activeElement = getActiveElement(doc);
342
+
315
343
  // option not specified nor focusable: use fallback options
316
- if (findContainerIndex(doc.activeElement) >= 0) {
317
- node = doc.activeElement;
344
+ if (findContainerIndex(activeElement) >= 0) {
345
+ node = activeElement;
318
346
  } else {
319
347
  const firstTabbableGroup = state.tabbableGroups[0];
320
348
  const firstTabbableNode =
@@ -456,31 +484,6 @@ const createFocusTrap = function (elements, userOptions) {
456
484
  }
457
485
  };
458
486
 
459
- /**
460
- * Gets the current activeElement. If it's a web-component and has open shadow-root
461
- * it will recursively search inside shadow roots for the "true" activeElement.
462
- *
463
- * @param {Document | ShadowRoot} el
464
- *
465
- * @returns {HTMLElement} The element that currently has the focus
466
- **/
467
- const getActiveElement = function (el) {
468
- const activeElement = el.activeElement;
469
-
470
- if (!activeElement) {
471
- return;
472
- }
473
-
474
- if (
475
- activeElement.shadowRoot &&
476
- activeElement.shadowRoot.activeElement !== null
477
- ) {
478
- return getActiveElement(activeElement.shadowRoot);
479
- }
480
-
481
- return activeElement;
482
- };
483
-
484
487
  const tryFocus = function (node) {
485
488
  if (node === false) {
486
489
  return;
@@ -850,12 +853,13 @@ const createFocusTrap = function (elements, userOptions) {
850
853
  /**
851
854
  * Adds listeners to the document necessary for trapping focus and attempts to set focus
852
855
  * to the configured initial focus node. Does nothing if the trap isn't active.
853
- * @returns {Promise<void>} Resolved (always) once the initial focus node has been focused.
854
- * Also resolved if the trap isn't active.
856
+ * @returns {Promise<void> | undefined} A promise resolved once the initial focus node has
857
+ * been focused when `delayInitialFocus=true`; `undefined` when focus is set synchronously
858
+ * or the trap isn't active.
855
859
  */
856
860
  const addListeners = function () {
857
861
  if (!state.active) {
858
- return Promise.resolve();
862
+ return;
859
863
  }
860
864
 
861
865
  // There can be only one listening focus trap at a time
@@ -863,7 +867,7 @@ const createFocusTrap = function (elements, userOptions) {
863
867
 
864
868
  // Delay ensures that the focused element doesn't capture the event
865
869
  // that caused the focus trap activation.
866
- /** @type {Promise<void>} */
870
+ /** @type {Promise<void> | undefined} */
867
871
  let promise;
868
872
  if (config.delayInitialFocus) {
869
873
  // NOTE: Promise constructor callback is called synchronously, which is what we want
@@ -875,7 +879,6 @@ const createFocusTrap = function (elements, userOptions) {
875
879
  });
876
880
  });
877
881
  } else {
878
- promise = Promise.resolve();
879
882
  tryFocus(getInitialFocusNode());
880
883
  }
881
884
 
@@ -979,17 +982,33 @@ const createFocusTrap = function (elements, userOptions) {
979
982
  //
980
983
 
981
984
  const checkDomRemoval = function (mutations) {
985
+ const focusedNode = state.mostRecentlyFocusedNode;
986
+ if (!focusedNode) {
987
+ return;
988
+ }
982
989
  const isFocusedNodeRemoved = mutations.some(function (mutation) {
983
990
  const removedNodes = Array.from(mutation.removedNodes);
984
991
  return removedNodes.some(function (node) {
985
- return node === state.mostRecentlyFocusedNode;
992
+ return (
993
+ node === focusedNode ||
994
+ (typeof node.contains === 'function' && node.contains(focusedNode))
995
+ );
986
996
  });
987
997
  });
988
998
 
989
- // If the currently focused is removed then browsers will move focus to the
999
+ // If the currently focused node is removed then browsers will move focus to the
990
1000
  // <body> element. If this happens, try to move focus back into the trap.
991
- if (isFocusedNodeRemoved) {
992
- tryFocus(getInitialFocusNode());
1001
+ if (
1002
+ isFocusedNodeRemoved &&
1003
+ state.containers.some(function (container) {
1004
+ return container?.isConnected;
1005
+ })
1006
+ ) {
1007
+ // Refresh tabbable state before resolving initial focus because
1008
+ // getInitialFocusNode() may fall back to the first tabbable node in the trap.
1009
+ updateTabbableNodes();
1010
+ const initialFocusNode = getInitialFocusNode();
1011
+ tryFocus(initialFocusNode);
993
1012
  }
994
1013
  };
995
1014
 
@@ -1061,21 +1080,28 @@ const createFocusTrap = function (elements, userOptions) {
1061
1080
 
1062
1081
  onActivate?.({ trap });
1063
1082
 
1064
- const finishActivation = async () => {
1083
+ const finishActivation = () => {
1065
1084
  if (checkCanFocusTrap) {
1066
1085
  updateTabbableNodes();
1067
1086
  }
1068
1087
 
1069
- // NOTE: wait for initial focus node to get focused before we potentially isolate
1070
- // the subtrees with aria-hidden while focus is still in some other subtree and
1071
- // not yet in the trap, resulting in some browsers (e.g. Chrome) logging to the
1072
- // console that they, "Blocked aria-hidden on an element because its descendant
1073
- // retained focus..."
1074
- await addListeners();
1088
+ const afterListeners = () => {
1089
+ trap._setSubtreeIsolation(true);
1090
+ updateObservedNodes();
1091
+ onPostActivate?.({ trap });
1092
+ };
1075
1093
 
1076
- trap._setSubtreeIsolation(true);
1077
- updateObservedNodes();
1078
- onPostActivate?.({ trap });
1094
+ // NOTE: wait for initial focus node to get focused (whether activation is fully,
1095
+ // partially, or not asynchronous) before we potentially isolate the subtrees
1096
+ // with aria-hidden while focus is still in some other subtree and not yet in
1097
+ // the trap, resulting in some browsers (e.g. Chrome) logging to the console that
1098
+ // they, "Blocked aria-hidden on an element because its descendant retained focus..."
1099
+ const listenersPromise = addListeners();
1100
+ if (listenersPromise) {
1101
+ listenersPromise.then(afterListeners);
1102
+ } else {
1103
+ afterListeners();
1104
+ }
1079
1105
  };
1080
1106
 
1081
1107
  if (checkCanFocusTrap) {
@@ -1137,6 +1163,7 @@ const createFocusTrap = function (elements, userOptions) {
1137
1163
  const onDeactivate = getOption(options, 'onDeactivate');
1138
1164
  const onPostDeactivate = getOption(options, 'onPostDeactivate');
1139
1165
  const checkCanReturnFocus = getOption(options, 'checkCanReturnFocus');
1166
+ const delayReturnFocus = getOption(options, 'delayReturnFocus');
1140
1167
  const returnFocus = getOption(
1141
1168
  options,
1142
1169
  'returnFocus',
@@ -1144,14 +1171,19 @@ const createFocusTrap = function (elements, userOptions) {
1144
1171
  );
1145
1172
 
1146
1173
  onDeactivate?.({ trap });
1174
+ const completeDeactivation = () => {
1175
+ if (returnFocus) {
1176
+ tryFocus(getReturnFocusNode(state.nodeFocusedBeforeActivation));
1177
+ }
1178
+ onPostDeactivate?.({ trap });
1179
+ };
1147
1180
 
1148
1181
  const finishDeactivation = () => {
1149
- delay(() => {
1150
- if (returnFocus) {
1151
- tryFocus(getReturnFocusNode(state.nodeFocusedBeforeActivation));
1152
- }
1153
- onPostDeactivate?.({ trap });
1154
- });
1182
+ if (delayReturnFocus && returnFocus) {
1183
+ delay(completeDeactivation);
1184
+ } else {
1185
+ completeDeactivation();
1186
+ }
1155
1187
  };
1156
1188
 
1157
1189
  if (returnFocus && checkCanReturnFocus) {
@@ -1244,19 +1276,26 @@ const createFocusTrap = function (elements, userOptions) {
1244
1276
 
1245
1277
  onUnpause?.({ trap });
1246
1278
 
1247
- const finishUnpause = async () => {
1279
+ const finishUnpause = () => {
1248
1280
  updateTabbableNodes();
1249
1281
 
1250
- // NOTE: wait for initial focus node to get focused before we potentially isolate
1251
- // the subtrees with aria-hidden while focus is still in some other subtree and
1252
- // not yet in the trap, resulting in some browsers (e.g. Chrome) logging to the
1253
- // console that they, "Blocked aria-hidden on an element because its descendant
1254
- // retained focus..."
1255
- await addListeners();
1256
-
1257
- trap._setSubtreeIsolation(true);
1258
- updateObservedNodes();
1259
- onPostUnpause?.({ trap });
1282
+ const afterListeners = () => {
1283
+ trap._setSubtreeIsolation(true);
1284
+ updateObservedNodes();
1285
+ onPostUnpause?.({ trap });
1286
+ };
1287
+
1288
+ // NOTE: wait for initial focus node to get focused (whether activation is fully,
1289
+ // partially, or not asynchronous) before we potentially isolate the subtrees
1290
+ // with aria-hidden while focus is still in some other subtree and not yet in
1291
+ // the trap, resulting in some browsers (e.g. Chrome) logging to the console that
1292
+ // they, "Blocked aria-hidden on an element because its descendant retained focus..."
1293
+ const listenersPromise = addListeners();
1294
+ if (listenersPromise) {
1295
+ listenersPromise.then(afterListeners);
1296
+ } else {
1297
+ afterListeners();
1298
+ }
1260
1299
  };
1261
1300
 
1262
1301
  finishUnpause();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "focus-trap",
3
- "version": "8.1.0",
3
+ "version": "8.2.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",
@@ -72,7 +72,7 @@
72
72
  "@babel/core": "^7.29.0",
73
73
  "@babel/eslint-parser": "^7.28.6",
74
74
  "@babel/eslint-plugin": "^7.27.1",
75
- "@babel/preset-env": "^7.29.2",
75
+ "@babel/preset-env": "^7.29.5",
76
76
  "@changesets/cli": "^2.31.0",
77
77
  "@eslint/js": "^9.39.2",
78
78
  "@rollup/plugin-babel": "^7.0.0",
@@ -80,40 +80,40 @@
80
80
  "@rollup/plugin-node-resolve": "^16.0.3",
81
81
  "@rollup/plugin-replace": "^6.0.3",
82
82
  "@rollup/plugin-terser": "^1.0.0",
83
- "@testing-library/cypress": "^10.1.0",
83
+ "@testing-library/cypress": "^10.1.3",
84
84
  "@testing-library/dom": "^10.4.1",
85
85
  "@testing-library/jest-dom": "^6.9.1",
86
86
  "@testing-library/user-event": "^14.6.1",
87
87
  "@types/jest": "^30.0.0",
88
88
  "@types/jquery": "^4.0.0",
89
- "@types/node": "^25.6.0",
90
- "@typescript-eslint/eslint-plugin": "^8.59.0",
91
- "@typescript-eslint/parser": "^8.58.2",
89
+ "@types/node": "^25.7.0",
90
+ "@typescript-eslint/eslint-plugin": "^8.59.2",
91
+ "@typescript-eslint/parser": "^8.59.3",
92
92
  "all-contributors-cli": "^6.26.1",
93
- "babel-jest": "^30.3.0",
93
+ "babel-jest": "^30.4.1",
94
94
  "babel-loader": "^10.1.1",
95
95
  "cross-env": "^10.1.0",
96
- "cypress": "^15.14.1",
96
+ "cypress": "^15.15.0",
97
97
  "cypress-plugin-tab": "^1.0.5",
98
98
  "eslint": "^9.39.2",
99
99
  "eslint-config-prettier": "^10.1.8",
100
100
  "eslint-import-resolver-node": "^0.3.10",
101
101
  "eslint-import-resolver-typescript": "^4.4.4",
102
- "eslint-plugin-cypress": "^6.3.1",
102
+ "eslint-plugin-cypress": "^6.4.1",
103
103
  "eslint-plugin-import": "^2.32.0",
104
104
  "eslint-plugin-jest": "^29.15.2",
105
105
  "eslint-plugin-jest-dom": "^5.5.0",
106
106
  "eslint-plugin-testing-library": "^7.16.2",
107
- "globals": "^17.5.0",
108
- "jest": "^30.3.0",
109
- "jest-environment-jsdom": "^30.3.0",
107
+ "globals": "^17.6.0",
108
+ "jest": "^30.4.2",
109
+ "jest-environment-jsdom": "^30.4.1",
110
110
  "jest-watch-typeahead": "^3.0.1",
111
111
  "onchange": "^7.1.0",
112
112
  "prettier": "^3.8.3",
113
- "rollup": "^4.60.2",
113
+ "rollup": "^4.60.3",
114
114
  "rollup-plugin-livereload": "^2.0.5",
115
115
  "rollup-plugin-serve": "^3.0.0",
116
- "start-server-and-test": "^3.0.2",
116
+ "start-server-and-test": "^3.0.4",
117
117
  "typescript": "^6.0.3"
118
118
  }
119
119
  }