@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
@@ -4,129 +4,461 @@
4
4
  * Maintain shadow DOM for screen readers
5
5
  */
6
6
  /**
7
- * Accessibility Manager
7
+ * Shadow tags that already convey their role natively, so we don't set an
8
+ * explicit `role` on them (it would be redundant). Generic `<div>`/`<span>`
9
+ * hosts are absent, so engine roles like `status`/`progressbar` land there.
10
+ */
11
+ const NATIVE_SHADOW_TAGS = new Set([
12
+ "BUTTON",
13
+ "A",
14
+ "IMG",
15
+ "INPUT",
16
+ "TEXTAREA",
17
+ "SELECT",
18
+ "H1",
19
+ "H2",
20
+ "H3",
21
+ "H4",
22
+ "H5",
23
+ "H6",
24
+ ]);
25
+ /**
26
+ * Attributes this module set on a shadow element on its previous pass, so a
27
+ * re-apply (a reactive `setSemantics` patch) can clear exactly what it owns —
28
+ * a dropped `expanded` must remove its stale `aria-expanded`, a name going
29
+ * away its `aria-label`.
30
+ */
31
+ const appliedShadowAttrs = new WeakMap();
32
+ /**
33
+ * Apply engine-derived accessibility semantics to a shadow-tree element.
34
+ *
35
+ * Unlike the DOM renderer, the canvas paints pixels — the accessible name is
36
+ * *not* otherwise exposed — so the derived name is applied directly as
37
+ * `aria-label` (whether derived or explicit). Roles are set only on generic
38
+ * hosts that don't convey them natively. `hidden` is handled by the caller
39
+ * (the node is omitted from the shadow tree entirely).
40
+ *
41
+ * Called at shadow-node create and again on every reactive `setSemantics`
42
+ * re-apply. Idempotent and clearing: attributes set on a previous pass that
43
+ * the new block no longer produces are removed.
44
+ */
45
+ export function applyShadowSemantics(element, semantics) {
46
+ if (!semantics && !appliedShadowAttrs.has(element))
47
+ return;
48
+ const next = [];
49
+ if (semantics) {
50
+ if (semantics.name) {
51
+ next.push(["aria-label", semantics.name]);
52
+ }
53
+ const tag = element.tagName?.toUpperCase();
54
+ if (semantics.role && !NATIVE_SHADOW_TAGS.has(tag)) {
55
+ next.push(["role", semantics.role]);
56
+ }
57
+ if (semantics.description) {
58
+ next.push(["aria-description", semantics.description]);
59
+ }
60
+ // Self-state relationship attributes.
61
+ if (semantics.expanded !== undefined) {
62
+ next.push(["aria-expanded", String(semantics.expanded)]);
63
+ }
64
+ if (semantics.pressed !== undefined) {
65
+ next.push(["aria-pressed", String(semantics.pressed)]);
66
+ }
67
+ if (semantics.selected !== undefined) {
68
+ next.push(["aria-selected", String(semantics.selected)]);
69
+ }
70
+ if (semantics.current) {
71
+ next.push(["aria-current", semantics.current]);
72
+ }
73
+ if (semantics.checked !== undefined) {
74
+ next.push(["aria-checked", String(semantics.checked)]);
75
+ }
76
+ if (semantics.busy) {
77
+ next.push(["aria-busy", "true"]);
78
+ }
79
+ if (semantics.live) {
80
+ // Live-region politeness — engine-validated ("polite" | "assertive").
81
+ next.push(["aria-live", semantics.live]);
82
+ }
83
+ }
84
+ // Clear what the previous pass set but this one doesn't.
85
+ const previous = appliedShadowAttrs.get(element);
86
+ if (previous) {
87
+ const keep = new Set(next.map(([name]) => name));
88
+ for (const name of previous) {
89
+ if (!keep.has(name)) {
90
+ element.removeAttribute(name);
91
+ }
92
+ }
93
+ }
94
+ for (const [name, value] of next) {
95
+ element.setAttribute(name, value);
96
+ }
97
+ if (next.length > 0) {
98
+ appliedShadowAttrs.set(element, next.map(([name]) => name));
99
+ }
100
+ else {
101
+ appliedShadowAttrs.delete(element);
102
+ }
103
+ }
104
+ /**
105
+ * Accessibility Mirror — positioned transparent semantics overlay
106
+ *
107
+ * A live DOM mirror of the virtual tree, rendered INVISIBLY over the canvas
108
+ * with every element absolutely positioned at its painted bounds (the
109
+ * Flutter-web / Google-Docs approach). Canvas *fallback content* was tried
110
+ * first and rejected on evidence: Chromium exposes fallback elements to the
111
+ * accessibility tree (names, roles, focus all work) but gives them ZERO
112
+ * geometry — and screen-reader browse modes (VoiceOver cursor, rotor, touch
113
+ * exploration) are geometry-driven, so they skip boundless elements. A
114
+ * rendered-but-transparent overlay gets real boxes, which makes browse mode
115
+ * work and puts native focus rings exactly over the painted controls.
116
+ *
117
+ * Real DOM focus on mirror elements is the single source of truth for
118
+ * `node.focused`; the overlay is `pointer-events: none` throughout so the
119
+ * canvas keeps all pointer interaction.
120
+ *
121
+ * The mirror is synced **incrementally** from the same patch stream that
122
+ * drives the canvas (`create/insert/move/remove/detach/attach/...`), so
123
+ * mirror elements keep their identity across re-renders — a screen reader's
124
+ * virtual cursor or a keyboard user's focus survives reactive updates
125
+ * instead of being destroyed by a rebuild. Element positions are refreshed
126
+ * after each canvas render via {@link syncPositions}.
8
127
  */
9
128
  export class AccessibilityLayer {
10
- shadowRoot;
129
+ mirrorRoot;
11
130
  nodeMap = new Map();
12
131
  enabled;
13
- constructor(container, enabled = true) {
14
- this.enabled = enabled && typeof document !== "undefined";
15
- // Create shadow root container (only in browser)
16
- if (this.enabled && typeof document !== "undefined") {
17
- this.shadowRoot = document.createElement("div");
18
- this.shadowRoot.setAttribute("role", "application");
19
- this.shadowRoot.setAttribute("aria-label", "Hypen Canvas Application");
20
- this.shadowRoot.style.position = "absolute";
21
- this.shadowRoot.style.left = "-9999px";
22
- this.shadowRoot.style.width = "1px";
23
- this.shadowRoot.style.height = "1px";
24
- this.shadowRoot.style.overflow = "hidden";
25
- if (container) {
26
- container.appendChild(this.shadowRoot);
132
+ /** Whether the environment can host a mirror at all. */
133
+ supported;
134
+ canvas;
135
+ /**
136
+ * Last box written to each mirror element, so the per-frame position sync
137
+ * only touches elements whose geometry actually changed — style writes on
138
+ * n absolutely-positioned elements per frame are what they cost.
139
+ */
140
+ lastSyncedBounds = new WeakMap();
141
+ lastRootRect = null;
142
+ boundReposition = () => this.repositionRoot();
143
+ constructor(canvas, enabled = true) {
144
+ this.canvas = canvas;
145
+ // The overlay mounts on document.body (positioned with page
146
+ // coordinates, like every canvas-adjacent helper here) — non-browser
147
+ // environments disable the mirror rather than crash.
148
+ this.supported =
149
+ typeof document !== "undefined" &&
150
+ !!document.body &&
151
+ typeof document.body.appendChild === "function" &&
152
+ !!canvas;
153
+ this.enabled = enabled && this.supported;
154
+ if (this.supported) {
155
+ // Plain container — deliberately NO role="application": that role
156
+ // switches screen readers out of virtual-cursor mode (arrow-key
157
+ // browsing stops working) and announces the container on every
158
+ // entry. The mirror is ordinary document content.
159
+ this.mirrorRoot = document.createElement("div");
160
+ this.mirrorRoot.setAttribute("data-hypen-a11y-overlay", "");
161
+ Object.assign(this.mirrorRoot.style, {
162
+ position: "absolute",
163
+ overflow: "hidden",
164
+ // The canvas owns ALL pointer interaction; the overlay is only for
165
+ // keyboard and assistive technology.
166
+ pointerEvents: "none",
167
+ });
168
+ if (this.enabled) {
169
+ this.mount();
27
170
  }
28
171
  }
29
172
  else {
30
- // Fallback for non-browser environments
31
- this.shadowRoot = {};
173
+ // Inert placeholder for non-browser environments
174
+ this.mirrorRoot = {};
32
175
  }
33
176
  }
177
+ mount() {
178
+ // Pseudo-elements can't be styled inline: placeholders (and selection
179
+ // highlights) would otherwise paint through the transparent overlay.
180
+ if (!document.getElementById?.("hypen-a11y-overlay-style")) {
181
+ const style = document.createElement("style");
182
+ style.id = "hypen-a11y-overlay-style";
183
+ style.textContent = [
184
+ "[data-hypen-a11y-overlay] input::placeholder,",
185
+ "[data-hypen-a11y-overlay] textarea::placeholder { color: transparent; opacity: 0; }",
186
+ "[data-hypen-a11y-overlay] ::selection { background: transparent; }",
187
+ "[data-hypen-a11y-overlay] input,",
188
+ "[data-hypen-a11y-overlay] textarea { resize: none; appearance: none; -webkit-appearance: none; }",
189
+ ].join("\n");
190
+ document.head?.appendChild?.(style);
191
+ }
192
+ document.body.appendChild(this.mirrorRoot);
193
+ // The overlay carries the semantics; the (now childless) canvas would
194
+ // otherwise surface as an anonymous element in browse mode.
195
+ this.canvas?.setAttribute?.("aria-hidden", "true");
196
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
197
+ window.addEventListener("resize", this.boundReposition);
198
+ window.addEventListener("scroll", this.boundReposition, true);
199
+ }
200
+ this.repositionRoot();
201
+ }
202
+ unmount() {
203
+ this.mirrorRoot.remove();
204
+ this.canvas?.removeAttribute?.("aria-hidden");
205
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
206
+ window.removeEventListener("resize", this.boundReposition);
207
+ window.removeEventListener("scroll", this.boundReposition, true);
208
+ }
209
+ }
210
+ /** Pin the overlay root to the canvas's page rect. */
211
+ repositionRoot() {
212
+ if (!this.enabled)
213
+ return;
214
+ const rect = this.canvas?.getBoundingClientRect?.();
215
+ if (!rect)
216
+ return;
217
+ const sx = typeof window !== "undefined" ? window.scrollX ?? 0 : 0;
218
+ const sy = typeof window !== "undefined" ? window.scrollY ?? 0 : 0;
219
+ const left = rect.left + sx;
220
+ const top = rect.top + sy;
221
+ const last = this.lastRootRect;
222
+ if (last &&
223
+ last.left === left &&
224
+ last.top === top &&
225
+ last.width === rect.width &&
226
+ last.height === rect.height) {
227
+ return;
228
+ }
229
+ this.lastRootRect = { left, top, width: rect.width, height: rect.height };
230
+ Object.assign(this.mirrorRoot.style, {
231
+ left: `${left}px`,
232
+ top: `${top}px`,
233
+ width: `${rect.width}px`,
234
+ height: `${rect.height}px`,
235
+ });
236
+ }
237
+ // -------------------------------------------------------------------------
238
+ // Incremental patch mirroring
239
+ // -------------------------------------------------------------------------
34
240
  /**
35
- * Sync shadow DOM with virtual tree
241
+ * Mirror a `create` patch: build the element (detached — a subsequent
242
+ * `insert` places it). Elements mirror the virtual tree 1:1; visibility
243
+ * and decorative hiding are expressed with `display:none` (which removes
244
+ * the subtree from the AT tree) instead of omission, so sibling order
245
+ * always matches the virtual tree and `beforeId` mapping stays exact.
246
+ *
247
+ * The mirror's accessibility metadata (role, name, busy, hidden) comes
248
+ * from the engine-derived {@link Semantics} block — the single source of
249
+ * truth shared with every other renderer — not from ad-hoc prop sniffing.
250
+ * Form *values* (input value/placeholder) still come from props, as those
251
+ * aren't semantics.
36
252
  */
37
- syncTree(root) {
253
+ createNode(node) {
38
254
  if (!this.enabled)
39
255
  return;
40
- // Clear existing
41
- this.shadowRoot.innerHTML = "";
42
- this.nodeMap.clear();
43
- // Build shadow DOM
44
- const shadowNode = this.createShadowNode(root);
45
- if (shadowNode) {
46
- this.shadowRoot.appendChild(shadowNode);
256
+ let element = this.nodeMap.get(node.id);
257
+ if (!element) {
258
+ element = this.createShadowElement(node);
259
+ element.setAttribute("data-hypen-id", node.id);
260
+ // Rendered but invisible: real boxes for AT geometry, zero visual
261
+ // presence — the canvas paints all pixels. `outline` is deliberately
262
+ // NOT suppressed: the native focus ring lands exactly over the
263
+ // painted control. Positions are written by syncPositions.
264
+ Object.assign(element.style, {
265
+ position: "absolute",
266
+ margin: "0",
267
+ padding: "0",
268
+ border: "0",
269
+ background: "transparent",
270
+ color: "transparent",
271
+ caretColor: "transparent",
272
+ overflow: "hidden",
273
+ });
274
+ this.nodeMap.set(node.id, element);
275
+ }
276
+ if (node.focusable) {
277
+ element.tabIndex = 0;
47
278
  }
279
+ applyShadowSemantics(element, node.semantics);
280
+ this.syncVisibility(element, node);
48
281
  }
49
282
  /**
50
- * Create shadow DOM element for virtual node
283
+ * Mirror an `insert` or `attach` patch. Re-inserting an element that is
284
+ * already in the tree moves it (native `insertBefore` semantics), so
285
+ * `move` routes here too. An `attach` reinserts the exact element kept
286
+ * alive by {@link detachNode} — AT identity survives router navigation.
51
287
  */
52
- createShadowNode(node) {
53
- if (!node.visible)
54
- return null;
55
- let element;
56
- // Create appropriate HTML element
57
- switch (node.type.toLowerCase()) {
58
- case "button":
59
- element = document.createElement("button");
60
- element.textContent = this.getNodeText(node);
61
- if (node.props.onclick) {
62
- element.setAttribute("aria-label", "Clickable button");
63
- }
64
- break;
65
- case "input":
66
- element = document.createElement("input");
67
- element.type = node.props.type || "text";
68
- element.value = node.props.value || "";
69
- if (node.props.placeholder) {
70
- element.placeholder = node.props.placeholder;
71
- }
72
- break;
73
- case "textarea":
74
- element = document.createElement("textarea");
75
- element.value = node.props.value || "";
76
- if (node.props.placeholder) {
77
- element.placeholder = node.props.placeholder;
78
- }
79
- break;
80
- case "image":
81
- element = document.createElement("img");
82
- element.src = node.props.src || "";
83
- element.alt = node.props.alt || "Image";
84
- break;
85
- case "text":
86
- element = document.createElement("span");
87
- element.textContent = String(node.props[0] || node.props.text || "");
88
- break;
89
- case "column":
90
- case "row":
91
- case "container":
92
- case "box":
93
- element = document.createElement("div");
94
- element.setAttribute("role", node.type === "column" ? "group" : "group");
95
- break;
96
- default:
97
- element = document.createElement("div");
288
+ insertNode(parentId, id, beforeId) {
289
+ if (!this.enabled)
290
+ return;
291
+ const element = this.nodeMap.get(id);
292
+ if (!element)
293
+ return;
294
+ // Root mount: the engine addresses the root as its own parent (or under
295
+ // the reserved "root" id). Everything else hangs off its parent element.
296
+ const parent = parentId === id
297
+ ? this.mirrorRoot
298
+ : this.nodeMap.get(parentId) ?? (parentId === "root" ? this.mirrorRoot : null);
299
+ if (!parent)
300
+ return;
301
+ const before = beforeId ? this.nodeMap.get(beforeId) : null;
302
+ if (before && before.parentNode === parent) {
303
+ parent.insertBefore(element, before);
98
304
  }
99
- // Set common attributes
100
- element.setAttribute("data-hypen-id", node.id);
101
- if (node.props["aria-label"]) {
102
- element.setAttribute("aria-label", node.props["aria-label"]);
305
+ else {
306
+ // Unknown/foreign beforeId — same append fallback the virtual tree uses.
307
+ parent.appendChild(element);
103
308
  }
104
- if (node.focusable) {
105
- element.tabIndex = 0;
309
+ }
310
+ /**
311
+ * Mirror a `remove` patch: drop the element and purge the subtree's
312
+ * id mappings.
313
+ */
314
+ removeNode(node) {
315
+ if (!this.enabled)
316
+ return;
317
+ this.nodeMap.get(node.id)?.remove();
318
+ this.purge(node);
319
+ }
320
+ purge(node) {
321
+ this.nodeMap.delete(node.id);
322
+ for (const child of node.children) {
323
+ this.purge(child);
324
+ }
325
+ }
326
+ /**
327
+ * Mirror a `detach` patch: unlink the element but keep the subtree and
328
+ * all id mappings alive so a later `attach` reinserts the same elements
329
+ * (router subtree cache parity).
330
+ */
331
+ detachNode(id) {
332
+ if (!this.enabled)
333
+ return;
334
+ this.nodeMap.get(id)?.remove();
335
+ }
336
+ /**
337
+ * Full rebuild from a virtual tree. Only used when re-enabling the mirror
338
+ * (`setEnabled(true)`) and by `clear()` — steady-state sync is incremental.
339
+ */
340
+ rebuild(root) {
341
+ if (!this.enabled)
342
+ return;
343
+ this.mirrorRoot.innerHTML = "";
344
+ this.nodeMap.clear();
345
+ if (root) {
346
+ this.mountSubtree(root, this.mirrorRoot);
106
347
  }
107
- // Store mapping
108
- this.nodeMap.set(node.id, element);
109
- // Add children
348
+ }
349
+ mountSubtree(node, parent) {
350
+ this.createNode(node);
351
+ const element = this.nodeMap.get(node.id);
352
+ if (!element)
353
+ return;
354
+ this.updateNode(node);
355
+ parent.appendChild(element);
110
356
  for (const child of node.children) {
111
- const childElement = this.createShadowNode(child);
112
- if (childElement) {
113
- element.appendChild(childElement);
114
- }
357
+ this.mountSubtree(child, element);
358
+ }
359
+ }
360
+ /**
361
+ * `display:none` removes the subtree from the accessibility tree — used
362
+ * both for `visible: false` and for decorative (`semantics.hidden`) nodes.
363
+ */
364
+ syncVisibility(element, node) {
365
+ element.style.display = node.visible && !node.semantics?.hidden ? "" : "none";
366
+ }
367
+ /**
368
+ * Write each mirror element's box to its node's painted (scroll-aware)
369
+ * bounds — called by the renderer after every canvas render, so AT
370
+ * geometry follows layout changes and container scrolling. Positions are
371
+ * parent-relative (every mirror element is absolutely positioned).
372
+ */
373
+ syncPositions(root) {
374
+ if (!this.enabled)
375
+ return;
376
+ this.repositionRoot();
377
+ if (root) {
378
+ this.syncNodePosition(root, 0, 0, 0, 0);
115
379
  }
116
- return element;
117
380
  }
118
381
  /**
119
- * Get text content from node tree
382
+ * `scrollX`/`scrollY` accumulate the ancestors' scroll offsets down the
383
+ * recursion (the same subtraction `getScrollAwareBounds` derives by
384
+ * walking up), keeping the pass O(n) instead of O(n·depth). Writes are
385
+ * skipped when the element's box is unchanged since the last sync.
120
386
  */
121
- getNodeText(node) {
122
- if (node.type === "text") {
123
- return String(node.props[0] || node.props.text || "");
387
+ syncNodePosition(node, parentX, parentY, scrollX, scrollY) {
388
+ const element = this.nodeMap.get(node.id);
389
+ if (!element || !node.layout)
390
+ return;
391
+ const x = node.layout.x - scrollX;
392
+ const y = node.layout.y - scrollY;
393
+ const width = node.layout.width;
394
+ const height = node.layout.height;
395
+ const relX = x - parentX;
396
+ const relY = y - parentY;
397
+ const last = this.lastSyncedBounds.get(element);
398
+ if (!last || last.x !== relX || last.y !== relY || last.width !== width || last.height !== height) {
399
+ element.style.left = `${relX}px`;
400
+ element.style.top = `${relY}px`;
401
+ element.style.width = `${width}px`;
402
+ element.style.height = `${height}px`;
403
+ this.lastSyncedBounds.set(element, { x: relX, y: relY, width, height });
124
404
  }
125
- let text = "";
405
+ const childScrollX = scrollX + (node.scrollState?.scrollX ?? 0);
406
+ const childScrollY = scrollY + (node.scrollState?.scrollY ?? 0);
126
407
  for (const child of node.children) {
127
- text += this.getNodeText(child);
408
+ this.syncNodePosition(child, x, y, childScrollX, childScrollY);
409
+ }
410
+ }
411
+ /**
412
+ * Choose the semantic HTML tag for a node's shadow element and set its
413
+ * non-accessibility content (text, input value/placeholder). Roles/names are
414
+ * applied separately from the semantics block.
415
+ */
416
+ createShadowElement(node) {
417
+ switch (node.type.toLowerCase()) {
418
+ case "button":
419
+ return document.createElement("button");
420
+ case "link": {
421
+ const a = document.createElement("a");
422
+ a.setAttribute("href", "#");
423
+ return a;
424
+ }
425
+ case "input": {
426
+ const input = document.createElement("input");
427
+ input.type = node.props.type || "text";
428
+ input.value = node.props.value || "";
429
+ if (node.props.placeholder)
430
+ input.placeholder = node.props.placeholder;
431
+ return input;
432
+ }
433
+ case "textarea": {
434
+ const textarea = document.createElement("textarea");
435
+ textarea.value = node.props.value || "";
436
+ if (node.props.placeholder)
437
+ textarea.placeholder = node.props.placeholder;
438
+ return textarea;
439
+ }
440
+ case "image":
441
+ return document.createElement("img");
442
+ case "heading": {
443
+ // Use the derived level when known; fall back to a generic h2.
444
+ const level = Math.min(6, Math.max(1, node.semantics?.level ?? 2));
445
+ return document.createElement(`h${level}`);
446
+ }
447
+ case "text": {
448
+ const span = document.createElement("span");
449
+ span.textContent = String(node.props[0] || node.props.text || "");
450
+ return span;
451
+ }
452
+ case "column":
453
+ case "row":
454
+ case "container":
455
+ case "box":
456
+ // Plain div — role="group" made browse mode announce "group" on
457
+ // every layout container (the DOM renderer emits plain divs too).
458
+ return document.createElement("div");
459
+ default:
460
+ return document.createElement("div");
128
461
  }
129
- return text;
130
462
  }
131
463
  /**
132
464
  * Focus node in shadow DOM
@@ -148,16 +480,29 @@ export class AccessibilityLayer {
148
480
  const element = this.nodeMap.get(node.id);
149
481
  if (!element)
150
482
  return;
483
+ const type = node.type.toLowerCase();
151
484
  // Update text content
152
- if (node.type === "text") {
485
+ if (type === "text") {
153
486
  element.textContent = String(node.props[0] || node.props.text || "");
154
487
  }
155
- // Update input value
156
- if (node.type === "input" || node.type === "textarea") {
157
- element.value = node.props.value || "";
488
+ // Update input value. Guarded — assigning even an identical value can
489
+ // collapse the browser's caret/selection in a focused mirror input.
490
+ if (type === "input" || type === "textarea") {
491
+ const input = element;
492
+ const value = node.props.value || "";
493
+ if (input.value !== value) {
494
+ input.value = value;
495
+ }
496
+ if (node.props.placeholder != null) {
497
+ input.placeholder = node.props.placeholder;
498
+ }
158
499
  }
159
- // Update visibility
160
- element.style.display = node.visible ? "" : "none";
500
+ // Re-apply semantics: painted text is invisible to AT, so a reactively
501
+ // changed accessible name/state only reaches screen readers through the
502
+ // shadow node's ARIA attributes. `applyShadowSemantics` clears anything
503
+ // it set previously that the current block no longer produces.
504
+ applyShadowSemantics(element, node.semantics);
505
+ this.syncVisibility(element, node);
161
506
  }
162
507
  /**
163
508
  * Get shadow element by node ID
@@ -165,21 +510,38 @@ export class AccessibilityLayer {
165
510
  getElement(nodeId) {
166
511
  return this.nodeMap.get(nodeId);
167
512
  }
513
+ /** The fallback-content root element (for focus delegation wiring). */
514
+ getRoot() {
515
+ return this.supported ? this.mirrorRoot : null;
516
+ }
517
+ isEnabled() {
518
+ return this.enabled;
519
+ }
168
520
  /**
169
- * Enable or disable accessibility layer
521
+ * Enable or disable the mirror. Re-enabling rebuilds from the given
522
+ * virtual tree (incremental sync has no history to replay).
170
523
  */
171
- setEnabled(enabled) {
172
- this.enabled = enabled;
173
- if (!enabled && this.shadowRoot.parentElement) {
174
- this.shadowRoot.remove();
524
+ setEnabled(enabled, root) {
525
+ const next = enabled && this.supported;
526
+ if (next === this.enabled)
527
+ return;
528
+ this.enabled = next;
529
+ if (!next) {
530
+ this.unmount();
531
+ this.nodeMap.clear();
532
+ }
533
+ else {
534
+ this.mount();
535
+ this.rebuild(root ?? null);
536
+ this.syncPositions(root ?? null);
175
537
  }
176
538
  }
177
539
  /**
178
540
  * Cleanup
179
541
  */
180
542
  destroy() {
181
- if (this.shadowRoot.parentElement) {
182
- this.shadowRoot.remove();
543
+ if (this.supported) {
544
+ this.unmount();
183
545
  }
184
546
  this.nodeMap.clear();
185
547
  }