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 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
- // Starting from a specific element
86
+ // Specifies the starting element
53
87
  getNextFocusable(container, { active: document.querySelector('.button') });
54
88
 
55
- // Wrap to the first element if necessary
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
- // Starting from a specific element
106
+ // Specifies the starting element
70
107
  getPreviousFocusable(container, { active: document.querySelector('.button') });
71
108
 
72
- // Wrap to the last element if necessary
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
- var cache = /* @__PURE__ */ new WeakMap();
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
- 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
- }
10
+ const { composed = false } = options;
29
11
  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;
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
- node.childNodes.forEach((child) => {
49
- walk(child);
50
- });
51
- }
52
- walk(container);
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 result;
74
+ return elements2;
87
75
  }
76
+ const placeholder = /* @__PURE__ */ new Set();
88
77
  for (const group of map.values()) {
89
- const enabled = group.filter((radio) => isFocusable(radio));
90
- if (enabled.length === 0) {
91
- continue;
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 result;
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 focusables = getFocusables(container);
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 1.0.0
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 1.0.0
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 1.0.0
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
- var cache = /* @__PURE__ */ new WeakMap();
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
- 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
- }
8
+ const { composed = false } = options;
27
9
  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;
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
- node.childNodes.forEach((child) => {
47
- walk(child);
48
- });
49
- }
50
- walk(container);
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 result;
72
+ return elements2;
85
73
  }
74
+ const placeholder = /* @__PURE__ */ new Set();
86
75
  for (const group of map.values()) {
87
- const enabled = group.filter((radio) => isFocusable(radio));
88
- if (enabled.length === 0) {
89
- continue;
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 result;
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 focusables = getFocusables(container);
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 1.0.0
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": "1.0.0",
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",