@hypen-space/web 0.4.980 → 0.5.3

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.
Files changed (229) hide show
  1. package/README.md +20 -13
  2. package/dist/canvas/accessibility.d.ts +118 -11
  3. package/dist/canvas/accessibility.js +470 -108
  4. package/dist/canvas/accessibility.js.map +1 -1
  5. package/dist/canvas/dispatch.d.ts +24 -0
  6. package/dist/canvas/dispatch.js +62 -0
  7. package/dist/canvas/dispatch.js.map +1 -0
  8. package/dist/canvas/editing.d.ts +178 -0
  9. package/dist/canvas/editing.js +590 -0
  10. package/dist/canvas/editing.js.map +1 -0
  11. package/dist/canvas/events.d.ts +28 -23
  12. package/dist/canvas/events.js +90 -103
  13. package/dist/canvas/events.js.map +1 -1
  14. package/dist/canvas/focus.d.ts +78 -0
  15. package/dist/canvas/focus.js +182 -0
  16. package/dist/canvas/focus.js.map +1 -0
  17. package/dist/canvas/index.d.ts +3 -2
  18. package/dist/canvas/index.js +3 -2
  19. package/dist/canvas/index.js.map +1 -1
  20. package/dist/canvas/layout.js +68 -98
  21. package/dist/canvas/layout.js.map +1 -1
  22. package/dist/canvas/paint.d.ts +16 -3
  23. package/dist/canvas/paint.js +449 -110
  24. package/dist/canvas/paint.js.map +1 -1
  25. package/dist/canvas/renderer.d.ts +28 -4
  26. package/dist/canvas/renderer.js +201 -81
  27. package/dist/canvas/renderer.js.map +1 -1
  28. package/dist/canvas/selection.js +31 -177
  29. package/dist/canvas/selection.js.map +1 -1
  30. package/dist/canvas/text-geometry.d.ts +72 -0
  31. package/dist/canvas/text-geometry.js +244 -0
  32. package/dist/canvas/text-geometry.js.map +1 -0
  33. package/dist/canvas/text.js +44 -18
  34. package/dist/canvas/text.js.map +1 -1
  35. package/dist/canvas/types.d.ts +10 -1
  36. package/dist/canvas/utils.d.ts +0 -23
  37. package/dist/canvas/utils.js +3 -67
  38. package/dist/canvas/utils.js.map +1 -1
  39. package/dist/canvas/variants.d.ts +52 -0
  40. package/dist/canvas/variants.js +156 -0
  41. package/dist/canvas/variants.js.map +1 -0
  42. package/dist/dom/a11y-styles.d.ts +20 -0
  43. package/dist/dom/a11y-styles.js +61 -0
  44. package/dist/dom/a11y-styles.js.map +1 -0
  45. package/dist/dom/applicators/aria.d.ts +19 -0
  46. package/dist/dom/applicators/aria.js +36 -0
  47. package/dist/dom/applicators/aria.js.map +1 -0
  48. package/dist/dom/applicators/events.d.ts +7 -0
  49. package/dist/dom/applicators/events.js +36 -8
  50. package/dist/dom/applicators/events.js.map +1 -1
  51. package/dist/dom/applicators/font.d.ts +11 -6
  52. package/dist/dom/applicators/font.js +16 -27
  53. package/dist/dom/applicators/font.js.map +1 -1
  54. package/dist/dom/applicators/index.js +51 -45
  55. package/dist/dom/applicators/index.js.map +1 -1
  56. package/dist/dom/canvas/index.js +2 -2
  57. package/dist/dom/canvas/index.js.map +1 -1
  58. package/dist/dom/components/app.js +2 -2
  59. package/dist/dom/components/app.js.map +1 -1
  60. package/dist/dom/components/audio.js +2 -2
  61. package/dist/dom/components/audio.js.map +1 -1
  62. package/dist/dom/components/avatar.js +3 -4
  63. package/dist/dom/components/avatar.js.map +1 -1
  64. package/dist/dom/components/badge.js +3 -4
  65. package/dist/dom/components/badge.js.map +1 -1
  66. package/dist/dom/components/button.js +2 -2
  67. package/dist/dom/components/button.js.map +1 -1
  68. package/dist/dom/components/card.js +2 -2
  69. package/dist/dom/components/card.js.map +1 -1
  70. package/dist/dom/components/center.js +2 -2
  71. package/dist/dom/components/center.js.map +1 -1
  72. package/dist/dom/components/checkbox.js +4 -5
  73. package/dist/dom/components/checkbox.js.map +1 -1
  74. package/dist/dom/components/column.js +2 -2
  75. package/dist/dom/components/column.js.map +1 -1
  76. package/dist/dom/components/container.js +2 -2
  77. package/dist/dom/components/container.js.map +1 -1
  78. package/dist/dom/components/divider.js +2 -2
  79. package/dist/dom/components/divider.js.map +1 -1
  80. package/dist/dom/components/grid.js +10 -10
  81. package/dist/dom/components/grid.js.map +1 -1
  82. package/dist/dom/components/heading.js +4 -6
  83. package/dist/dom/components/heading.js.map +1 -1
  84. package/dist/dom/components/hypenapp.d.ts +25 -1
  85. package/dist/dom/components/hypenapp.js +215 -247
  86. package/dist/dom/components/hypenapp.js.map +1 -1
  87. package/dist/dom/components/icon.js +4 -5
  88. package/dist/dom/components/icon.js.map +1 -1
  89. package/dist/dom/components/image.js +2 -2
  90. package/dist/dom/components/image.js.map +1 -1
  91. package/dist/dom/components/index.d.ts +2 -2
  92. package/dist/dom/components/index.js +9 -2
  93. package/dist/dom/components/index.js.map +1 -1
  94. package/dist/dom/components/input.js +2 -2
  95. package/dist/dom/components/input.js.map +1 -1
  96. package/dist/dom/components/link.js +2 -2
  97. package/dist/dom/components/link.js.map +1 -1
  98. package/dist/dom/components/list.js +2 -2
  99. package/dist/dom/components/list.js.map +1 -1
  100. package/dist/dom/components/paragraph.js +3 -4
  101. package/dist/dom/components/paragraph.js.map +1 -1
  102. package/dist/dom/components/progressbar.js +3 -3
  103. package/dist/dom/components/progressbar.js.map +1 -1
  104. package/dist/dom/components/route.js +2 -2
  105. package/dist/dom/components/route.js.map +1 -1
  106. package/dist/dom/components/router.js +2 -2
  107. package/dist/dom/components/router.js.map +1 -1
  108. package/dist/dom/components/row.js +10 -10
  109. package/dist/dom/components/row.js.map +1 -1
  110. package/dist/dom/components/select.js +3 -4
  111. package/dist/dom/components/select.js.map +1 -1
  112. package/dist/dom/components/slider.js +2 -2
  113. package/dist/dom/components/slider.js.map +1 -1
  114. package/dist/dom/components/spacer.js +2 -2
  115. package/dist/dom/components/spacer.js.map +1 -1
  116. package/dist/dom/components/spinner.js +27 -13
  117. package/dist/dom/components/spinner.js.map +1 -1
  118. package/dist/dom/components/stack.js +10 -10
  119. package/dist/dom/components/stack.js.map +1 -1
  120. package/dist/dom/components/switch.js +5 -6
  121. package/dist/dom/components/switch.js.map +1 -1
  122. package/dist/dom/components/tabs.d.ts +27 -0
  123. package/dist/dom/components/tabs.js +69 -0
  124. package/dist/dom/components/tabs.js.map +1 -0
  125. package/dist/dom/components/text.js +4 -7
  126. package/dist/dom/components/text.js.map +1 -1
  127. package/dist/dom/components/textarea.js +2 -2
  128. package/dist/dom/components/textarea.js.map +1 -1
  129. package/dist/dom/components/video.js +2 -2
  130. package/dist/dom/components/video.js.map +1 -1
  131. package/dist/dom/components/visuallyhidden.d.ts +9 -0
  132. package/dist/dom/components/visuallyhidden.js +26 -0
  133. package/dist/dom/components/visuallyhidden.js.map +1 -0
  134. package/dist/dom/debug.js +6 -8
  135. package/dist/dom/debug.js.map +1 -1
  136. package/dist/dom/index.d.ts +0 -1
  137. package/dist/dom/index.js +0 -1
  138. package/dist/dom/index.js.map +1 -1
  139. package/dist/dom/operability.d.ts +100 -0
  140. package/dist/dom/operability.js +298 -0
  141. package/dist/dom/operability.js.map +1 -0
  142. package/dist/dom/renderer.d.ts +98 -5
  143. package/dist/dom/renderer.js +400 -66
  144. package/dist/dom/renderer.js.map +1 -1
  145. package/dist/dom/route-focus.d.ts +42 -0
  146. package/dist/dom/route-focus.js +88 -0
  147. package/dist/dom/route-focus.js.map +1 -0
  148. package/dist/dom/semantics.d.ts +35 -0
  149. package/dist/dom/semantics.js +184 -0
  150. package/dist/dom/semantics.js.map +1 -0
  151. package/dist/variants.d.ts +90 -0
  152. package/dist/variants.js +206 -0
  153. package/dist/variants.js.map +1 -0
  154. package/package.json +2 -2
  155. package/src/canvas/QUICKSTART.md +16 -11
  156. package/src/canvas/README.md +63 -34
  157. package/src/canvas/accessibility.ts +507 -115
  158. package/src/canvas/dispatch.ts +78 -0
  159. package/src/canvas/editing.ts +697 -0
  160. package/src/canvas/events.ts +104 -115
  161. package/src/canvas/focus.ts +216 -0
  162. package/src/canvas/index.ts +8 -9
  163. package/src/canvas/layout.ts +68 -103
  164. package/src/canvas/paint.ts +509 -109
  165. package/src/canvas/renderer.ts +239 -99
  166. package/src/canvas/selection.ts +52 -210
  167. package/src/canvas/text-geometry.ts +311 -0
  168. package/src/canvas/text.ts +45 -18
  169. package/src/canvas/types.ts +28 -1
  170. package/src/canvas/utils.ts +3 -69
  171. package/src/canvas/variants.ts +172 -0
  172. package/src/dom/README.md +8 -7
  173. package/src/dom/a11y-styles.ts +65 -0
  174. package/src/dom/applicators/aria.ts +41 -0
  175. package/src/dom/applicators/events.ts +39 -7
  176. package/src/dom/applicators/font.ts +16 -29
  177. package/src/dom/applicators/index.ts +61 -46
  178. package/src/dom/canvas/index.ts +2 -2
  179. package/src/dom/components/app.ts +2 -2
  180. package/src/dom/components/audio.ts +2 -2
  181. package/src/dom/components/avatar.ts +3 -4
  182. package/src/dom/components/badge.ts +3 -4
  183. package/src/dom/components/button.ts +2 -2
  184. package/src/dom/components/card.ts +2 -2
  185. package/src/dom/components/center.ts +2 -2
  186. package/src/dom/components/checkbox.ts +4 -5
  187. package/src/dom/components/column.ts +2 -2
  188. package/src/dom/components/container.ts +2 -2
  189. package/src/dom/components/divider.ts +2 -2
  190. package/src/dom/components/grid.ts +10 -10
  191. package/src/dom/components/heading.ts +4 -6
  192. package/src/dom/components/hypenapp.ts +246 -274
  193. package/src/dom/components/icon.ts +4 -5
  194. package/src/dom/components/image.ts +2 -2
  195. package/src/dom/components/index.ts +10 -3
  196. package/src/dom/components/input.ts +2 -2
  197. package/src/dom/components/link.ts +2 -2
  198. package/src/dom/components/list.ts +2 -2
  199. package/src/dom/components/paragraph.ts +3 -4
  200. package/src/dom/components/progressbar.ts +3 -3
  201. package/src/dom/components/route.ts +2 -2
  202. package/src/dom/components/router.ts +2 -2
  203. package/src/dom/components/row.ts +10 -10
  204. package/src/dom/components/select.ts +3 -4
  205. package/src/dom/components/slider.ts +2 -2
  206. package/src/dom/components/spacer.ts +2 -2
  207. package/src/dom/components/spinner.ts +34 -16
  208. package/src/dom/components/stack.ts +10 -10
  209. package/src/dom/components/switch.ts +5 -6
  210. package/src/dom/components/tabs.ts +76 -0
  211. package/src/dom/components/text.ts +4 -7
  212. package/src/dom/components/textarea.ts +2 -2
  213. package/src/dom/components/video.ts +2 -2
  214. package/src/dom/components/visuallyhidden.ts +30 -0
  215. package/src/dom/debug.ts +6 -8
  216. package/src/dom/index.ts +0 -8
  217. package/src/dom/operability.ts +312 -0
  218. package/src/dom/renderer.ts +457 -66
  219. package/src/dom/route-focus.ts +98 -0
  220. package/src/dom/semantics.ts +199 -0
  221. package/src/variants.ts +249 -0
  222. package/dist/canvas/input.d.ts +0 -76
  223. package/dist/canvas/input.js +0 -207
  224. package/dist/canvas/input.js.map +0 -1
  225. package/dist/client.d.ts +0 -36
  226. package/dist/client.js +0 -47
  227. package/dist/client.js.map +0 -1
  228. package/src/canvas/input.ts +0 -251
  229. package/src/client.ts +0 -88
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Keyboard operability for actionable elements.
3
+ *
4
+ * Native interactive elements (`<button>`, `<a href>`) are keyboard-operable
5
+ * for free — the browser activates them on Enter/Space. But Hypen's actionable
6
+ * `Card` renders a plain `<div>`, which is neither focusable nor
7
+ * keyboard-activatable. This module closes that gap: a non-native actionable
8
+ * host is given a `button` role, made focusable (`tabindex=0`), and wired so
9
+ * Enter/Space dispatch the same action as a click.
10
+ *
11
+ * This is intentionally client-side: focus and key handling are synchronous
12
+ * interactions the streaming engine cannot mediate.
13
+ */
14
+
15
+ import { triggerElementAction } from "./applicators/events.js";
16
+ import { getMeta, setMeta } from "./element-data.js";
17
+ import { focusRouteTarget } from "./route-focus.js";
18
+
19
+ /** Host tags that the browser already makes keyboard-operable. */
20
+ const NATIVE_INTERACTIVE = new Set(["BUTTON", "A"]);
21
+
22
+ /**
23
+ * Make a non-native actionable element keyboard-operable. Idempotent across
24
+ * re-renders (guarded by a dataset flag). No-op for native interactive tags.
25
+ */
26
+ export function makeKeyboardActivatable(element: HTMLElement, actionValue: unknown): void {
27
+ const tag = element.tagName?.toUpperCase();
28
+ if (tag && NATIVE_INTERACTIVE.has(tag)) return;
29
+
30
+ // The keydown listener reads the action from element meta, so a later
31
+ // re-apply with a different action retargets keyboard activation exactly
32
+ // as it does the click listener — no stale action, no second listener.
33
+ setMeta(element, "kbdAction", actionValue);
34
+
35
+ // Wire the keydown listener only once.
36
+ if (element.dataset?.hypenKbd) return;
37
+ if (element.dataset) element.dataset.hypenKbd = "1";
38
+
39
+ // Announce as a button, make it reachable, and activate on Enter/Space.
40
+ element.setAttribute("role", "button");
41
+ element.tabIndex = 0;
42
+
43
+ element.addEventListener("keydown", (event: Event) => {
44
+ const key = (event as KeyboardEvent).key;
45
+ if (key === "Enter" || key === " " || key === "Spacebar") {
46
+ // Space would otherwise scroll the page.
47
+ event.preventDefault?.();
48
+ triggerElementAction(element, getMeta(element, "kbdAction"));
49
+ }
50
+ });
51
+ }
52
+
53
+ /** CSS selector for the elements that can hold keyboard focus. */
54
+ const FOCUSABLE_SELECTOR = "a,button,input,textarea,select,[tabindex]";
55
+
56
+ /**
57
+ * Pick the focus target for a Tab keypress within a trapped container, wrapping
58
+ * at the ends.
59
+ *
60
+ * Pure over the ordered list of focusable descendants so it is testable without
61
+ * a real DOM: given the currently-focused element (`active`) and the Shift
62
+ * state, returns the element that should receive focus next, or `null` when
63
+ * there is nothing to focus. Tab past the last element wraps to the first;
64
+ * Shift+Tab past the first wraps to the last. When focus is outside the set,
65
+ * Tab lands on the first element and Shift+Tab on the last.
66
+ */
67
+ export function nextTrapFocus<T>(focusable: T[], active: T | null, shift: boolean): T | null {
68
+ if (focusable.length === 0) return null;
69
+ const first = focusable[0]!;
70
+ const last = focusable[focusable.length - 1]!;
71
+ const index = active == null ? -1 : focusable.indexOf(active);
72
+
73
+ if (index === -1) return shift ? last : first;
74
+ if (shift) return index === 0 ? last : focusable[index - 1]!;
75
+ return index === focusable.length - 1 ? first : focusable[index + 1]!;
76
+ }
77
+
78
+ /**
79
+ * Pick the tab that should receive focus for a keypress inside a roving
80
+ * tablist (WAI-ARIA APG "Tabs" keyboard contract). Pure over the ordered tab
81
+ * list so it is testable without a real DOM: Arrow Right/Down move to the
82
+ * next tab (wrapping), Arrow Left/Up to the previous (wrapping), Home/End to
83
+ * the first/last. Any other key — or an empty list — returns `null` (leave
84
+ * focus alone).
85
+ */
86
+ export function nextRovingFocus<T>(tabs: T[], active: T | null, key: string): T | null {
87
+ if (tabs.length === 0) return null;
88
+ const first = tabs[0]!;
89
+ const last = tabs[tabs.length - 1]!;
90
+ if (key === "Home") return first;
91
+ if (key === "End") return last;
92
+
93
+ const forward = key === "ArrowRight" || key === "ArrowDown";
94
+ const backward = key === "ArrowLeft" || key === "ArrowUp";
95
+ if (!forward && !backward) return null;
96
+
97
+ const index = active == null ? -1 : tabs.indexOf(active);
98
+ if (index === -1) return first;
99
+ if (forward) return index === tabs.length - 1 ? first : tabs[index + 1]!;
100
+ return index === 0 ? last : tabs[index - 1]!;
101
+ }
102
+
103
+ /**
104
+ * Pick the item typeahead should move focus to (WAI-ARIA APG first-character
105
+ * navigation). Pure over the ordered item list so it is testable without a
106
+ * real DOM: the search is a case-insensitive `startsWith` over `textOf(item)`,
107
+ * starting from the item AFTER `active` and wrapping; `active` itself is the
108
+ * last candidate, so a query the current item already matches keeps focus in
109
+ * place (multi-char accumulation refining onto the same item). Returns `null`
110
+ * when nothing matches or the query is empty. Query accumulation and its
111
+ * timeout live at the caller.
112
+ */
113
+ export function nextTypeaheadFocus<T>(
114
+ items: T[],
115
+ active: T | null,
116
+ query: string,
117
+ textOf: (item: T) => string,
118
+ ): T | null {
119
+ if (items.length === 0 || query.length === 0) return null;
120
+ const q = query.toLowerCase();
121
+ const start = active == null ? -1 : items.indexOf(active);
122
+ for (let step = 1; step <= items.length; step++) {
123
+ const item = items[(start + step + items.length) % items.length]!;
124
+ if (textOf(item).trim().toLowerCase().startsWith(q)) return item;
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /** Pause after which an accumulated typeahead query starts over. */
130
+ const TYPEAHEAD_RESET_MS = 500;
131
+
132
+ /**
133
+ * Shared roving-tabindex installer for composite widgets (tablist, listbox):
134
+ * arrow keys move focus among the `itemSelector` descendants (wrapping),
135
+ * Home/End jump to the ends, and only the focused item stays in the page Tab
136
+ * order (`tabindex=0`; the rest drop to `-1`) so a single Tab keypress leaves
137
+ * the widget. Printable characters accumulate into a short-lived typeahead
138
+ * query (reset after a 500ms pause, tracked by timestamp — no timers) that
139
+ * roves to the next item whose text starts with the query. Space is excluded:
140
+ * it activates the focused item, and stealing it would break native Button
141
+ * hosts. Activation stays with the item's own click/Enter handling —
142
+ * selection is app state, not focus state. Idempotent across re-renders,
143
+ * guarded by a dataset flag.
144
+ */
145
+ function installRovingWidget(container: HTMLElement, itemSelector: string): void {
146
+ if (container.dataset?.hypenRoving) return;
147
+ if (container.dataset) container.dataset.hypenRoving = "1";
148
+
149
+ const itemsOf = (): HTMLElement[] =>
150
+ Array.from(container.querySelectorAll(itemSelector)) as unknown as HTMLElement[];
151
+
152
+ const rove = (target: HTMLElement, items: HTMLElement[]): void => {
153
+ for (const item of items) {
154
+ item.tabIndex = item === target ? 0 : -1;
155
+ }
156
+ target.focus?.();
157
+ };
158
+
159
+ let typeahead = "";
160
+ let typeaheadAt = 0;
161
+
162
+ container.addEventListener("keydown", (event: Event) => {
163
+ const keyEvent = event as KeyboardEvent;
164
+ const key = keyEvent.key;
165
+ const items = itemsOf();
166
+ const focused = (container.ownerDocument?.activeElement ?? null) as HTMLElement | null;
167
+ const active = focused && items.includes(focused) ? focused : null;
168
+
169
+ let target = nextRovingFocus(items, active, key);
170
+
171
+ if (
172
+ !target &&
173
+ key.length === 1 &&
174
+ key !== " " &&
175
+ !keyEvent.ctrlKey &&
176
+ !keyEvent.metaKey &&
177
+ !keyEvent.altKey
178
+ ) {
179
+ const now = Date.now();
180
+ if (now - typeaheadAt > TYPEAHEAD_RESET_MS) typeahead = "";
181
+ typeaheadAt = now;
182
+ typeahead += key;
183
+ target = nextTypeaheadFocus(items, active, typeahead, (item) => item.textContent ?? "");
184
+ }
185
+
186
+ if (target) {
187
+ event.preventDefault?.();
188
+ rove(target, items);
189
+ }
190
+ });
191
+
192
+ // First entry into the widget establishes the roving state (focused item
193
+ // reachable, siblings parked at -1) without needing any keydown first.
194
+ container.addEventListener("focusin", (event: Event) => {
195
+ const items = itemsOf();
196
+ const focused = (event as FocusEvent).target as HTMLElement | null;
197
+ if (focused && items.includes(focused)) {
198
+ for (const item of items) {
199
+ item.tabIndex = item === focused ? 0 : -1;
200
+ }
201
+ }
202
+ });
203
+ }
204
+
205
+ /**
206
+ * Install the WAI-ARIA APG "Tabs" keyboard contract on a tablist container:
207
+ * roving tabindex over the `role="tab"` descendants plus first-character
208
+ * typeahead (see `installRovingWidget`).
209
+ */
210
+ export function makeRovingTablist(container: HTMLElement): void {
211
+ installRovingWidget(container, '[role="tab"]');
212
+ }
213
+
214
+ /**
215
+ * Install the WAI-ARIA APG "Listbox" keyboard contract on a listbox
216
+ * container: roving tabindex over the `role="option"` descendants plus
217
+ * first-character typeahead (see `installRovingWidget`). No-op for a native
218
+ * `<select>` host (Hypen's `Select`), where the browser already owns arrow
219
+ * and typeahead behaviour.
220
+ */
221
+ export function makeRovingListbox(container: HTMLElement): void {
222
+ if (container.tagName?.toUpperCase() === "SELECT") return;
223
+ installRovingWidget(container, '[role="option"]');
224
+ }
225
+
226
+ /**
227
+ * Install a focus trap on a dialog-like container: Tab / Shift+Tab cycle focus
228
+ * among the container's focusable descendants and wrap at the ends. Closing is
229
+ * app state: Escape is handled by `installDialogEscape` only when the dialog
230
+ * declares an `onClose` action. Idempotent across re-renders, guarded by a
231
+ * dataset flag.
232
+ */
233
+ export function makeFocusTrap(container: HTMLElement): void {
234
+ if (container.dataset?.hypenTrap) return;
235
+ if (container.dataset) container.dataset.hypenTrap = "1";
236
+
237
+ container.addEventListener("keydown", (event: Event) => {
238
+ if ((event as KeyboardEvent).key !== "Tab") return;
239
+ const focusable = Array.from(
240
+ container.querySelectorAll(FOCUSABLE_SELECTOR),
241
+ ) as unknown as HTMLElement[];
242
+ const active = (container.ownerDocument?.activeElement ?? null) as HTMLElement | null;
243
+ const target = nextTrapFocus(focusable, active, (event as KeyboardEvent).shiftKey === true);
244
+ if (target) {
245
+ event.preventDefault?.();
246
+ target.focus?.();
247
+ }
248
+ });
249
+ }
250
+
251
+ /** Tags that can hold focus without a tabindex — the DFS twin of `FOCUSABLE_SELECTOR`. */
252
+ const FOCUSABLE_TAGS = new Set(["A", "BUTTON", "INPUT", "TEXTAREA", "SELECT"]);
253
+
254
+ /**
255
+ * First focusable descendant of `root` in document order, or `null`. Matches
256
+ * the focus trap's element set (`FOCUSABLE_SELECTOR`) but walks `children`
257
+ * recursively instead of `querySelectorAll`, so it works on hosts without
258
+ * selector support (test doubles) — document order is exactly child order.
259
+ */
260
+ export function findFirstFocusable(root: HTMLElement): HTMLElement | null {
261
+ const children = Array.from((root.children ?? []) as unknown as ArrayLike<HTMLElement>);
262
+ for (const child of children) {
263
+ const tag = child.tagName?.toUpperCase() ?? "";
264
+ if (FOCUSABLE_TAGS.has(tag) || child.getAttribute?.("tabindex") != null) return child;
265
+ const nested = findFirstFocusable(child);
266
+ if (nested) return nested;
267
+ }
268
+ return null;
269
+ }
270
+
271
+ /**
272
+ * Dialog auto-focus on mount (WAI-ARIA APG "Dialog (Modal)"): focus the
273
+ * dialog's first focusable descendant, else the dialog itself (granted
274
+ * programmatic focusability by `focusRouteTarget`). Returns the element that
275
+ * held focus before the dialog opened, so the caller can restore it on close.
276
+ */
277
+ export function focusDialogOnOpen(dialog: HTMLElement): HTMLElement | null {
278
+ const doc = dialog.ownerDocument ?? (typeof document !== "undefined" ? document : null);
279
+ const previous = (doc?.activeElement ?? null) as HTMLElement | null;
280
+ focusRouteTarget(findFirstFocusable(dialog) ?? dialog);
281
+ return previous;
282
+ }
283
+
284
+ /**
285
+ * Restore focus to the element that had it before a dialog opened. Skipped
286
+ * when the opener is gone: no longer connected to the document, or living
287
+ * inside the (now closed) dialog itself.
288
+ */
289
+ export function restoreDialogFocus(dialog: HTMLElement, opener: HTMLElement | null): void {
290
+ if (!opener) return;
291
+ if (dialog === opener || dialog.contains?.(opener)) return;
292
+ if ((opener as { isConnected?: boolean }).isConnected === false) return;
293
+ opener.focus?.();
294
+ }
295
+
296
+ /**
297
+ * Escape-to-close for a dialog that declares an `onClose` action: Escape
298
+ * dispatches the action; the actual close (state flip, unmount) stays with
299
+ * the app. A dialog without `onClose` gets no Escape behaviour — the renderer
300
+ * cannot know how to close it. Idempotent across re-renders, guarded by a
301
+ * dataset flag.
302
+ */
303
+ export function installDialogEscape(dialog: HTMLElement, onCloseValue: unknown): void {
304
+ if (dialog.dataset?.hypenDialogEsc) return;
305
+ if (dialog.dataset) dialog.dataset.hypenDialogEsc = "1";
306
+
307
+ dialog.addEventListener("keydown", (event: Event) => {
308
+ if ((event as KeyboardEvent).key !== "Escape") return;
309
+ event.preventDefault?.();
310
+ triggerElementAction(dialog, onCloseValue);
311
+ });
312
+ }