power-focusable 2.2.1 → 3.0.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/README.md +25 -1
- package/dist/index.cjs +184 -37
- package/dist/index.d.cts +7 -4
- package/dist/index.d.ts +7 -4
- package/dist/index.js +183 -38
- 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.");
|
|
@@ -16,7 +55,7 @@ function getFocusables(container = document.body, options = {}) {
|
|
|
16
55
|
elements[elements.length] = node;
|
|
17
56
|
}
|
|
18
57
|
}
|
|
19
|
-
const children =
|
|
58
|
+
const children = getChildrenComposed(node);
|
|
20
59
|
for (let i = 0, l = children.length; i < l; i++) {
|
|
21
60
|
const child = children[i];
|
|
22
61
|
if (!child) {
|
|
@@ -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 = getParentComposed(node);
|
|
111
|
+
if (!parent) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
for (const sibling of getSiblingsComposed(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,7 +157,7 @@ function isFocusable(element) {
|
|
|
87
157
|
}
|
|
88
158
|
return true;
|
|
89
159
|
}
|
|
90
|
-
function
|
|
160
|
+
function getChildrenComposed(node) {
|
|
91
161
|
if (node instanceof ShadowRoot) {
|
|
92
162
|
return getChildren(node);
|
|
93
163
|
}
|
|
@@ -105,6 +175,16 @@ function getComposedChildren(node) {
|
|
|
105
175
|
}
|
|
106
176
|
return getChildren(node);
|
|
107
177
|
}
|
|
178
|
+
function getParentComposed(node) {
|
|
179
|
+
if (node instanceof Element && node.assignedSlot) {
|
|
180
|
+
return node.assignedSlot;
|
|
181
|
+
}
|
|
182
|
+
const parent = node.parentNode;
|
|
183
|
+
if (parent instanceof ShadowRoot) {
|
|
184
|
+
return parent.host;
|
|
185
|
+
}
|
|
186
|
+
return parent instanceof Element ? parent : null;
|
|
187
|
+
}
|
|
108
188
|
function getRelativeFocusable(container, offset, options) {
|
|
109
189
|
const {
|
|
110
190
|
active = getActiveElement(),
|
|
@@ -116,7 +196,7 @@ function getRelativeFocusable(container, offset, options) {
|
|
|
116
196
|
if (!length) {
|
|
117
197
|
return null;
|
|
118
198
|
}
|
|
119
|
-
if (!active || !
|
|
199
|
+
if (!active || !containsComposed(container, active)) {
|
|
120
200
|
return null;
|
|
121
201
|
}
|
|
122
202
|
if (!(active instanceof Element)) {
|
|
@@ -132,6 +212,47 @@ function getRelativeFocusable(container, offset, options) {
|
|
|
132
212
|
}
|
|
133
213
|
return focusables[(offsetIndex + length) % length] ?? null;
|
|
134
214
|
}
|
|
215
|
+
function getSiblingsComposed(node) {
|
|
216
|
+
if (node.assignedSlot) {
|
|
217
|
+
return [...node.assignedSlot.children].filter(
|
|
218
|
+
(child) => child instanceof Element && child !== node
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const parent = getParentComposed(node);
|
|
222
|
+
if (!parent) {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
return getSiblings(node);
|
|
226
|
+
}
|
|
227
|
+
function isDisabledDeep(element) {
|
|
228
|
+
let current = element;
|
|
229
|
+
while (current) {
|
|
230
|
+
if (current instanceof ShadowRoot) {
|
|
231
|
+
if (current.mode !== "open") {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
current = current.host;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (!(current instanceof Element)) {
|
|
238
|
+
current = current.parentNode;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (current === element && isFormControl(current) && isDisabled(current)) {
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
if (current.hasAttribute("inert")) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
if (isFormControl(element) && current.tagName === "FIELDSET" && isDisabled(current)) {
|
|
248
|
+
if (!current.querySelector(":scope > legend:first-of-type")?.contains(element)) {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
current = current.parentNode;
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
135
256
|
function normalizeRadioGroup(elements) {
|
|
136
257
|
let map = null;
|
|
137
258
|
for (let i = 0, l = elements.length; i < l; i++) {
|
|
@@ -187,7 +308,31 @@ function sortByTabIndex(elements) {
|
|
|
187
308
|
}
|
|
188
309
|
return sorted;
|
|
189
310
|
}
|
|
190
|
-
|
|
311
|
+
var inertRefCounts = /* @__PURE__ */ new WeakMap();
|
|
312
|
+
function applyInert(element) {
|
|
313
|
+
if (isInert(element) && !inertRefCounts.has(element)) {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
const count = inertRefCounts.get(element) ?? 0;
|
|
317
|
+
inertRefCounts.set(element, count + 1);
|
|
318
|
+
if (count === 0) {
|
|
319
|
+
setInert(element, true);
|
|
320
|
+
}
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
function restoreInert(element) {
|
|
324
|
+
const count = inertRefCounts.get(element);
|
|
325
|
+
if (!count) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (count === 1) {
|
|
329
|
+
inertRefCounts.delete(element);
|
|
330
|
+
setInert(element, false);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
inertRefCounts.set(element, count - 1);
|
|
334
|
+
}
|
|
335
|
+
function containsComposed(container, element) {
|
|
191
336
|
let current = element;
|
|
192
337
|
while (current) {
|
|
193
338
|
if (current === container) {
|
|
@@ -197,6 +342,11 @@ function containsDeep(container, element) {
|
|
|
197
342
|
}
|
|
198
343
|
return false;
|
|
199
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 = getParentComposed(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.0
|
|
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.0
|
|
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.0
|
|
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.");
|
|
@@ -14,7 +53,7 @@ function getFocusables(container = document.body, options = {}) {
|
|
|
14
53
|
elements[elements.length] = node;
|
|
15
54
|
}
|
|
16
55
|
}
|
|
17
|
-
const children =
|
|
56
|
+
const children = getChildrenComposed(node);
|
|
18
57
|
for (let i = 0, l = children.length; i < l; i++) {
|
|
19
58
|
const child = children[i];
|
|
20
59
|
if (!child) {
|
|
@@ -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 = getParentComposed(node);
|
|
109
|
+
if (!parent) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
for (const sibling of getSiblingsComposed(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,7 +155,7 @@ function isFocusable(element) {
|
|
|
85
155
|
}
|
|
86
156
|
return true;
|
|
87
157
|
}
|
|
88
|
-
function
|
|
158
|
+
function getChildrenComposed(node) {
|
|
89
159
|
if (node instanceof ShadowRoot) {
|
|
90
160
|
return getChildren(node);
|
|
91
161
|
}
|
|
@@ -103,6 +173,16 @@ function getComposedChildren(node) {
|
|
|
103
173
|
}
|
|
104
174
|
return getChildren(node);
|
|
105
175
|
}
|
|
176
|
+
function getParentComposed(node) {
|
|
177
|
+
if (node instanceof Element && node.assignedSlot) {
|
|
178
|
+
return node.assignedSlot;
|
|
179
|
+
}
|
|
180
|
+
const parent = node.parentNode;
|
|
181
|
+
if (parent instanceof ShadowRoot) {
|
|
182
|
+
return parent.host;
|
|
183
|
+
}
|
|
184
|
+
return parent instanceof Element ? parent : null;
|
|
185
|
+
}
|
|
106
186
|
function getRelativeFocusable(container, offset, options) {
|
|
107
187
|
const {
|
|
108
188
|
active = getActiveElement(),
|
|
@@ -114,7 +194,7 @@ function getRelativeFocusable(container, offset, options) {
|
|
|
114
194
|
if (!length) {
|
|
115
195
|
return null;
|
|
116
196
|
}
|
|
117
|
-
if (!active || !
|
|
197
|
+
if (!active || !containsComposed(container, active)) {
|
|
118
198
|
return null;
|
|
119
199
|
}
|
|
120
200
|
if (!(active instanceof Element)) {
|
|
@@ -130,6 +210,47 @@ function getRelativeFocusable(container, offset, options) {
|
|
|
130
210
|
}
|
|
131
211
|
return focusables[(offsetIndex + length) % length] ?? null;
|
|
132
212
|
}
|
|
213
|
+
function getSiblingsComposed(node) {
|
|
214
|
+
if (node.assignedSlot) {
|
|
215
|
+
return [...node.assignedSlot.children].filter(
|
|
216
|
+
(child) => child instanceof Element && child !== node
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
const parent = getParentComposed(node);
|
|
220
|
+
if (!parent) {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
return getSiblings(node);
|
|
224
|
+
}
|
|
225
|
+
function isDisabledDeep(element) {
|
|
226
|
+
let current = element;
|
|
227
|
+
while (current) {
|
|
228
|
+
if (current instanceof ShadowRoot) {
|
|
229
|
+
if (current.mode !== "open") {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
current = current.host;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!(current instanceof Element)) {
|
|
236
|
+
current = current.parentNode;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (current === element && isFormControl(current) && isDisabled(current)) {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
if (current.hasAttribute("inert")) {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
if (isFormControl(element) && current.tagName === "FIELDSET" && isDisabled(current)) {
|
|
246
|
+
if (!current.querySelector(":scope > legend:first-of-type")?.contains(element)) {
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
current = current.parentNode;
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
133
254
|
function normalizeRadioGroup(elements) {
|
|
134
255
|
let map = null;
|
|
135
256
|
for (let i = 0, l = elements.length; i < l; i++) {
|
|
@@ -185,7 +306,31 @@ function sortByTabIndex(elements) {
|
|
|
185
306
|
}
|
|
186
307
|
return sorted;
|
|
187
308
|
}
|
|
188
|
-
|
|
309
|
+
var inertRefCounts = /* @__PURE__ */ new WeakMap();
|
|
310
|
+
function applyInert(element) {
|
|
311
|
+
if (isInert(element) && !inertRefCounts.has(element)) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
const count = inertRefCounts.get(element) ?? 0;
|
|
315
|
+
inertRefCounts.set(element, count + 1);
|
|
316
|
+
if (count === 0) {
|
|
317
|
+
setInert(element, true);
|
|
318
|
+
}
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
function restoreInert(element) {
|
|
322
|
+
const count = inertRefCounts.get(element);
|
|
323
|
+
if (!count) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (count === 1) {
|
|
327
|
+
inertRefCounts.delete(element);
|
|
328
|
+
setInert(element, false);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
inertRefCounts.set(element, count - 1);
|
|
332
|
+
}
|
|
333
|
+
function containsComposed(container, element) {
|
|
189
334
|
let current = element;
|
|
190
335
|
while (current) {
|
|
191
336
|
if (current === container) {
|
|
@@ -195,6 +340,11 @@ function containsDeep(container, element) {
|
|
|
195
340
|
}
|
|
196
341
|
return false;
|
|
197
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 = getParentComposed(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.0
|
|
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.0",
|
|
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",
|