power-focusable 1.0.0 → 2.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 +45 -5
- package/dist/index.cjs +52 -57
- package/dist/index.d.cts +4 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +52 -57
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# Power Focusable
|
|
2
2
|
|
|
3
|
-
High-precision focus management utility with shadow DOM support. Handles complex focus rules.
|
|
3
|
+
High-precision focus management utility with shadow DOM support. Handles complex focus rules including tabindex ordering, radio groups, etc.
|
|
4
|
+
|
|
5
|
+
> [!NOTE]
|
|
6
|
+
> Supports shadow DOM traversal via the composed tree. Only open shadow roots are included; closed shadow roots are not accessible.
|
|
4
7
|
|
|
5
8
|
## Install
|
|
6
9
|
|
|
@@ -26,6 +29,34 @@ import { ... } 'https://cdn.jsdelivr.net/npm/power-focusable/dist/index.js';
|
|
|
26
29
|
import { ... } 'https://unpkg.com/power-focusable/dist/index.js';
|
|
27
30
|
```
|
|
28
31
|
|
|
32
|
+
## 🪄 Options
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
interface PowerFocusableOptions {
|
|
36
|
+
active?: HTMLElement | null; // default: document.activeElement
|
|
37
|
+
composed?: boolean; // default: false
|
|
38
|
+
wrap?: boolean; // default: false
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### `active`
|
|
43
|
+
|
|
44
|
+
Specifies the starting element.
|
|
45
|
+
|
|
46
|
+
Used by `getNextFocusable` and `getPreviousFocusable`.
|
|
47
|
+
|
|
48
|
+
### `composed`
|
|
49
|
+
|
|
50
|
+
If `true`, traverses the composed tree (including shadow DOM; slower)
|
|
51
|
+
|
|
52
|
+
Used by `getFocusables`, `getNextFocusable`, and `getPreviousFocusable`.
|
|
53
|
+
|
|
54
|
+
### `wrap`
|
|
55
|
+
|
|
56
|
+
If `true`, wraps around to the first or last element when reaching the end.
|
|
57
|
+
|
|
58
|
+
Used by `getNextFocusable` and `getPreviousFocusable`.
|
|
59
|
+
|
|
29
60
|
## 📦 APIs
|
|
30
61
|
|
|
31
62
|
### `getFocusables`
|
|
@@ -37,6 +68,9 @@ getFocusables(container);
|
|
|
37
68
|
// => HTMLElement[]
|
|
38
69
|
//
|
|
39
70
|
// container (optional): HTMLElement (default: document.body)
|
|
71
|
+
|
|
72
|
+
// Traverses the composed tree (including shadow DOM; slower)
|
|
73
|
+
getFocusables(container, { composed: true });
|
|
40
74
|
```
|
|
41
75
|
|
|
42
76
|
### `getNextFocusable`
|
|
@@ -49,10 +83,13 @@ getNextFocusable(container);
|
|
|
49
83
|
//
|
|
50
84
|
// container (optional): HTMLElement (default: document.body)
|
|
51
85
|
|
|
52
|
-
//
|
|
86
|
+
// Specifies the starting element
|
|
53
87
|
getNextFocusable(container, { active: document.querySelector('.button') });
|
|
54
88
|
|
|
55
|
-
//
|
|
89
|
+
// Traverses the composed tree (including shadow DOM; slower)
|
|
90
|
+
getNextFocusable(container, { composed: true });
|
|
91
|
+
|
|
92
|
+
// Wraps around to the first element when reaching the end.
|
|
56
93
|
getNextFocusable(container, { wrap: true });
|
|
57
94
|
```
|
|
58
95
|
|
|
@@ -66,10 +103,13 @@ getPreviousFocusable(container);
|
|
|
66
103
|
//
|
|
67
104
|
// container (optional): HTMLElement (default: document.body)
|
|
68
105
|
|
|
69
|
-
//
|
|
106
|
+
// Specifies the starting element
|
|
70
107
|
getPreviousFocusable(container, { active: document.querySelector('.button') });
|
|
71
108
|
|
|
72
|
-
//
|
|
109
|
+
// Traverses the composed tree (including shadow DOM; slower)
|
|
110
|
+
getPreviousFocusable(container, { composed: true });
|
|
111
|
+
|
|
112
|
+
//Wraps around to the last element when reaching the end.
|
|
73
113
|
getPreviousFocusable(container, { wrap: true });
|
|
74
114
|
|
|
75
115
|
```
|
package/dist/index.cjs
CHANGED
|
@@ -2,54 +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
|
-
|
|
6
|
-
function getFocusables(container = document.body) {
|
|
5
|
+
function getFocusables(container = document.body, options = {}) {
|
|
7
6
|
if (!(container instanceof HTMLElement)) {
|
|
8
7
|
console.warn("Invalid container element. Fallback: <body> element.");
|
|
9
8
|
container = document.body;
|
|
10
9
|
}
|
|
11
|
-
|
|
12
|
-
let isFound = false;
|
|
13
|
-
function walk2(node) {
|
|
14
|
-
if (isFound) {
|
|
15
|
-
return;
|
|
16
|
-
}
|
|
17
|
-
if (node instanceof HTMLElement && node.shadowRoot && node.shadowRoot.mode === "open") {
|
|
18
|
-
isFound = true;
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
node.childNodes.forEach(walk2);
|
|
22
|
-
}
|
|
23
|
-
walk2(root);
|
|
24
|
-
return isFound;
|
|
25
|
-
}
|
|
26
|
-
if (!container.querySelector(FOCUSABLE_SELECTOR) && !hasShadow(container)) {
|
|
27
|
-
return [];
|
|
28
|
-
}
|
|
10
|
+
const { composed = false } = options;
|
|
29
11
|
const elements = [];
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
12
|
+
if (composed) {
|
|
13
|
+
let walk2 = function(node) {
|
|
14
|
+
if (node instanceof HTMLElement) {
|
|
15
|
+
if (isFocusable(node)) {
|
|
16
|
+
elements.push(node);
|
|
17
|
+
}
|
|
18
|
+
const shadow = node.shadowRoot;
|
|
19
|
+
if (shadow && shadow.mode === "open") {
|
|
20
|
+
walk2(shadow);
|
|
21
|
+
}
|
|
22
|
+
} else if (node instanceof HTMLSlotElement) {
|
|
23
|
+
const assigned = node.assignedElements({ flatten: true });
|
|
24
|
+
if (assigned.length > 0) {
|
|
25
|
+
assigned.forEach((a) => {
|
|
26
|
+
walk2(a);
|
|
27
|
+
});
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
46
30
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
31
|
+
node.childNodes.forEach((child) => {
|
|
32
|
+
walk2(child);
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
walk2(container);
|
|
36
|
+
} else {
|
|
37
|
+
elements.push(
|
|
38
|
+
...[
|
|
39
|
+
...container.querySelectorAll(FOCUSABLE_SELECTOR)
|
|
40
|
+
].filter(isFocusable)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const cache = /* @__PURE__ */ new WeakMap();
|
|
53
44
|
function sort(elements2) {
|
|
54
45
|
const ordered = [];
|
|
55
46
|
const natural = [];
|
|
@@ -70,7 +61,6 @@ function getFocusables(container = document.body) {
|
|
|
70
61
|
}
|
|
71
62
|
function normalizeRadioGroup(elements2) {
|
|
72
63
|
let map = null;
|
|
73
|
-
const result = [];
|
|
74
64
|
for (const element of elements2) {
|
|
75
65
|
if (element instanceof HTMLInputElement && element.type === "radio" && element.name) {
|
|
76
66
|
if (!map) {
|
|
@@ -78,23 +68,28 @@ function getFocusables(container = document.body) {
|
|
|
78
68
|
}
|
|
79
69
|
const key = `${element.form?.id ?? "no-form"}::${element.name}`;
|
|
80
70
|
(map.get(key) ?? map.set(key, []).get(key)).push(element);
|
|
81
|
-
} else {
|
|
82
|
-
result.push(element);
|
|
83
71
|
}
|
|
84
72
|
}
|
|
85
73
|
if (!map) {
|
|
86
|
-
return
|
|
74
|
+
return elements2;
|
|
87
75
|
}
|
|
76
|
+
const placeholder = /* @__PURE__ */ new Set();
|
|
88
77
|
for (const group of map.values()) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
78
|
+
if (group.length > 0) {
|
|
79
|
+
const enabled = group.filter((radio) => isFocusable(radio));
|
|
80
|
+
if (enabled.length > 0) {
|
|
81
|
+
placeholder.add(
|
|
82
|
+
enabled.find((radio) => radio.checked) ?? enabled[0]
|
|
83
|
+
);
|
|
84
|
+
}
|
|
92
85
|
}
|
|
93
|
-
result.push(
|
|
94
|
-
enabled.find((radio) => radio.checked) ?? enabled[0]
|
|
95
|
-
);
|
|
96
86
|
}
|
|
97
|
-
return
|
|
87
|
+
return elements2.filter((element) => {
|
|
88
|
+
if (element instanceof HTMLInputElement && element.type === "radio" && element.name) {
|
|
89
|
+
return placeholder.has(element);
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
});
|
|
98
93
|
}
|
|
99
94
|
return normalizeRadioGroup(sort(elements));
|
|
100
95
|
}
|
|
@@ -157,12 +152,12 @@ function isFocusable(element) {
|
|
|
157
152
|
});
|
|
158
153
|
}
|
|
159
154
|
function getRelativeFocusable(container, offset = 0, options) {
|
|
160
|
-
const
|
|
155
|
+
const { active, composed = false, wrap = false } = options;
|
|
156
|
+
const focusables = getFocusables(container, { composed });
|
|
161
157
|
const { length } = focusables;
|
|
162
158
|
if (length === 0) {
|
|
163
159
|
return null;
|
|
164
160
|
}
|
|
165
|
-
const { active, wrap = false } = options;
|
|
166
161
|
function getActiveElement() {
|
|
167
162
|
let active2 = document.activeElement;
|
|
168
163
|
while (active2 instanceof HTMLElement && active2.shadowRoot?.activeElement) {
|
|
@@ -195,9 +190,9 @@ function getRelativeFocusable(container, offset = 0, options) {
|
|
|
195
190
|
/**
|
|
196
191
|
* Power Focusable
|
|
197
192
|
* High-precision focus management utility with shadow DOM support.
|
|
198
|
-
* Handles complex focus rules.
|
|
193
|
+
* Handles complex focus rules including tabindex ordering, radio groups, etc.
|
|
199
194
|
*
|
|
200
|
-
* @version
|
|
195
|
+
* @version 2.0.1
|
|
201
196
|
* @author Yusuke Kamiyamane
|
|
202
197
|
* @license MIT
|
|
203
198
|
* @copyright Copyright (c) Yusuke Kamiyamane
|
package/dist/index.d.cts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Power Focusable
|
|
3
3
|
* High-precision focus management utility with shadow DOM support.
|
|
4
|
-
* Handles complex focus rules.
|
|
4
|
+
* Handles complex focus rules including tabindex ordering, radio groups, etc.
|
|
5
5
|
*
|
|
6
|
-
* @version
|
|
6
|
+
* @version 2.0.1
|
|
7
7
|
* @author Yusuke Kamiyamane
|
|
8
8
|
* @license MIT
|
|
9
9
|
* @copyright Copyright (c) Yusuke Kamiyamane
|
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
*/
|
|
12
12
|
interface PowerFocusableOptions {
|
|
13
13
|
readonly active?: HTMLElement | null;
|
|
14
|
+
readonly composed?: boolean;
|
|
14
15
|
readonly wrap?: boolean;
|
|
15
16
|
}
|
|
16
|
-
declare function getFocusables(container?: HTMLElement): HTMLElement[];
|
|
17
|
+
declare function getFocusables(container?: HTMLElement, options?: Omit<PowerFocusableOptions, 'active' | 'wrap'>): HTMLElement[];
|
|
17
18
|
declare function getNextFocusable(container?: HTMLElement, options?: PowerFocusableOptions): HTMLElement | null;
|
|
18
19
|
declare function getPreviousFocusable(container?: HTMLElement, options?: PowerFocusableOptions): HTMLElement | null;
|
|
19
20
|
declare function hasFocusable(container?: HTMLElement): boolean;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Power Focusable
|
|
3
3
|
* High-precision focus management utility with shadow DOM support.
|
|
4
|
-
* Handles complex focus rules.
|
|
4
|
+
* Handles complex focus rules including tabindex ordering, radio groups, etc.
|
|
5
5
|
*
|
|
6
|
-
* @version
|
|
6
|
+
* @version 2.0.1
|
|
7
7
|
* @author Yusuke Kamiyamane
|
|
8
8
|
* @license MIT
|
|
9
9
|
* @copyright Copyright (c) Yusuke Kamiyamane
|
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
*/
|
|
12
12
|
interface PowerFocusableOptions {
|
|
13
13
|
readonly active?: HTMLElement | null;
|
|
14
|
+
readonly composed?: boolean;
|
|
14
15
|
readonly wrap?: boolean;
|
|
15
16
|
}
|
|
16
|
-
declare function getFocusables(container?: HTMLElement): HTMLElement[];
|
|
17
|
+
declare function getFocusables(container?: HTMLElement, options?: Omit<PowerFocusableOptions, 'active' | 'wrap'>): HTMLElement[];
|
|
17
18
|
declare function getNextFocusable(container?: HTMLElement, options?: PowerFocusableOptions): HTMLElement | null;
|
|
18
19
|
declare function getPreviousFocusable(container?: HTMLElement, options?: PowerFocusableOptions): HTMLElement | null;
|
|
19
20
|
declare function hasFocusable(container?: HTMLElement): boolean;
|
package/dist/index.js
CHANGED
|
@@ -1,53 +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
|
-
|
|
4
|
-
function getFocusables(container = document.body) {
|
|
3
|
+
function getFocusables(container = document.body, options = {}) {
|
|
5
4
|
if (!(container instanceof HTMLElement)) {
|
|
6
5
|
console.warn("Invalid container element. Fallback: <body> element.");
|
|
7
6
|
container = document.body;
|
|
8
7
|
}
|
|
9
|
-
|
|
10
|
-
let isFound = false;
|
|
11
|
-
function walk2(node) {
|
|
12
|
-
if (isFound) {
|
|
13
|
-
return;
|
|
14
|
-
}
|
|
15
|
-
if (node instanceof HTMLElement && node.shadowRoot && node.shadowRoot.mode === "open") {
|
|
16
|
-
isFound = true;
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
node.childNodes.forEach(walk2);
|
|
20
|
-
}
|
|
21
|
-
walk2(root);
|
|
22
|
-
return isFound;
|
|
23
|
-
}
|
|
24
|
-
if (!container.querySelector(FOCUSABLE_SELECTOR) && !hasShadow(container)) {
|
|
25
|
-
return [];
|
|
26
|
-
}
|
|
8
|
+
const { composed = false } = options;
|
|
27
9
|
const elements = [];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
10
|
+
if (composed) {
|
|
11
|
+
let walk2 = function(node) {
|
|
12
|
+
if (node instanceof HTMLElement) {
|
|
13
|
+
if (isFocusable(node)) {
|
|
14
|
+
elements.push(node);
|
|
15
|
+
}
|
|
16
|
+
const shadow = node.shadowRoot;
|
|
17
|
+
if (shadow && shadow.mode === "open") {
|
|
18
|
+
walk2(shadow);
|
|
19
|
+
}
|
|
20
|
+
} else if (node instanceof HTMLSlotElement) {
|
|
21
|
+
const assigned = node.assignedElements({ flatten: true });
|
|
22
|
+
if (assigned.length > 0) {
|
|
23
|
+
assigned.forEach((a) => {
|
|
24
|
+
walk2(a);
|
|
25
|
+
});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
44
28
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
29
|
+
node.childNodes.forEach((child) => {
|
|
30
|
+
walk2(child);
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
walk2(container);
|
|
34
|
+
} else {
|
|
35
|
+
elements.push(
|
|
36
|
+
...[
|
|
37
|
+
...container.querySelectorAll(FOCUSABLE_SELECTOR)
|
|
38
|
+
].filter(isFocusable)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const cache = /* @__PURE__ */ new WeakMap();
|
|
51
42
|
function sort(elements2) {
|
|
52
43
|
const ordered = [];
|
|
53
44
|
const natural = [];
|
|
@@ -68,7 +59,6 @@ function getFocusables(container = document.body) {
|
|
|
68
59
|
}
|
|
69
60
|
function normalizeRadioGroup(elements2) {
|
|
70
61
|
let map = null;
|
|
71
|
-
const result = [];
|
|
72
62
|
for (const element of elements2) {
|
|
73
63
|
if (element instanceof HTMLInputElement && element.type === "radio" && element.name) {
|
|
74
64
|
if (!map) {
|
|
@@ -76,23 +66,28 @@ function getFocusables(container = document.body) {
|
|
|
76
66
|
}
|
|
77
67
|
const key = `${element.form?.id ?? "no-form"}::${element.name}`;
|
|
78
68
|
(map.get(key) ?? map.set(key, []).get(key)).push(element);
|
|
79
|
-
} else {
|
|
80
|
-
result.push(element);
|
|
81
69
|
}
|
|
82
70
|
}
|
|
83
71
|
if (!map) {
|
|
84
|
-
return
|
|
72
|
+
return elements2;
|
|
85
73
|
}
|
|
74
|
+
const placeholder = /* @__PURE__ */ new Set();
|
|
86
75
|
for (const group of map.values()) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
76
|
+
if (group.length > 0) {
|
|
77
|
+
const enabled = group.filter((radio) => isFocusable(radio));
|
|
78
|
+
if (enabled.length > 0) {
|
|
79
|
+
placeholder.add(
|
|
80
|
+
enabled.find((radio) => radio.checked) ?? enabled[0]
|
|
81
|
+
);
|
|
82
|
+
}
|
|
90
83
|
}
|
|
91
|
-
result.push(
|
|
92
|
-
enabled.find((radio) => radio.checked) ?? enabled[0]
|
|
93
|
-
);
|
|
94
84
|
}
|
|
95
|
-
return
|
|
85
|
+
return elements2.filter((element) => {
|
|
86
|
+
if (element instanceof HTMLInputElement && element.type === "radio" && element.name) {
|
|
87
|
+
return placeholder.has(element);
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
});
|
|
96
91
|
}
|
|
97
92
|
return normalizeRadioGroup(sort(elements));
|
|
98
93
|
}
|
|
@@ -155,12 +150,12 @@ function isFocusable(element) {
|
|
|
155
150
|
});
|
|
156
151
|
}
|
|
157
152
|
function getRelativeFocusable(container, offset = 0, options) {
|
|
158
|
-
const
|
|
153
|
+
const { active, composed = false, wrap = false } = options;
|
|
154
|
+
const focusables = getFocusables(container, { composed });
|
|
159
155
|
const { length } = focusables;
|
|
160
156
|
if (length === 0) {
|
|
161
157
|
return null;
|
|
162
158
|
}
|
|
163
|
-
const { active, wrap = false } = options;
|
|
164
159
|
function getActiveElement() {
|
|
165
160
|
let active2 = document.activeElement;
|
|
166
161
|
while (active2 instanceof HTMLElement && active2.shadowRoot?.activeElement) {
|
|
@@ -193,9 +188,9 @@ function getRelativeFocusable(container, offset = 0, options) {
|
|
|
193
188
|
/**
|
|
194
189
|
* Power Focusable
|
|
195
190
|
* High-precision focus management utility with shadow DOM support.
|
|
196
|
-
* Handles complex focus rules.
|
|
191
|
+
* Handles complex focus rules including tabindex ordering, radio groups, etc.
|
|
197
192
|
*
|
|
198
|
-
* @version
|
|
193
|
+
* @version 2.0.1
|
|
199
194
|
* @author Yusuke Kamiyamane
|
|
200
195
|
* @license MIT
|
|
201
196
|
* @copyright Copyright (c) Yusuke Kamiyamane
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "power-focusable",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "High-precision focus management utility with shadow DOM support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"keywords": [
|
|
27
27
|
"focus",
|
|
28
|
+
"focus-management",
|
|
28
29
|
"focusable",
|
|
29
30
|
"shadow-dom",
|
|
30
31
|
"shadowdom",
|