power-focusable 2.2.1 → 3.0.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/README.md +25 -1
- package/dist/index.cjs +200 -53
- package/dist/index.d.cts +7 -4
- package/dist/index.d.ts +7 -4
- package/dist/index.js +199 -54
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Power Focusable
|
|
2
2
|
|
|
3
|
-
High-precision focus management utility with
|
|
3
|
+
High-precision focus management utility with full composed tree support. Handles complex focus rules including tabindex ordering, radio groups, inert, and shadow DOM.
|
|
4
4
|
|
|
5
5
|
> [!NOTE]
|
|
6
6
|
> Supports shadow DOM traversal via the composed tree. Only open shadow roots are included; closed shadow roots are not accessible.
|
|
@@ -14,10 +14,12 @@ npm i power-focusable
|
|
|
14
14
|
```ts
|
|
15
15
|
// npm
|
|
16
16
|
import {
|
|
17
|
+
createFocusTrap,
|
|
17
18
|
getFocusables,
|
|
18
19
|
getNextFocusable,
|
|
19
20
|
getPreviousFocusable,
|
|
20
21
|
hasFocusable,
|
|
22
|
+
inertOutside,
|
|
21
23
|
isFocusable,
|
|
22
24
|
} from 'power-focusable';
|
|
23
25
|
|
|
@@ -59,6 +61,17 @@ Used by `getNextFocusable` and `getPreviousFocusable`.
|
|
|
59
61
|
|
|
60
62
|
## 📦 APIs
|
|
61
63
|
|
|
64
|
+
### `createFocusTrap`
|
|
65
|
+
|
|
66
|
+
Creates a keyboard focus trap within the container. Automatically focuses the container itself when possible; otherwise focuses the first available focusable element.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
const cleanup = createFocusTrap(container);
|
|
70
|
+
// => () => void
|
|
71
|
+
//
|
|
72
|
+
// container: Element
|
|
73
|
+
```
|
|
74
|
+
|
|
62
75
|
### `getFocusables`
|
|
63
76
|
|
|
64
77
|
Returns all focusable elements within the container.
|
|
@@ -128,6 +141,17 @@ hasFocusable(container);
|
|
|
128
141
|
hasFocusable(container, { composed: true });
|
|
129
142
|
```
|
|
130
143
|
|
|
144
|
+
### `inertOutside`
|
|
145
|
+
|
|
146
|
+
Temporarily applies `inert` to all elements outside the target element. Useful for modals, dialogs, and overlays.
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
const cleanup = inertOutside(element);
|
|
150
|
+
// => () => void
|
|
151
|
+
//
|
|
152
|
+
// element: Element
|
|
153
|
+
```
|
|
154
|
+
|
|
131
155
|
### `isFocusable`
|
|
132
156
|
|
|
133
157
|
Returns whether the given element is focusable.
|
package/dist/index.cjs
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
var FOCUSABLE_SELECTOR = `:is(a[href], area[href], button, embed, iframe, input:not([type="hidden" i]), object, select, details > summary:first-of-type, textarea, [contenteditable]:not([contenteditable="false" i]), [controls], [tabindex]):not(:disabled, [hidden], [inert], [tabindex="-1"])`;
|
|
5
|
+
function createFocusTrap(container) {
|
|
6
|
+
if (!(container instanceof Element)) {
|
|
7
|
+
throw new Error("Invalid container element");
|
|
8
|
+
}
|
|
9
|
+
focus(container);
|
|
10
|
+
if (getActiveElement() !== container) {
|
|
11
|
+
const first = getFocusables(container, { composed: true })[0];
|
|
12
|
+
if (first) {
|
|
13
|
+
focus(first);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function onKeyDown(event) {
|
|
17
|
+
const { key, altKey, ctrlKey, metaKey, shiftKey } = event;
|
|
18
|
+
if (key !== "Tab" || altKey || ctrlKey || metaKey) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (!event.composedPath().includes(container)) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const focusable = getRelativeFocusable(container, shiftKey ? -1 : 1, {
|
|
25
|
+
composed: true,
|
|
26
|
+
wrap: true
|
|
27
|
+
});
|
|
28
|
+
if (!focusable) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
event.preventDefault();
|
|
32
|
+
focus(focusable);
|
|
33
|
+
}
|
|
34
|
+
let controller = new AbortController();
|
|
35
|
+
document.addEventListener("keydown", onKeyDown, {
|
|
36
|
+
capture: true,
|
|
37
|
+
signal: controller.signal
|
|
38
|
+
});
|
|
39
|
+
return () => {
|
|
40
|
+
controller?.abort();
|
|
41
|
+
controller = null;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
5
44
|
function getFocusables(container = document.body, options = {}) {
|
|
6
45
|
if (!(container instanceof Element)) {
|
|
7
46
|
console.warn("Invalid container element. Fallback: <body> element.");
|
|
@@ -61,12 +100,43 @@ function hasFocusable(container = document.body, options = {}) {
|
|
|
61
100
|
}
|
|
62
101
|
return !!getFocusables(container, options).length;
|
|
63
102
|
}
|
|
103
|
+
function inertOutside(element) {
|
|
104
|
+
if (!(element instanceof Element)) {
|
|
105
|
+
console.warn("Invalid element");
|
|
106
|
+
return () => {
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function traverse(node, callback) {
|
|
110
|
+
const parent = getComposedParent(node);
|
|
111
|
+
if (!parent) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
for (const sibling of getComposedSiblings(node)) {
|
|
115
|
+
callback(sibling);
|
|
116
|
+
}
|
|
117
|
+
traverse(parent, callback);
|
|
118
|
+
}
|
|
119
|
+
const elements = [];
|
|
120
|
+
traverse(element, (node) => {
|
|
121
|
+
if (!(node instanceof Element)) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (applyInert(node)) {
|
|
125
|
+
elements.push(node);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return () => {
|
|
129
|
+
elements.forEach((element2) => {
|
|
130
|
+
restoreInert(element2);
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
}
|
|
64
134
|
function isFocusable(element) {
|
|
65
135
|
if (!(element instanceof Element)) {
|
|
66
136
|
console.warn("Invalid element");
|
|
67
137
|
return false;
|
|
68
138
|
}
|
|
69
|
-
if (element.hasAttribute("hidden") || element
|
|
139
|
+
if (element.hasAttribute("hidden") || isInert(element)) {
|
|
70
140
|
return false;
|
|
71
141
|
}
|
|
72
142
|
if (getTabIndex(element) < 0) {
|
|
@@ -87,24 +157,6 @@ function isFocusable(element) {
|
|
|
87
157
|
}
|
|
88
158
|
return true;
|
|
89
159
|
}
|
|
90
|
-
function getComposedChildren(node) {
|
|
91
|
-
if (node instanceof ShadowRoot) {
|
|
92
|
-
return getChildren(node);
|
|
93
|
-
}
|
|
94
|
-
if (!(node instanceof Element)) {
|
|
95
|
-
return [];
|
|
96
|
-
}
|
|
97
|
-
if (node instanceof HTMLSlotElement) {
|
|
98
|
-
const assigned = node.assignedElements({ flatten: true });
|
|
99
|
-
if (assigned.length) {
|
|
100
|
-
return assigned;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
if (node instanceof HTMLElement && node.shadowRoot?.mode === "open") {
|
|
104
|
-
return getChildren(node.shadowRoot);
|
|
105
|
-
}
|
|
106
|
-
return getChildren(node);
|
|
107
|
-
}
|
|
108
160
|
function getRelativeFocusable(container, offset, options) {
|
|
109
161
|
const {
|
|
110
162
|
active = getActiveElement(),
|
|
@@ -116,7 +168,7 @@ function getRelativeFocusable(container, offset, options) {
|
|
|
116
168
|
if (!length) {
|
|
117
169
|
return null;
|
|
118
170
|
}
|
|
119
|
-
if (!active || !
|
|
171
|
+
if (!active || !containsComposed(container, active)) {
|
|
120
172
|
return null;
|
|
121
173
|
}
|
|
122
174
|
if (!(active instanceof Element)) {
|
|
@@ -132,6 +184,35 @@ function getRelativeFocusable(container, offset, options) {
|
|
|
132
184
|
}
|
|
133
185
|
return focusables[(offsetIndex + length) % length] ?? null;
|
|
134
186
|
}
|
|
187
|
+
function isDisabledDeep(element) {
|
|
188
|
+
let current = element;
|
|
189
|
+
while (current) {
|
|
190
|
+
if (current instanceof ShadowRoot) {
|
|
191
|
+
if (current.mode !== "open") {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
current = current.host;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (!(current instanceof Element)) {
|
|
198
|
+
current = current.parentNode;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (current === element && isFormControl(current) && isDisabled(current)) {
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
if (current.hasAttribute("inert")) {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
if (isFormControl(element) && current.tagName === "FIELDSET" && isDisabled(current)) {
|
|
208
|
+
if (!current.querySelector(":scope > legend:first-of-type")?.contains(element)) {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
current = current.parentNode;
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
135
216
|
function normalizeRadioGroup(elements) {
|
|
136
217
|
let map = null;
|
|
137
218
|
for (let i = 0, l = elements.length; i < l; i++) {
|
|
@@ -187,7 +268,7 @@ function sortByTabIndex(elements) {
|
|
|
187
268
|
}
|
|
188
269
|
return sorted;
|
|
189
270
|
}
|
|
190
|
-
function
|
|
271
|
+
function containsComposed(container, element) {
|
|
191
272
|
let current = element;
|
|
192
273
|
while (current) {
|
|
193
274
|
if (current === container) {
|
|
@@ -197,6 +278,75 @@ function containsDeep(container, element) {
|
|
|
197
278
|
}
|
|
198
279
|
return false;
|
|
199
280
|
}
|
|
281
|
+
function getComposedChildren(node) {
|
|
282
|
+
if (node instanceof ShadowRoot) {
|
|
283
|
+
return getChildren(node);
|
|
284
|
+
}
|
|
285
|
+
if (!(node instanceof Element)) {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
if (node instanceof HTMLSlotElement) {
|
|
289
|
+
const assigned = node.assignedElements({ flatten: true });
|
|
290
|
+
if (assigned.length) {
|
|
291
|
+
return assigned;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (node instanceof HTMLElement && node.shadowRoot?.mode === "open") {
|
|
295
|
+
return getChildren(node.shadowRoot);
|
|
296
|
+
}
|
|
297
|
+
return getChildren(node);
|
|
298
|
+
}
|
|
299
|
+
function getComposedParent(node) {
|
|
300
|
+
if (node instanceof Element && node.assignedSlot) {
|
|
301
|
+
return node.assignedSlot;
|
|
302
|
+
}
|
|
303
|
+
const parent = node.parentNode;
|
|
304
|
+
if (parent instanceof ShadowRoot) {
|
|
305
|
+
return parent.host;
|
|
306
|
+
}
|
|
307
|
+
return parent instanceof Element ? parent : null;
|
|
308
|
+
}
|
|
309
|
+
function getComposedSiblings(node) {
|
|
310
|
+
if (node.assignedSlot) {
|
|
311
|
+
return [...node.assignedSlot.children].filter(
|
|
312
|
+
(child) => child instanceof Element && child !== node
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
const parent = getComposedParent(node);
|
|
316
|
+
if (!parent) {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
return getSiblings(node);
|
|
320
|
+
}
|
|
321
|
+
var inertRefCounts = /* @__PURE__ */ new WeakMap();
|
|
322
|
+
function applyInert(element) {
|
|
323
|
+
if (isInert(element) && !inertRefCounts.has(element)) {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
const count = inertRefCounts.get(element) ?? 0;
|
|
327
|
+
inertRefCounts.set(element, count + 1);
|
|
328
|
+
if (count === 0) {
|
|
329
|
+
setInert(element, true);
|
|
330
|
+
}
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
function restoreInert(element) {
|
|
334
|
+
const count = inertRefCounts.get(element);
|
|
335
|
+
if (!count) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (count === 1) {
|
|
339
|
+
inertRefCounts.delete(element);
|
|
340
|
+
setInert(element, false);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
inertRefCounts.set(element, count - 1);
|
|
344
|
+
}
|
|
345
|
+
function focus(element) {
|
|
346
|
+
if ("focus" in element && typeof element.focus === "function") {
|
|
347
|
+
element.focus();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
200
350
|
function getActiveElement() {
|
|
201
351
|
let current = document.activeElement;
|
|
202
352
|
while (current?.shadowRoot?.activeElement) {
|
|
@@ -211,62 +361,59 @@ function getChildren(node) {
|
|
|
211
361
|
}
|
|
212
362
|
return elements;
|
|
213
363
|
}
|
|
364
|
+
function getSiblings(node) {
|
|
365
|
+
const parent = getComposedParent(node);
|
|
366
|
+
if (!parent) {
|
|
367
|
+
return [];
|
|
368
|
+
}
|
|
369
|
+
const elements = [];
|
|
370
|
+
for (let child = parent.firstElementChild; child; child = child.nextElementSibling) {
|
|
371
|
+
if (child !== node) {
|
|
372
|
+
elements[elements.length] = child;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return elements;
|
|
376
|
+
}
|
|
214
377
|
function getTabIndex(element) {
|
|
215
378
|
return "tabIndex" in element ? Number(element.tabIndex) : 0;
|
|
216
379
|
}
|
|
217
380
|
function isDisabled(element) {
|
|
218
381
|
return "disabled" in element && !!element.disabled;
|
|
219
382
|
}
|
|
220
|
-
function isDisabledDeep(element) {
|
|
221
|
-
let current = element;
|
|
222
|
-
while (current) {
|
|
223
|
-
if (current instanceof ShadowRoot) {
|
|
224
|
-
if (current.mode !== "open") {
|
|
225
|
-
return false;
|
|
226
|
-
}
|
|
227
|
-
current = current.host;
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
if (!(current instanceof Element)) {
|
|
231
|
-
current = current.parentNode;
|
|
232
|
-
continue;
|
|
233
|
-
}
|
|
234
|
-
if (current === element && isFormControl(current) && isDisabled(current)) {
|
|
235
|
-
return true;
|
|
236
|
-
}
|
|
237
|
-
if (current.hasAttribute("inert")) {
|
|
238
|
-
return true;
|
|
239
|
-
}
|
|
240
|
-
if (isFormControl(element) && current.tagName === "FIELDSET" && isDisabled(current)) {
|
|
241
|
-
if (!current.querySelector(":scope > legend:first-of-type")?.contains(element)) {
|
|
242
|
-
return true;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
current = current.parentNode;
|
|
246
|
-
}
|
|
247
|
-
return false;
|
|
248
|
-
}
|
|
249
383
|
function isFormControl(element) {
|
|
250
384
|
const name = element.tagName;
|
|
251
385
|
return name === "BUTTON" || name === "INPUT" || name === "SELECT" || name === "TEXTAREA";
|
|
252
386
|
}
|
|
387
|
+
function isInert(element) {
|
|
388
|
+
return "inert" in element && !!element.inert;
|
|
389
|
+
}
|
|
253
390
|
function isUngroupedRadio(element) {
|
|
254
391
|
return element instanceof HTMLInputElement && element.type === "radio" && !!element.name;
|
|
255
392
|
}
|
|
393
|
+
function setInert(element, boolean) {
|
|
394
|
+
if (boolean) {
|
|
395
|
+
element.setAttribute("inert", "");
|
|
396
|
+
} else {
|
|
397
|
+
element.removeAttribute("inert");
|
|
398
|
+
}
|
|
399
|
+
}
|
|
256
400
|
/**
|
|
257
401
|
* Power Focusable
|
|
258
|
-
* High-precision focus management utility with
|
|
259
|
-
* Handles complex focus rules including tabindex ordering, radio groups,
|
|
402
|
+
* High-precision focus management utility with full composed tree support.
|
|
403
|
+
* Handles complex focus rules including tabindex ordering, radio groups, inert,
|
|
404
|
+
* and shadow DOM.
|
|
260
405
|
*
|
|
261
|
-
* @version
|
|
406
|
+
* @version 3.0.1
|
|
262
407
|
* @author Yusuke Kamiyamane
|
|
263
408
|
* @license MIT
|
|
264
409
|
* @copyright Copyright (c) Yusuke Kamiyamane
|
|
265
410
|
* @see {@link https://github.com/y14e/power-focusable}
|
|
266
411
|
*/
|
|
267
412
|
|
|
413
|
+
exports.createFocusTrap = createFocusTrap;
|
|
268
414
|
exports.getFocusables = getFocusables;
|
|
269
415
|
exports.getNextFocusable = getNextFocusable;
|
|
270
416
|
exports.getPreviousFocusable = getPreviousFocusable;
|
|
271
417
|
exports.hasFocusable = hasFocusable;
|
|
418
|
+
exports.inertOutside = inertOutside;
|
|
272
419
|
exports.isFocusable = isFocusable;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Power Focusable
|
|
3
|
-
* High-precision focus management utility with
|
|
4
|
-
* Handles complex focus rules including tabindex ordering, radio groups,
|
|
3
|
+
* High-precision focus management utility with full composed tree support.
|
|
4
|
+
* Handles complex focus rules including tabindex ordering, radio groups, inert,
|
|
5
|
+
* and shadow DOM.
|
|
5
6
|
*
|
|
6
|
-
* @version
|
|
7
|
+
* @version 3.0.1
|
|
7
8
|
* @author Yusuke Kamiyamane
|
|
8
9
|
* @license MIT
|
|
9
10
|
* @copyright Copyright (c) Yusuke Kamiyamane
|
|
@@ -14,10 +15,12 @@ interface PowerFocusableOptions {
|
|
|
14
15
|
readonly composed?: boolean;
|
|
15
16
|
readonly wrap?: boolean;
|
|
16
17
|
}
|
|
18
|
+
declare function createFocusTrap(container: Element): () => void;
|
|
17
19
|
declare function getFocusables(container?: Element, options?: Omit<PowerFocusableOptions, 'active' | 'wrap'>): Element[];
|
|
18
20
|
declare function getNextFocusable(container?: Element, options?: PowerFocusableOptions): Element | null;
|
|
19
21
|
declare function getPreviousFocusable(container?: Element, options?: PowerFocusableOptions): Element | null;
|
|
20
22
|
declare function hasFocusable(container?: Element, options?: Omit<PowerFocusableOptions, 'active' | 'wrap'>): boolean;
|
|
23
|
+
declare function inertOutside(element: Element): () => void;
|
|
21
24
|
declare function isFocusable(element: Element): boolean;
|
|
22
25
|
|
|
23
|
-
export { type PowerFocusableOptions, getFocusables, getNextFocusable, getPreviousFocusable, hasFocusable, isFocusable };
|
|
26
|
+
export { type PowerFocusableOptions, createFocusTrap, getFocusables, getNextFocusable, getPreviousFocusable, hasFocusable, inertOutside, isFocusable };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Power Focusable
|
|
3
|
-
* High-precision focus management utility with
|
|
4
|
-
* Handles complex focus rules including tabindex ordering, radio groups,
|
|
3
|
+
* High-precision focus management utility with full composed tree support.
|
|
4
|
+
* Handles complex focus rules including tabindex ordering, radio groups, inert,
|
|
5
|
+
* and shadow DOM.
|
|
5
6
|
*
|
|
6
|
-
* @version
|
|
7
|
+
* @version 3.0.1
|
|
7
8
|
* @author Yusuke Kamiyamane
|
|
8
9
|
* @license MIT
|
|
9
10
|
* @copyright Copyright (c) Yusuke Kamiyamane
|
|
@@ -14,10 +15,12 @@ interface PowerFocusableOptions {
|
|
|
14
15
|
readonly composed?: boolean;
|
|
15
16
|
readonly wrap?: boolean;
|
|
16
17
|
}
|
|
18
|
+
declare function createFocusTrap(container: Element): () => void;
|
|
17
19
|
declare function getFocusables(container?: Element, options?: Omit<PowerFocusableOptions, 'active' | 'wrap'>): Element[];
|
|
18
20
|
declare function getNextFocusable(container?: Element, options?: PowerFocusableOptions): Element | null;
|
|
19
21
|
declare function getPreviousFocusable(container?: Element, options?: PowerFocusableOptions): Element | null;
|
|
20
22
|
declare function hasFocusable(container?: Element, options?: Omit<PowerFocusableOptions, 'active' | 'wrap'>): boolean;
|
|
23
|
+
declare function inertOutside(element: Element): () => void;
|
|
21
24
|
declare function isFocusable(element: Element): boolean;
|
|
22
25
|
|
|
23
|
-
export { type PowerFocusableOptions, getFocusables, getNextFocusable, getPreviousFocusable, hasFocusable, isFocusable };
|
|
26
|
+
export { type PowerFocusableOptions, createFocusTrap, getFocusables, getNextFocusable, getPreviousFocusable, hasFocusable, inertOutside, isFocusable };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
var FOCUSABLE_SELECTOR = `:is(a[href], area[href], button, embed, iframe, input:not([type="hidden" i]), object, select, details > summary:first-of-type, textarea, [contenteditable]:not([contenteditable="false" i]), [controls], [tabindex]):not(:disabled, [hidden], [inert], [tabindex="-1"])`;
|
|
3
|
+
function createFocusTrap(container) {
|
|
4
|
+
if (!(container instanceof Element)) {
|
|
5
|
+
throw new Error("Invalid container element");
|
|
6
|
+
}
|
|
7
|
+
focus(container);
|
|
8
|
+
if (getActiveElement() !== container) {
|
|
9
|
+
const first = getFocusables(container, { composed: true })[0];
|
|
10
|
+
if (first) {
|
|
11
|
+
focus(first);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function onKeyDown(event) {
|
|
15
|
+
const { key, altKey, ctrlKey, metaKey, shiftKey } = event;
|
|
16
|
+
if (key !== "Tab" || altKey || ctrlKey || metaKey) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (!event.composedPath().includes(container)) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const focusable = getRelativeFocusable(container, shiftKey ? -1 : 1, {
|
|
23
|
+
composed: true,
|
|
24
|
+
wrap: true
|
|
25
|
+
});
|
|
26
|
+
if (!focusable) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
event.preventDefault();
|
|
30
|
+
focus(focusable);
|
|
31
|
+
}
|
|
32
|
+
let controller = new AbortController();
|
|
33
|
+
document.addEventListener("keydown", onKeyDown, {
|
|
34
|
+
capture: true,
|
|
35
|
+
signal: controller.signal
|
|
36
|
+
});
|
|
37
|
+
return () => {
|
|
38
|
+
controller?.abort();
|
|
39
|
+
controller = null;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
3
42
|
function getFocusables(container = document.body, options = {}) {
|
|
4
43
|
if (!(container instanceof Element)) {
|
|
5
44
|
console.warn("Invalid container element. Fallback: <body> element.");
|
|
@@ -59,12 +98,43 @@ function hasFocusable(container = document.body, options = {}) {
|
|
|
59
98
|
}
|
|
60
99
|
return !!getFocusables(container, options).length;
|
|
61
100
|
}
|
|
101
|
+
function inertOutside(element) {
|
|
102
|
+
if (!(element instanceof Element)) {
|
|
103
|
+
console.warn("Invalid element");
|
|
104
|
+
return () => {
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function traverse(node, callback) {
|
|
108
|
+
const parent = getComposedParent(node);
|
|
109
|
+
if (!parent) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
for (const sibling of getComposedSiblings(node)) {
|
|
113
|
+
callback(sibling);
|
|
114
|
+
}
|
|
115
|
+
traverse(parent, callback);
|
|
116
|
+
}
|
|
117
|
+
const elements = [];
|
|
118
|
+
traverse(element, (node) => {
|
|
119
|
+
if (!(node instanceof Element)) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (applyInert(node)) {
|
|
123
|
+
elements.push(node);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
return () => {
|
|
127
|
+
elements.forEach((element2) => {
|
|
128
|
+
restoreInert(element2);
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
}
|
|
62
132
|
function isFocusable(element) {
|
|
63
133
|
if (!(element instanceof Element)) {
|
|
64
134
|
console.warn("Invalid element");
|
|
65
135
|
return false;
|
|
66
136
|
}
|
|
67
|
-
if (element.hasAttribute("hidden") || element
|
|
137
|
+
if (element.hasAttribute("hidden") || isInert(element)) {
|
|
68
138
|
return false;
|
|
69
139
|
}
|
|
70
140
|
if (getTabIndex(element) < 0) {
|
|
@@ -85,24 +155,6 @@ function isFocusable(element) {
|
|
|
85
155
|
}
|
|
86
156
|
return true;
|
|
87
157
|
}
|
|
88
|
-
function getComposedChildren(node) {
|
|
89
|
-
if (node instanceof ShadowRoot) {
|
|
90
|
-
return getChildren(node);
|
|
91
|
-
}
|
|
92
|
-
if (!(node instanceof Element)) {
|
|
93
|
-
return [];
|
|
94
|
-
}
|
|
95
|
-
if (node instanceof HTMLSlotElement) {
|
|
96
|
-
const assigned = node.assignedElements({ flatten: true });
|
|
97
|
-
if (assigned.length) {
|
|
98
|
-
return assigned;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
if (node instanceof HTMLElement && node.shadowRoot?.mode === "open") {
|
|
102
|
-
return getChildren(node.shadowRoot);
|
|
103
|
-
}
|
|
104
|
-
return getChildren(node);
|
|
105
|
-
}
|
|
106
158
|
function getRelativeFocusable(container, offset, options) {
|
|
107
159
|
const {
|
|
108
160
|
active = getActiveElement(),
|
|
@@ -114,7 +166,7 @@ function getRelativeFocusable(container, offset, options) {
|
|
|
114
166
|
if (!length) {
|
|
115
167
|
return null;
|
|
116
168
|
}
|
|
117
|
-
if (!active || !
|
|
169
|
+
if (!active || !containsComposed(container, active)) {
|
|
118
170
|
return null;
|
|
119
171
|
}
|
|
120
172
|
if (!(active instanceof Element)) {
|
|
@@ -130,6 +182,35 @@ function getRelativeFocusable(container, offset, options) {
|
|
|
130
182
|
}
|
|
131
183
|
return focusables[(offsetIndex + length) % length] ?? null;
|
|
132
184
|
}
|
|
185
|
+
function isDisabledDeep(element) {
|
|
186
|
+
let current = element;
|
|
187
|
+
while (current) {
|
|
188
|
+
if (current instanceof ShadowRoot) {
|
|
189
|
+
if (current.mode !== "open") {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
current = current.host;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (!(current instanceof Element)) {
|
|
196
|
+
current = current.parentNode;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (current === element && isFormControl(current) && isDisabled(current)) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
if (current.hasAttribute("inert")) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
if (isFormControl(element) && current.tagName === "FIELDSET" && isDisabled(current)) {
|
|
206
|
+
if (!current.querySelector(":scope > legend:first-of-type")?.contains(element)) {
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
current = current.parentNode;
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
133
214
|
function normalizeRadioGroup(elements) {
|
|
134
215
|
let map = null;
|
|
135
216
|
for (let i = 0, l = elements.length; i < l; i++) {
|
|
@@ -185,7 +266,7 @@ function sortByTabIndex(elements) {
|
|
|
185
266
|
}
|
|
186
267
|
return sorted;
|
|
187
268
|
}
|
|
188
|
-
function
|
|
269
|
+
function containsComposed(container, element) {
|
|
189
270
|
let current = element;
|
|
190
271
|
while (current) {
|
|
191
272
|
if (current === container) {
|
|
@@ -195,6 +276,75 @@ function containsDeep(container, element) {
|
|
|
195
276
|
}
|
|
196
277
|
return false;
|
|
197
278
|
}
|
|
279
|
+
function getComposedChildren(node) {
|
|
280
|
+
if (node instanceof ShadowRoot) {
|
|
281
|
+
return getChildren(node);
|
|
282
|
+
}
|
|
283
|
+
if (!(node instanceof Element)) {
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
if (node instanceof HTMLSlotElement) {
|
|
287
|
+
const assigned = node.assignedElements({ flatten: true });
|
|
288
|
+
if (assigned.length) {
|
|
289
|
+
return assigned;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (node instanceof HTMLElement && node.shadowRoot?.mode === "open") {
|
|
293
|
+
return getChildren(node.shadowRoot);
|
|
294
|
+
}
|
|
295
|
+
return getChildren(node);
|
|
296
|
+
}
|
|
297
|
+
function getComposedParent(node) {
|
|
298
|
+
if (node instanceof Element && node.assignedSlot) {
|
|
299
|
+
return node.assignedSlot;
|
|
300
|
+
}
|
|
301
|
+
const parent = node.parentNode;
|
|
302
|
+
if (parent instanceof ShadowRoot) {
|
|
303
|
+
return parent.host;
|
|
304
|
+
}
|
|
305
|
+
return parent instanceof Element ? parent : null;
|
|
306
|
+
}
|
|
307
|
+
function getComposedSiblings(node) {
|
|
308
|
+
if (node.assignedSlot) {
|
|
309
|
+
return [...node.assignedSlot.children].filter(
|
|
310
|
+
(child) => child instanceof Element && child !== node
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
const parent = getComposedParent(node);
|
|
314
|
+
if (!parent) {
|
|
315
|
+
return [];
|
|
316
|
+
}
|
|
317
|
+
return getSiblings(node);
|
|
318
|
+
}
|
|
319
|
+
var inertRefCounts = /* @__PURE__ */ new WeakMap();
|
|
320
|
+
function applyInert(element) {
|
|
321
|
+
if (isInert(element) && !inertRefCounts.has(element)) {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
const count = inertRefCounts.get(element) ?? 0;
|
|
325
|
+
inertRefCounts.set(element, count + 1);
|
|
326
|
+
if (count === 0) {
|
|
327
|
+
setInert(element, true);
|
|
328
|
+
}
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
function restoreInert(element) {
|
|
332
|
+
const count = inertRefCounts.get(element);
|
|
333
|
+
if (!count) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (count === 1) {
|
|
337
|
+
inertRefCounts.delete(element);
|
|
338
|
+
setInert(element, false);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
inertRefCounts.set(element, count - 1);
|
|
342
|
+
}
|
|
343
|
+
function focus(element) {
|
|
344
|
+
if ("focus" in element && typeof element.focus === "function") {
|
|
345
|
+
element.focus();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
198
348
|
function getActiveElement() {
|
|
199
349
|
let current = document.activeElement;
|
|
200
350
|
while (current?.shadowRoot?.activeElement) {
|
|
@@ -209,58 +359,53 @@ function getChildren(node) {
|
|
|
209
359
|
}
|
|
210
360
|
return elements;
|
|
211
361
|
}
|
|
362
|
+
function getSiblings(node) {
|
|
363
|
+
const parent = getComposedParent(node);
|
|
364
|
+
if (!parent) {
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
367
|
+
const elements = [];
|
|
368
|
+
for (let child = parent.firstElementChild; child; child = child.nextElementSibling) {
|
|
369
|
+
if (child !== node) {
|
|
370
|
+
elements[elements.length] = child;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return elements;
|
|
374
|
+
}
|
|
212
375
|
function getTabIndex(element) {
|
|
213
376
|
return "tabIndex" in element ? Number(element.tabIndex) : 0;
|
|
214
377
|
}
|
|
215
378
|
function isDisabled(element) {
|
|
216
379
|
return "disabled" in element && !!element.disabled;
|
|
217
380
|
}
|
|
218
|
-
function isDisabledDeep(element) {
|
|
219
|
-
let current = element;
|
|
220
|
-
while (current) {
|
|
221
|
-
if (current instanceof ShadowRoot) {
|
|
222
|
-
if (current.mode !== "open") {
|
|
223
|
-
return false;
|
|
224
|
-
}
|
|
225
|
-
current = current.host;
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
if (!(current instanceof Element)) {
|
|
229
|
-
current = current.parentNode;
|
|
230
|
-
continue;
|
|
231
|
-
}
|
|
232
|
-
if (current === element && isFormControl(current) && isDisabled(current)) {
|
|
233
|
-
return true;
|
|
234
|
-
}
|
|
235
|
-
if (current.hasAttribute("inert")) {
|
|
236
|
-
return true;
|
|
237
|
-
}
|
|
238
|
-
if (isFormControl(element) && current.tagName === "FIELDSET" && isDisabled(current)) {
|
|
239
|
-
if (!current.querySelector(":scope > legend:first-of-type")?.contains(element)) {
|
|
240
|
-
return true;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
current = current.parentNode;
|
|
244
|
-
}
|
|
245
|
-
return false;
|
|
246
|
-
}
|
|
247
381
|
function isFormControl(element) {
|
|
248
382
|
const name = element.tagName;
|
|
249
383
|
return name === "BUTTON" || name === "INPUT" || name === "SELECT" || name === "TEXTAREA";
|
|
250
384
|
}
|
|
385
|
+
function isInert(element) {
|
|
386
|
+
return "inert" in element && !!element.inert;
|
|
387
|
+
}
|
|
251
388
|
function isUngroupedRadio(element) {
|
|
252
389
|
return element instanceof HTMLInputElement && element.type === "radio" && !!element.name;
|
|
253
390
|
}
|
|
391
|
+
function setInert(element, boolean) {
|
|
392
|
+
if (boolean) {
|
|
393
|
+
element.setAttribute("inert", "");
|
|
394
|
+
} else {
|
|
395
|
+
element.removeAttribute("inert");
|
|
396
|
+
}
|
|
397
|
+
}
|
|
254
398
|
/**
|
|
255
399
|
* Power Focusable
|
|
256
|
-
* High-precision focus management utility with
|
|
257
|
-
* Handles complex focus rules including tabindex ordering, radio groups,
|
|
400
|
+
* High-precision focus management utility with full composed tree support.
|
|
401
|
+
* Handles complex focus rules including tabindex ordering, radio groups, inert,
|
|
402
|
+
* and shadow DOM.
|
|
258
403
|
*
|
|
259
|
-
* @version
|
|
404
|
+
* @version 3.0.1
|
|
260
405
|
* @author Yusuke Kamiyamane
|
|
261
406
|
* @license MIT
|
|
262
407
|
* @copyright Copyright (c) Yusuke Kamiyamane
|
|
263
408
|
* @see {@link https://github.com/y14e/power-focusable}
|
|
264
409
|
*/
|
|
265
410
|
|
|
266
|
-
export { getFocusables, getNextFocusable, getPreviousFocusable, hasFocusable, isFocusable };
|
|
411
|
+
export { createFocusTrap, getFocusables, getNextFocusable, getPreviousFocusable, hasFocusable, inertOutside, isFocusable };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "power-focusable",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "High-precision focus management utility with
|
|
3
|
+
"version": "3.0.1",
|
|
4
|
+
"description": "High-precision focus management utility with full composed tree support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
7
7
|
"module": "./dist/index.js",
|