focus-trap 7.6.6 → 7.7.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
@@ -7,12 +7,20 @@ import {
7
7
  } from 'tabbable';
8
8
 
9
9
  const activeFocusTraps = {
10
+ // Returns the trap from the top of the stack.
11
+ getActiveTrap(trapStack) {
12
+ if (trapStack?.length > 0) {
13
+ return trapStack[trapStack.length - 1];
14
+ }
15
+ return null;
16
+ },
17
+
18
+ // Pauses the currently active trap, then adds a new trap to the stack.
10
19
  activateTrap(trapStack, trap) {
11
- if (trapStack.length > 0) {
12
- const activeTrap = trapStack[trapStack.length - 1];
13
- if (activeTrap !== trap) {
14
- activeTrap._setPausedState(true);
15
- }
20
+ const activeTrap = activeFocusTraps.getActiveTrap(trapStack);
21
+
22
+ if (trap !== activeTrap) {
23
+ activeFocusTraps.pauseTrap(trapStack);
16
24
  }
17
25
 
18
26
  const trapIndex = trapStack.indexOf(trap);
@@ -25,17 +33,28 @@ const activeFocusTraps = {
25
33
  }
26
34
  },
27
35
 
36
+ // Removes the trap from the top of the stack, then unpauses the next trap down.
28
37
  deactivateTrap(trapStack, trap) {
29
38
  const trapIndex = trapStack.indexOf(trap);
30
39
  if (trapIndex !== -1) {
31
40
  trapStack.splice(trapIndex, 1);
32
41
  }
33
42
 
34
- if (
35
- trapStack.length > 0 &&
36
- !trapStack[trapStack.length - 1]._isManuallyPaused()
37
- ) {
38
- trapStack[trapStack.length - 1]._setPausedState(false);
43
+ activeFocusTraps.unpauseTrap(trapStack);
44
+ },
45
+
46
+ // Pauses the trap at the top of the stack.
47
+ pauseTrap(trapStack) {
48
+ const activeTrap = activeFocusTraps.getActiveTrap(trapStack);
49
+ activeTrap?._setPausedState(true);
50
+ },
51
+
52
+ // Unpauses the trap at the top of the stack.
53
+ unpauseTrap(trapStack) {
54
+ const activeTrap = activeFocusTraps.getActiveTrap(trapStack);
55
+
56
+ if (activeTrap && !activeTrap._isManuallyPaused()) {
57
+ activeTrap._setPausedState(false);
39
58
  }
40
59
  },
41
60
  };
@@ -109,6 +128,7 @@ const createFocusTrap = function (elements, userOptions) {
109
128
  returnFocusOnDeactivate: true,
110
129
  escapeDeactivates: true,
111
130
  delayInitialFocus: true,
131
+ isolateSubtrees: false,
112
132
  isKeyForward,
113
133
  isKeyBackward,
114
134
  ...userOptions,
@@ -116,7 +136,7 @@ const createFocusTrap = function (elements, userOptions) {
116
136
 
117
137
  const state = {
118
138
  // containers given to createFocusTrap()
119
- // @type {Array<HTMLElement>}
139
+ /** @type {Array<HTMLElement>} */
120
140
  containers: [],
121
141
 
122
142
  // list of objects identifying tabbable nodes in `containers` in the trap
@@ -124,17 +144,18 @@ const createFocusTrap = function (elements, userOptions) {
124
144
  // is active, but the trap should never get to a state where there isn't at least one group
125
145
  // with at least one tabbable node in it (that would lead to an error condition that would
126
146
  // result in an error being thrown)
127
- // @type {Array<{
128
- // container: HTMLElement,
129
- // tabbableNodes: Array<HTMLElement>, // empty if none
130
- // focusableNodes: Array<HTMLElement>, // empty if none
131
- // posTabIndexesFound: boolean,
132
- // firstTabbableNode: HTMLElement|undefined,
133
- // lastTabbableNode: HTMLElement|undefined,
134
- // firstDomTabbableNode: HTMLElement|undefined,
135
- // lastDomTabbableNode: HTMLElement|undefined,
136
- // nextTabbableNode: (node: HTMLElement, forward: boolean) => HTMLElement|undefined
137
- // }>}
147
+ /** @type {Array<{
148
+ * container: HTMLElement,
149
+ * tabbableNodes: Array<HTMLElement>, // empty if none
150
+ * focusableNodes: Array<HTMLElement>, // empty if none
151
+ * posTabIndexesFound: boolean,
152
+ * firstTabbableNode: HTMLElement|undefined,
153
+ * lastTabbableNode: HTMLElement|undefined,
154
+ * firstDomTabbableNode: HTMLElement|undefined,
155
+ * lastDomTabbableNode: HTMLElement|undefined,
156
+ * nextTabbableNode: (node: HTMLElement, forward: boolean) => HTMLElement|undefined
157
+ * }>}
158
+ */
138
159
  containerGroups: [], // same order/length as `containers` list
139
160
 
140
161
  // references to objects in `containerGroups`, but only those that actually have
@@ -143,6 +164,13 @@ const createFocusTrap = function (elements, userOptions) {
143
164
  // the same length
144
165
  tabbableGroups: [],
145
166
 
167
+ // references to nodes that are siblings to the ancestors of this trap's containers.
168
+ /** @type {Set<HTMLElement>} */
169
+ adjacentElements: new Set(),
170
+
171
+ // references to nodes that were inert before the trap was activated.
172
+ /** @type {Set<HTMLElement>} */
173
+ alreadyInert: new Set(),
146
174
  nodeFocusedBeforeActivation: null,
147
175
  mostRecentlyFocusedNode: null,
148
176
  active: false,
@@ -857,6 +885,64 @@ const createFocusTrap = function (elements, userOptions) {
857
885
  return trap;
858
886
  };
859
887
 
888
+ /**
889
+ * Traverses up the DOM from each of `containers`, collecting references to
890
+ * the elements that are siblings to `container` or an ancestor of `container`.
891
+ * @param {Array<HTMLElement>} containers
892
+ */
893
+ const collectAdjacentElements = function (containers) {
894
+ // Re-activate all adjacent elements & clear previous collection.
895
+ if (state.active && !state.paused) {
896
+ trap._setSubtreeIsolation(false);
897
+ }
898
+ state.adjacentElements.clear();
899
+ state.alreadyInert.clear();
900
+
901
+ // Collect all ancestors of all containers to avoid redundant processing.
902
+ const containerAncestors = new Set();
903
+
904
+ const adjacentElements = new Set();
905
+
906
+ // Compile all elements adjacent to the focus trap containers & lineage.
907
+ for (const container of containers) {
908
+ containerAncestors.add(container);
909
+ let insideShadowRoot =
910
+ typeof ShadowRoot !== 'undefined' &&
911
+ container.getRootNode() instanceof ShadowRoot;
912
+ let current = container;
913
+ while (current) {
914
+ containerAncestors.add(current);
915
+
916
+ let parent = current.parentElement;
917
+ let siblings = [];
918
+ if (parent) {
919
+ siblings = parent.children;
920
+ } else if (!parent && insideShadowRoot) {
921
+ siblings = current.getRootNode().children;
922
+ parent = current.getRootNode().host;
923
+ insideShadowRoot =
924
+ typeof ShadowRoot !== 'undefined' &&
925
+ parent.getRootNode() instanceof ShadowRoot;
926
+ }
927
+
928
+ // Add all the children, we'll remove container lineage later.
929
+ for (const child of siblings) {
930
+ adjacentElements.add(child);
931
+ }
932
+
933
+ current = parent;
934
+ }
935
+ }
936
+
937
+ // Multi-container traps may overlap.
938
+ // Remove elements within container lineages.
939
+ containerAncestors.forEach((el) => {
940
+ adjacentElements.delete(el);
941
+ });
942
+
943
+ state.adjacentElements = adjacentElements;
944
+ };
945
+
860
946
  const removeListeners = function () {
861
947
  if (!state.active) {
862
948
  return;
@@ -936,34 +1022,58 @@ const createFocusTrap = function (elements, userOptions) {
936
1022
  const onPostActivate = getOption(activateOptions, 'onPostActivate');
937
1023
  const checkCanFocusTrap = getOption(activateOptions, 'checkCanFocusTrap');
938
1024
 
939
- if (!checkCanFocusTrap) {
940
- updateTabbableNodes();
1025
+ // If a currently-active trap is isolating its subtree, we need to remove
1026
+ // that isolation to allow the new trap to find tabbable nodes.
1027
+ const preexistingTrap = activeFocusTraps.getActiveTrap(trapStack);
1028
+ let revertState = false;
1029
+ if (preexistingTrap && !preexistingTrap.paused) {
1030
+ preexistingTrap._setSubtreeIsolation(false);
1031
+ revertState = true;
941
1032
  }
942
1033
 
943
- state.active = true;
944
- state.paused = false;
945
- state.nodeFocusedBeforeActivation = getActiveElement(doc);
1034
+ try {
1035
+ if (!checkCanFocusTrap) {
1036
+ updateTabbableNodes();
1037
+ }
1038
+
1039
+ state.active = true;
1040
+ state.paused = false;
1041
+ state.nodeFocusedBeforeActivation = getActiveElement(doc);
1042
+
1043
+ onActivate?.();
946
1044
 
947
- onActivate?.();
1045
+ const finishActivation = () => {
1046
+ if (checkCanFocusTrap) {
1047
+ updateTabbableNodes();
1048
+ }
1049
+ addListeners();
1050
+ updateObservedNodes();
1051
+ if (config.isolateSubtrees) {
1052
+ trap._setSubtreeIsolation(true);
1053
+ }
1054
+ onPostActivate?.();
1055
+ };
948
1056
 
949
- const finishActivation = () => {
950
1057
  if (checkCanFocusTrap) {
951
- updateTabbableNodes();
1058
+ checkCanFocusTrap(state.containers.concat()).then(
1059
+ finishActivation,
1060
+ finishActivation
1061
+ );
1062
+ return this;
952
1063
  }
953
- addListeners();
954
- updateObservedNodes();
955
- onPostActivate?.();
956
- };
957
1064
 
958
- if (checkCanFocusTrap) {
959
- checkCanFocusTrap(state.containers.concat()).then(
960
- finishActivation,
961
- finishActivation
962
- );
963
- return this;
1065
+ finishActivation();
1066
+ } catch (error) {
1067
+ // If our activation throws an exception and the stack hasn't changed,
1068
+ // we need to re-enable the prior trap's subtree isolation.
1069
+ if (
1070
+ preexistingTrap === activeFocusTraps.getActiveTrap(trapStack) &&
1071
+ revertState
1072
+ ) {
1073
+ preexistingTrap._setSubtreeIsolation(true);
1074
+ }
1075
+ throw error;
964
1076
  }
965
-
966
- finishActivation();
967
1077
  return this;
968
1078
  },
969
1079
 
@@ -982,6 +1092,14 @@ const createFocusTrap = function (elements, userOptions) {
982
1092
  clearTimeout(state.delayInitialFocusTimer); // noop if undefined
983
1093
  state.delayInitialFocusTimer = undefined;
984
1094
 
1095
+ // Prior to removing this trap from the trapStack, we need to remove any applications of `inert`.
1096
+ // This allows the next trap down to update its tabbable nodes properly.
1097
+ //
1098
+ // If this trap is not top of the stack, don't change any current isolation.
1099
+ if (!state.paused) {
1100
+ trap._setSubtreeIsolation(false);
1101
+ }
1102
+ state.alreadyInert.clear();
985
1103
  removeListeners();
986
1104
  state.active = false;
987
1105
  state.paused = false;
@@ -1051,8 +1169,16 @@ const createFocusTrap = function (elements, userOptions) {
1051
1169
  typeof element === 'string' ? doc.querySelector(element) : element
1052
1170
  );
1053
1171
 
1172
+ if (config.isolateSubtrees) {
1173
+ collectAdjacentElements(state.containers);
1174
+ }
1175
+
1054
1176
  if (state.active) {
1055
1177
  updateTabbableNodes();
1178
+
1179
+ if (config.isolateSubtrees && !state.paused) {
1180
+ trap._setSubtreeIsolation(true);
1181
+ }
1056
1182
  }
1057
1183
 
1058
1184
  updateObservedNodes();
@@ -1074,6 +1200,7 @@ const createFocusTrap = function (elements, userOptions) {
1074
1200
  }
1075
1201
 
1076
1202
  state.paused = paused;
1203
+
1077
1204
  if (paused) {
1078
1205
  const onPause = getOption(options, 'onPause');
1079
1206
  const onPostPause = getOption(options, 'onPostPause');
@@ -1081,6 +1208,7 @@ const createFocusTrap = function (elements, userOptions) {
1081
1208
 
1082
1209
  removeListeners();
1083
1210
  updateObservedNodes();
1211
+ trap._setSubtreeIsolation(false);
1084
1212
 
1085
1213
  onPostPause?.();
1086
1214
  } else {
@@ -1089,6 +1217,7 @@ const createFocusTrap = function (elements, userOptions) {
1089
1217
 
1090
1218
  onUnpause?.();
1091
1219
 
1220
+ trap._setSubtreeIsolation(true);
1092
1221
  updateTabbableNodes();
1093
1222
  addListeners();
1094
1223
  updateObservedNodes();
@@ -1099,6 +1228,29 @@ const createFocusTrap = function (elements, userOptions) {
1099
1228
  return this;
1100
1229
  },
1101
1230
  },
1231
+ _setSubtreeIsolation: {
1232
+ value(isEnabled) {
1233
+ if (config.isolateSubtrees) {
1234
+ state.adjacentElements.forEach((el) => {
1235
+ if (isEnabled) {
1236
+ // check both attribute and property to ensure initial state is captured
1237
+ // correctly across different browsers and test environments (like JSDOM)
1238
+ const isInitiallyInert = el.inert || el.hasAttribute('inert');
1239
+ if (isInitiallyInert) {
1240
+ state.alreadyInert.add(el);
1241
+ }
1242
+ el.inert = true;
1243
+ } else {
1244
+ if (state.alreadyInert.has(el)) {
1245
+ // do nothing
1246
+ } else {
1247
+ el.inert = false;
1248
+ }
1249
+ }
1250
+ });
1251
+ }
1252
+ },
1253
+ },
1102
1254
  });
1103
1255
 
1104
1256
  // initialize container elements
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "focus-trap",
3
- "version": "7.6.6",
3
+ "version": "7.7.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",
@@ -21,7 +21,7 @@
21
21
  "format": "prettier --write \"{*,src/**/*,test/**/*,docs/js/**/*,.github/workflows/**/*,cypress/**/*}.+(js|cjs|mjs|ts|cts|mts|yml)\"",
22
22
  "format:check": "prettier --check \"{*,src/**/*,test/**/*,docs/js/**/*,.github/workflows/**/*,cypress/**/*}.+(js|cjs|mjs|ts|cts|mts|yml)\"",
23
23
  "format:watch": "onchange \"{*,src/**/*,test/**/*,docs/js/**/*,.github/workflows/**/*,cypress/**/*}.+(js|cjs|mjs|ts|cts|mts|yml)\" -- prettier --write {{changed}}",
24
- "lint": "eslint \"{*,docs/js/**/*,cypress/e2e/**/*}.+(js|cjs|mjs|ts|cts|mts)\"",
24
+ "lint": "eslint \"{*,docs/js/**/*,cypress/e2e/**/*,test/**/*}.+(js|cjs|mjs|ts|cts|mts)\"",
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",
@@ -32,11 +32,12 @@
32
32
  "start": "npm run compile:demo -- --watch --environment SERVE,RELOAD",
33
33
  "start:cypress": "npm run compile:demo -- --environment SERVE,IS_CYPRESS_ENV:\"$CYPRESS_BROWSER\"",
34
34
  "test:types": "tsc index.d.ts",
35
- "test:unit": "echo \"No unit tests to run!\"",
35
+ "test:unit": "jest",
36
+ "test:coverage": "jest --coverage",
36
37
  "test:e2e": "ELECTRON_ENABLE_LOGGING=1 start-server-and-test start:cypress 9966 'cypress run --browser $CYPRESS_BROWSER --headless'",
37
38
  "test:e2e:chrome": "CYPRESS_BROWSER=chrome npm run test:e2e",
38
39
  "test:e2e:dev": "ELECTRON_ENABLE_LOGGING=1 start-server-and-test start:cypress 9966 'cypress open'",
39
- "test": "npm run format:check && npm run lint && npm run test:unit && npm run test:types && npm run test:e2e:chrome",
40
+ "test": "npm run format:check && npm run lint && npm run test:coverage && npm run test:types && npm run test:e2e:chrome",
40
41
  "prepare": "npm run build",
41
42
  "prepublishOnly": "npm run test && npm run build",
42
43
  "release": "npm run build && changeset publish",
@@ -64,46 +65,55 @@
64
65
  },
65
66
  "homepage": "https://github.com/focus-trap/focus-trap#readme",
66
67
  "dependencies": {
67
- "tabbable": "^6.3.0"
68
+ "tabbable": "^6.4.0"
68
69
  },
69
70
  "devDependencies": {
70
71
  "@babel/cli": "^7.28.3",
71
- "@babel/core": "^7.28.4",
72
- "@babel/eslint-parser": "^7.28.4",
72
+ "@babel/core": "^7.28.5",
73
+ "@babel/eslint-parser": "^7.28.5",
73
74
  "@babel/eslint-plugin": "^7.27.1",
74
- "@babel/preset-env": "^7.28.3",
75
- "@changesets/cli": "^2.29.7",
76
- "@eslint/js": "^9.38.0",
75
+ "@babel/preset-env": "^7.28.5",
76
+ "@changesets/cli": "^2.29.8",
77
+ "@eslint/js": "^9.39.2",
77
78
  "@rollup/plugin-babel": "^6.1.0",
78
- "@rollup/plugin-commonjs": "^28.0.8",
79
+ "@rollup/plugin-commonjs": "^29.0.0",
79
80
  "@rollup/plugin-node-resolve": "^16.0.3",
80
- "@rollup/plugin-replace": "^6.0.2",
81
+ "@rollup/plugin-replace": "^6.0.3",
81
82
  "@rollup/plugin-terser": "^0.4.4",
82
83
  "@testing-library/cypress": "^10.1.0",
84
+ "@testing-library/dom": "^10.4.1",
85
+ "@testing-library/jest-dom": "^6.9.1",
86
+ "@testing-library/user-event": "^14.6.1",
87
+ "@types/jest": "^30.0.0",
83
88
  "@types/jquery": "^3.5.33",
84
- "@typescript-eslint/eslint-plugin": "^8.46.2",
85
- "@typescript-eslint/parser": "^8.46.2",
89
+ "@types/node": "^25.0.3",
90
+ "@typescript-eslint/eslint-plugin": "^8.51.0",
91
+ "@typescript-eslint/parser": "^8.50.0",
86
92
  "all-contributors-cli": "^6.26.1",
93
+ "babel-jest": "^30.1.2",
87
94
  "babel-loader": "^10.0.0",
88
95
  "cross-env": "^10.1.0",
89
- "cypress": "^14.5.4",
96
+ "cypress": "^15.8.1",
90
97
  "cypress-plugin-tab": "^1.0.5",
91
- "eslint": "^9.38.0",
98
+ "eslint": "^9.39.2",
92
99
  "eslint-config-prettier": "^10.1.8",
93
100
  "eslint-import-resolver-node": "^0.3.9",
94
101
  "eslint-import-resolver-typescript": "^4.4.4",
95
102
  "eslint-plugin-cypress": "^5.2.0",
96
103
  "eslint-plugin-import": "^2.32.0",
97
- "eslint-plugin-jest": "^29.0.1",
104
+ "eslint-plugin-jest": "^29.12.0",
98
105
  "eslint-plugin-jest-dom": "^5.5.0",
99
- "eslint-plugin-testing-library": "^7.13.3",
100
- "globals": "^16.4.0",
106
+ "eslint-plugin-testing-library": "^7.15.3",
107
+ "globals": "^16.5.0",
108
+ "jest": "^30.2.0",
109
+ "jest-environment-jsdom": "^30.2.0",
110
+ "jest-watch-typeahead": "^3.0.1",
101
111
  "onchange": "^7.1.0",
102
- "prettier": "^3.6.2",
103
- "rollup": "^4.52.5",
112
+ "prettier": "^3.7.4",
113
+ "rollup": "^4.54.0",
104
114
  "rollup-plugin-livereload": "^2.0.5",
105
115
  "rollup-plugin-serve": "^3.0.0",
106
- "start-server-and-test": "^2.1.2",
116
+ "start-server-and-test": "^2.1.3",
107
117
  "typescript": "^5.9.3"
108
118
  }
109
119
  }