power-focusable 1.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/LICENSE +21 -0
- package/README.md +98 -0
- package/dist/index.cjs +211 -0
- package/dist/index.d.cts +22 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +205 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Yusuke Kamiyamane
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Power Focusable
|
|
2
|
+
|
|
3
|
+
High-precision focus management utility with shadow DOM support. Handles complex focus rules.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i power-focusable
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// npm
|
|
13
|
+
import {
|
|
14
|
+
getFocusables,
|
|
15
|
+
getNextFocusable,
|
|
16
|
+
getPreviousFocusable,
|
|
17
|
+
hasFocusable,
|
|
18
|
+
isFocusable,
|
|
19
|
+
} from 'power-focusable';
|
|
20
|
+
|
|
21
|
+
// CDNs
|
|
22
|
+
import { ... } 'https://esm.sh/power-focusable'
|
|
23
|
+
// or
|
|
24
|
+
import { ... } 'https://cdn.jsdelivr.net/npm/power-focusable/dist/index.js';
|
|
25
|
+
// or
|
|
26
|
+
import { ... } 'https://unpkg.com/power-focusable/dist/index.js';
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 📦 APIs
|
|
30
|
+
|
|
31
|
+
### `getFocusables`
|
|
32
|
+
|
|
33
|
+
Returns all focusable elements within the container.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
getFocusables(container);
|
|
37
|
+
// => HTMLElement[]
|
|
38
|
+
//
|
|
39
|
+
// container (optional): HTMLElement (default: document.body)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### `getNextFocusable`
|
|
43
|
+
|
|
44
|
+
Returns the next focusable element within the container, starting from `document.activeElement`.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
getNextFocusable(container);
|
|
48
|
+
// => HTMLElement | null
|
|
49
|
+
//
|
|
50
|
+
// container (optional): HTMLElement (default: document.body)
|
|
51
|
+
|
|
52
|
+
// Starting from a specific element
|
|
53
|
+
getNextFocusable(container, { active: document.querySelector('.button') });
|
|
54
|
+
|
|
55
|
+
// Wrap to the first element if necessary
|
|
56
|
+
getNextFocusable(container, { wrap: true });
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### `getPreviousFocusable`
|
|
60
|
+
|
|
61
|
+
Returns the previous focusable element within the container, starting from `document.activeElement`.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
getPreviousFocusable(container);
|
|
65
|
+
// => HTMLElement | null
|
|
66
|
+
//
|
|
67
|
+
// container (optional): HTMLElement (default: document.body)
|
|
68
|
+
|
|
69
|
+
// Starting from a specific element
|
|
70
|
+
getPreviousFocusable(container, { active: document.querySelector('.button') });
|
|
71
|
+
|
|
72
|
+
// Wrap to the last element if necessary
|
|
73
|
+
getPreviousFocusable(container, { wrap: true });
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### `hasFocusable`
|
|
78
|
+
|
|
79
|
+
Returns whether the container contains at least one focusable element.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
hasFocusable(container);
|
|
83
|
+
// => boolean
|
|
84
|
+
//
|
|
85
|
+
// container (optional): HTMLElement (default: document.body)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### `isFocusable`
|
|
89
|
+
|
|
90
|
+
Returns whether the given element is focusable.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
isFocusable(element);
|
|
94
|
+
// => boolean
|
|
95
|
+
//
|
|
96
|
+
// element: HTMLElement
|
|
97
|
+
|
|
98
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
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
|
+
var cache = /* @__PURE__ */ new WeakMap();
|
|
6
|
+
function getFocusables(container = document.body) {
|
|
7
|
+
if (!(container instanceof HTMLElement)) {
|
|
8
|
+
console.warn("Invalid container element. Fallback: <body> element.");
|
|
9
|
+
container = document.body;
|
|
10
|
+
}
|
|
11
|
+
function hasShadow(root) {
|
|
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
|
+
}
|
|
29
|
+
const elements = [];
|
|
30
|
+
function walk(node) {
|
|
31
|
+
if (node instanceof HTMLElement) {
|
|
32
|
+
if (isFocusable(node)) {
|
|
33
|
+
elements.push(node);
|
|
34
|
+
}
|
|
35
|
+
const shadow = node.shadowRoot;
|
|
36
|
+
if (shadow && shadow.mode === "open") {
|
|
37
|
+
walk(shadow);
|
|
38
|
+
}
|
|
39
|
+
} else if (node instanceof HTMLSlotElement) {
|
|
40
|
+
const elements2 = node.assignedElements({ flatten: true });
|
|
41
|
+
if (elements2.length > 0) {
|
|
42
|
+
elements2.forEach((element) => {
|
|
43
|
+
walk(element);
|
|
44
|
+
});
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
node.childNodes.forEach((child) => {
|
|
49
|
+
walk(child);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
walk(container);
|
|
53
|
+
function sort(elements2) {
|
|
54
|
+
const ordered = [];
|
|
55
|
+
const natural = [];
|
|
56
|
+
function getTabIndex(element) {
|
|
57
|
+
const cached = cache.get(element);
|
|
58
|
+
if (cached !== void 0) {
|
|
59
|
+
return cached;
|
|
60
|
+
}
|
|
61
|
+
const number = Number(element.getAttribute("tabindex"));
|
|
62
|
+
cache.set(element, number);
|
|
63
|
+
return number;
|
|
64
|
+
}
|
|
65
|
+
elements2.forEach((element) => {
|
|
66
|
+
(getTabIndex(element) > 0 ? ordered : natural).push(element);
|
|
67
|
+
});
|
|
68
|
+
ordered.sort((a, b) => getTabIndex(a) - getTabIndex(b));
|
|
69
|
+
return [...ordered, ...natural];
|
|
70
|
+
}
|
|
71
|
+
function normalizeRadioGroup(elements2) {
|
|
72
|
+
let map = null;
|
|
73
|
+
const result = [];
|
|
74
|
+
for (const element of elements2) {
|
|
75
|
+
if (element instanceof HTMLInputElement && element.type === "radio" && element.name) {
|
|
76
|
+
if (!map) {
|
|
77
|
+
map = /* @__PURE__ */ new Map();
|
|
78
|
+
}
|
|
79
|
+
const key = `${element.form?.id ?? "no-form"}::${element.name}`;
|
|
80
|
+
(map.get(key) ?? map.set(key, []).get(key)).push(element);
|
|
81
|
+
} else {
|
|
82
|
+
result.push(element);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!map) {
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
for (const group of map.values()) {
|
|
89
|
+
const enabled = group.filter((radio) => isFocusable(radio));
|
|
90
|
+
if (enabled.length === 0) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
result.push(
|
|
94
|
+
enabled.find((radio) => radio.checked) ?? enabled[0]
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
return normalizeRadioGroup(sort(elements));
|
|
100
|
+
}
|
|
101
|
+
function getNextFocusable(container = document.body, options = {}) {
|
|
102
|
+
if (!(container instanceof HTMLElement)) {
|
|
103
|
+
console.warn("Invalid container element. Fallback: <body> element.");
|
|
104
|
+
container = document.body;
|
|
105
|
+
}
|
|
106
|
+
return getRelativeFocusable(container, 1, options);
|
|
107
|
+
}
|
|
108
|
+
function getPreviousFocusable(container = document.body, options = {}) {
|
|
109
|
+
if (!(container instanceof HTMLElement)) {
|
|
110
|
+
console.warn("Invalid container element. Fallback: <body> element.");
|
|
111
|
+
container = document.body;
|
|
112
|
+
}
|
|
113
|
+
return getRelativeFocusable(container, -1, options);
|
|
114
|
+
}
|
|
115
|
+
function hasFocusable(container = document.body) {
|
|
116
|
+
if (!(container instanceof HTMLElement)) {
|
|
117
|
+
console.warn("Invalid container element. Fallback: <body> element.");
|
|
118
|
+
container = document.body;
|
|
119
|
+
}
|
|
120
|
+
return getFocusables(container).length > 0;
|
|
121
|
+
}
|
|
122
|
+
function isFocusable(element) {
|
|
123
|
+
if (!(element instanceof HTMLElement)) {
|
|
124
|
+
console.warn("Invalid element");
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
function isDisabledDeep(element2) {
|
|
128
|
+
function isFormControl(element3) {
|
|
129
|
+
return /^(BUTTON|INPUT|SELECT|TEXTAREA)$/.test(element3.tagName);
|
|
130
|
+
}
|
|
131
|
+
function isDisabled(element3) {
|
|
132
|
+
return "disabled" in element3 && element3.disabled;
|
|
133
|
+
}
|
|
134
|
+
for (let current = element2; current; current = current instanceof ShadowRoot ? current.mode === "open" ? current.host : null : current.parentNode) {
|
|
135
|
+
if (!(current instanceof Element)) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (current === element2 && isFormControl(current) && isDisabled(current)) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
if (current.matches("[inert]")) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
if (isFormControl(element2) && current.tagName === "FIELDSET" && isDisabled(current)) {
|
|
145
|
+
if (current.querySelector(":scope > legend:first-of-type")?.contains(element2)) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
return element.matches(FOCUSABLE_SELECTOR) && !isDisabledDeep(element) && element.checkVisibility({
|
|
154
|
+
contentVisibilityAuto: true,
|
|
155
|
+
opacityProperty: true,
|
|
156
|
+
visibilityProperty: true
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
function getRelativeFocusable(container, offset = 0, options) {
|
|
160
|
+
const focusables = getFocusables(container);
|
|
161
|
+
const { length } = focusables;
|
|
162
|
+
if (length === 0) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const { active, wrap = false } = options;
|
|
166
|
+
function getActiveElement() {
|
|
167
|
+
let active2 = document.activeElement;
|
|
168
|
+
while (active2 instanceof HTMLElement && active2.shadowRoot?.activeElement) {
|
|
169
|
+
active2 = active2.shadowRoot.activeElement;
|
|
170
|
+
}
|
|
171
|
+
return active2 instanceof HTMLElement ? active2 : null;
|
|
172
|
+
}
|
|
173
|
+
const current = active ?? getActiveElement();
|
|
174
|
+
function containsDeep(container2, node) {
|
|
175
|
+
for (let current2 = node; current2; current2 = !(current2 instanceof ShadowRoot) ? current2.parentNode : current2.mode === "open" ? current2.host : null) {
|
|
176
|
+
if (current2 === container2) {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
if (!current || !containsDeep(container, current)) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const currentIndex = focusables.indexOf(current);
|
|
186
|
+
if (currentIndex === -1) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const offsetIndex = currentIndex + offset;
|
|
190
|
+
if ((offsetIndex < 0 || offsetIndex >= length) && !wrap) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
return focusables[(offsetIndex + length) % length] ?? null;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Power Focusable
|
|
197
|
+
* High-precision focus management utility with shadow DOM support.
|
|
198
|
+
* Handles complex focus rules.
|
|
199
|
+
*
|
|
200
|
+
* @version 1.0.0
|
|
201
|
+
* @author Yusuke Kamiyamane
|
|
202
|
+
* @license MIT
|
|
203
|
+
* @copyright Copyright (c) Yusuke Kamiyamane
|
|
204
|
+
* @see {@link https://github.com/y14e/power-focusable}
|
|
205
|
+
*/
|
|
206
|
+
|
|
207
|
+
exports.getFocusables = getFocusables;
|
|
208
|
+
exports.getNextFocusable = getNextFocusable;
|
|
209
|
+
exports.getPreviousFocusable = getPreviousFocusable;
|
|
210
|
+
exports.hasFocusable = hasFocusable;
|
|
211
|
+
exports.isFocusable = isFocusable;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Power Focusable
|
|
3
|
+
* High-precision focus management utility with shadow DOM support.
|
|
4
|
+
* Handles complex focus rules.
|
|
5
|
+
*
|
|
6
|
+
* @version 1.0.0
|
|
7
|
+
* @author Yusuke Kamiyamane
|
|
8
|
+
* @license MIT
|
|
9
|
+
* @copyright Copyright (c) Yusuke Kamiyamane
|
|
10
|
+
* @see {@link https://github.com/y14e/power-focusable}
|
|
11
|
+
*/
|
|
12
|
+
interface PowerFocusableOptions {
|
|
13
|
+
readonly active?: HTMLElement | null;
|
|
14
|
+
readonly wrap?: boolean;
|
|
15
|
+
}
|
|
16
|
+
declare function getFocusables(container?: HTMLElement): HTMLElement[];
|
|
17
|
+
declare function getNextFocusable(container?: HTMLElement, options?: PowerFocusableOptions): HTMLElement | null;
|
|
18
|
+
declare function getPreviousFocusable(container?: HTMLElement, options?: PowerFocusableOptions): HTMLElement | null;
|
|
19
|
+
declare function hasFocusable(container?: HTMLElement): boolean;
|
|
20
|
+
declare function isFocusable(element: HTMLElement): boolean;
|
|
21
|
+
|
|
22
|
+
export { type PowerFocusableOptions, getFocusables, getNextFocusable, getPreviousFocusable, hasFocusable, isFocusable };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Power Focusable
|
|
3
|
+
* High-precision focus management utility with shadow DOM support.
|
|
4
|
+
* Handles complex focus rules.
|
|
5
|
+
*
|
|
6
|
+
* @version 1.0.0
|
|
7
|
+
* @author Yusuke Kamiyamane
|
|
8
|
+
* @license MIT
|
|
9
|
+
* @copyright Copyright (c) Yusuke Kamiyamane
|
|
10
|
+
* @see {@link https://github.com/y14e/power-focusable}
|
|
11
|
+
*/
|
|
12
|
+
interface PowerFocusableOptions {
|
|
13
|
+
readonly active?: HTMLElement | null;
|
|
14
|
+
readonly wrap?: boolean;
|
|
15
|
+
}
|
|
16
|
+
declare function getFocusables(container?: HTMLElement): HTMLElement[];
|
|
17
|
+
declare function getNextFocusable(container?: HTMLElement, options?: PowerFocusableOptions): HTMLElement | null;
|
|
18
|
+
declare function getPreviousFocusable(container?: HTMLElement, options?: PowerFocusableOptions): HTMLElement | null;
|
|
19
|
+
declare function hasFocusable(container?: HTMLElement): boolean;
|
|
20
|
+
declare function isFocusable(element: HTMLElement): boolean;
|
|
21
|
+
|
|
22
|
+
export { type PowerFocusableOptions, getFocusables, getNextFocusable, getPreviousFocusable, hasFocusable, isFocusable };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// src/index.ts
|
|
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
|
+
var cache = /* @__PURE__ */ new WeakMap();
|
|
4
|
+
function getFocusables(container = document.body) {
|
|
5
|
+
if (!(container instanceof HTMLElement)) {
|
|
6
|
+
console.warn("Invalid container element. Fallback: <body> element.");
|
|
7
|
+
container = document.body;
|
|
8
|
+
}
|
|
9
|
+
function hasShadow(root) {
|
|
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
|
+
}
|
|
27
|
+
const elements = [];
|
|
28
|
+
function walk(node) {
|
|
29
|
+
if (node instanceof HTMLElement) {
|
|
30
|
+
if (isFocusable(node)) {
|
|
31
|
+
elements.push(node);
|
|
32
|
+
}
|
|
33
|
+
const shadow = node.shadowRoot;
|
|
34
|
+
if (shadow && shadow.mode === "open") {
|
|
35
|
+
walk(shadow);
|
|
36
|
+
}
|
|
37
|
+
} else if (node instanceof HTMLSlotElement) {
|
|
38
|
+
const elements2 = node.assignedElements({ flatten: true });
|
|
39
|
+
if (elements2.length > 0) {
|
|
40
|
+
elements2.forEach((element) => {
|
|
41
|
+
walk(element);
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
node.childNodes.forEach((child) => {
|
|
47
|
+
walk(child);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
walk(container);
|
|
51
|
+
function sort(elements2) {
|
|
52
|
+
const ordered = [];
|
|
53
|
+
const natural = [];
|
|
54
|
+
function getTabIndex(element) {
|
|
55
|
+
const cached = cache.get(element);
|
|
56
|
+
if (cached !== void 0) {
|
|
57
|
+
return cached;
|
|
58
|
+
}
|
|
59
|
+
const number = Number(element.getAttribute("tabindex"));
|
|
60
|
+
cache.set(element, number);
|
|
61
|
+
return number;
|
|
62
|
+
}
|
|
63
|
+
elements2.forEach((element) => {
|
|
64
|
+
(getTabIndex(element) > 0 ? ordered : natural).push(element);
|
|
65
|
+
});
|
|
66
|
+
ordered.sort((a, b) => getTabIndex(a) - getTabIndex(b));
|
|
67
|
+
return [...ordered, ...natural];
|
|
68
|
+
}
|
|
69
|
+
function normalizeRadioGroup(elements2) {
|
|
70
|
+
let map = null;
|
|
71
|
+
const result = [];
|
|
72
|
+
for (const element of elements2) {
|
|
73
|
+
if (element instanceof HTMLInputElement && element.type === "radio" && element.name) {
|
|
74
|
+
if (!map) {
|
|
75
|
+
map = /* @__PURE__ */ new Map();
|
|
76
|
+
}
|
|
77
|
+
const key = `${element.form?.id ?? "no-form"}::${element.name}`;
|
|
78
|
+
(map.get(key) ?? map.set(key, []).get(key)).push(element);
|
|
79
|
+
} else {
|
|
80
|
+
result.push(element);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!map) {
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
for (const group of map.values()) {
|
|
87
|
+
const enabled = group.filter((radio) => isFocusable(radio));
|
|
88
|
+
if (enabled.length === 0) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
result.push(
|
|
92
|
+
enabled.find((radio) => radio.checked) ?? enabled[0]
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
return normalizeRadioGroup(sort(elements));
|
|
98
|
+
}
|
|
99
|
+
function getNextFocusable(container = document.body, options = {}) {
|
|
100
|
+
if (!(container instanceof HTMLElement)) {
|
|
101
|
+
console.warn("Invalid container element. Fallback: <body> element.");
|
|
102
|
+
container = document.body;
|
|
103
|
+
}
|
|
104
|
+
return getRelativeFocusable(container, 1, options);
|
|
105
|
+
}
|
|
106
|
+
function getPreviousFocusable(container = document.body, options = {}) {
|
|
107
|
+
if (!(container instanceof HTMLElement)) {
|
|
108
|
+
console.warn("Invalid container element. Fallback: <body> element.");
|
|
109
|
+
container = document.body;
|
|
110
|
+
}
|
|
111
|
+
return getRelativeFocusable(container, -1, options);
|
|
112
|
+
}
|
|
113
|
+
function hasFocusable(container = document.body) {
|
|
114
|
+
if (!(container instanceof HTMLElement)) {
|
|
115
|
+
console.warn("Invalid container element. Fallback: <body> element.");
|
|
116
|
+
container = document.body;
|
|
117
|
+
}
|
|
118
|
+
return getFocusables(container).length > 0;
|
|
119
|
+
}
|
|
120
|
+
function isFocusable(element) {
|
|
121
|
+
if (!(element instanceof HTMLElement)) {
|
|
122
|
+
console.warn("Invalid element");
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
function isDisabledDeep(element2) {
|
|
126
|
+
function isFormControl(element3) {
|
|
127
|
+
return /^(BUTTON|INPUT|SELECT|TEXTAREA)$/.test(element3.tagName);
|
|
128
|
+
}
|
|
129
|
+
function isDisabled(element3) {
|
|
130
|
+
return "disabled" in element3 && element3.disabled;
|
|
131
|
+
}
|
|
132
|
+
for (let current = element2; current; current = current instanceof ShadowRoot ? current.mode === "open" ? current.host : null : current.parentNode) {
|
|
133
|
+
if (!(current instanceof Element)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (current === element2 && isFormControl(current) && isDisabled(current)) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
if (current.matches("[inert]")) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
if (isFormControl(element2) && current.tagName === "FIELDSET" && isDisabled(current)) {
|
|
143
|
+
if (current.querySelector(":scope > legend:first-of-type")?.contains(element2)) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
return element.matches(FOCUSABLE_SELECTOR) && !isDisabledDeep(element) && element.checkVisibility({
|
|
152
|
+
contentVisibilityAuto: true,
|
|
153
|
+
opacityProperty: true,
|
|
154
|
+
visibilityProperty: true
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function getRelativeFocusable(container, offset = 0, options) {
|
|
158
|
+
const focusables = getFocusables(container);
|
|
159
|
+
const { length } = focusables;
|
|
160
|
+
if (length === 0) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const { active, wrap = false } = options;
|
|
164
|
+
function getActiveElement() {
|
|
165
|
+
let active2 = document.activeElement;
|
|
166
|
+
while (active2 instanceof HTMLElement && active2.shadowRoot?.activeElement) {
|
|
167
|
+
active2 = active2.shadowRoot.activeElement;
|
|
168
|
+
}
|
|
169
|
+
return active2 instanceof HTMLElement ? active2 : null;
|
|
170
|
+
}
|
|
171
|
+
const current = active ?? getActiveElement();
|
|
172
|
+
function containsDeep(container2, node) {
|
|
173
|
+
for (let current2 = node; current2; current2 = !(current2 instanceof ShadowRoot) ? current2.parentNode : current2.mode === "open" ? current2.host : null) {
|
|
174
|
+
if (current2 === container2) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
if (!current || !containsDeep(container, current)) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const currentIndex = focusables.indexOf(current);
|
|
184
|
+
if (currentIndex === -1) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
const offsetIndex = currentIndex + offset;
|
|
188
|
+
if ((offsetIndex < 0 || offsetIndex >= length) && !wrap) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
return focusables[(offsetIndex + length) % length] ?? null;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Power Focusable
|
|
195
|
+
* High-precision focus management utility with shadow DOM support.
|
|
196
|
+
* Handles complex focus rules.
|
|
197
|
+
*
|
|
198
|
+
* @version 1.0.0
|
|
199
|
+
* @author Yusuke Kamiyamane
|
|
200
|
+
* @license MIT
|
|
201
|
+
* @copyright Copyright (c) Yusuke Kamiyamane
|
|
202
|
+
* @see {@link https://github.com/y14e/power-focusable}
|
|
203
|
+
*/
|
|
204
|
+
|
|
205
|
+
export { getFocusables, getNextFocusable, getPreviousFocusable, hasFocusable, isFocusable };
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "power-focusable",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "High-precision focus management utility with shadow DOM support",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsup",
|
|
23
|
+
"prepublishOnly": "npm run build",
|
|
24
|
+
"lint": "tsc --noEmit"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"focus",
|
|
28
|
+
"focusable",
|
|
29
|
+
"shadow-dom",
|
|
30
|
+
"shadowdom",
|
|
31
|
+
"a11y",
|
|
32
|
+
"accessibility",
|
|
33
|
+
"typescript",
|
|
34
|
+
"utility"
|
|
35
|
+
],
|
|
36
|
+
"author": "Yusuke Kamiyamane",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/y14e/power-focusable.git"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/y14e/power-focusable/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/y14e/power-focusable#readme",
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"bun-types": "latest",
|
|
48
|
+
"tsup": "^8.0.0",
|
|
49
|
+
"typescript": "^5.6.0"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=18"
|
|
53
|
+
}
|
|
54
|
+
}
|