@hypen-space/web 0.5.2 → 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 (104) hide show
  1. package/dist/canvas/accessibility.d.ts +118 -11
  2. package/dist/canvas/accessibility.js +470 -108
  3. package/dist/canvas/accessibility.js.map +1 -1
  4. package/dist/canvas/dispatch.d.ts +24 -0
  5. package/dist/canvas/dispatch.js +62 -0
  6. package/dist/canvas/dispatch.js.map +1 -0
  7. package/dist/canvas/editing.d.ts +178 -0
  8. package/dist/canvas/editing.js +590 -0
  9. package/dist/canvas/editing.js.map +1 -0
  10. package/dist/canvas/events.d.ts +28 -23
  11. package/dist/canvas/events.js +76 -128
  12. package/dist/canvas/events.js.map +1 -1
  13. package/dist/canvas/focus.d.ts +78 -0
  14. package/dist/canvas/focus.js +182 -0
  15. package/dist/canvas/focus.js.map +1 -0
  16. package/dist/canvas/index.d.ts +3 -1
  17. package/dist/canvas/index.js +3 -1
  18. package/dist/canvas/index.js.map +1 -1
  19. package/dist/canvas/paint.d.ts +16 -3
  20. package/dist/canvas/paint.js +283 -41
  21. package/dist/canvas/paint.js.map +1 -1
  22. package/dist/canvas/renderer.d.ts +20 -1
  23. package/dist/canvas/renderer.js +181 -50
  24. package/dist/canvas/renderer.js.map +1 -1
  25. package/dist/canvas/selection.js +31 -177
  26. package/dist/canvas/selection.js.map +1 -1
  27. package/dist/canvas/text-geometry.d.ts +72 -0
  28. package/dist/canvas/text-geometry.js +244 -0
  29. package/dist/canvas/text-geometry.js.map +1 -0
  30. package/dist/canvas/text.js +42 -9
  31. package/dist/canvas/text.js.map +1 -1
  32. package/dist/canvas/types.d.ts +7 -1
  33. package/dist/canvas/variants.d.ts +4 -1
  34. package/dist/canvas/variants.js +22 -3
  35. package/dist/canvas/variants.js.map +1 -1
  36. package/dist/dom/a11y-styles.d.ts +20 -0
  37. package/dist/dom/a11y-styles.js +61 -0
  38. package/dist/dom/a11y-styles.js.map +1 -0
  39. package/dist/dom/applicators/aria.d.ts +19 -0
  40. package/dist/dom/applicators/aria.js +36 -0
  41. package/dist/dom/applicators/aria.js.map +1 -0
  42. package/dist/dom/applicators/events.d.ts +7 -0
  43. package/dist/dom/applicators/events.js +35 -8
  44. package/dist/dom/applicators/events.js.map +1 -1
  45. package/dist/dom/applicators/index.js +4 -0
  46. package/dist/dom/applicators/index.js.map +1 -1
  47. package/dist/dom/components/hypenapp.d.ts +25 -1
  48. package/dist/dom/components/hypenapp.js +213 -245
  49. package/dist/dom/components/hypenapp.js.map +1 -1
  50. package/dist/dom/components/index.js +7 -0
  51. package/dist/dom/components/index.js.map +1 -1
  52. package/dist/dom/components/spinner.js +24 -10
  53. package/dist/dom/components/spinner.js.map +1 -1
  54. package/dist/dom/components/tabs.d.ts +27 -0
  55. package/dist/dom/components/tabs.js +69 -0
  56. package/dist/dom/components/tabs.js.map +1 -0
  57. package/dist/dom/components/visuallyhidden.d.ts +9 -0
  58. package/dist/dom/components/visuallyhidden.js +26 -0
  59. package/dist/dom/components/visuallyhidden.js.map +1 -0
  60. package/dist/dom/operability.d.ts +100 -0
  61. package/dist/dom/operability.js +298 -0
  62. package/dist/dom/operability.js.map +1 -0
  63. package/dist/dom/renderer.d.ts +98 -5
  64. package/dist/dom/renderer.js +398 -63
  65. package/dist/dom/renderer.js.map +1 -1
  66. package/dist/dom/route-focus.d.ts +42 -0
  67. package/dist/dom/route-focus.js +88 -0
  68. package/dist/dom/route-focus.js.map +1 -0
  69. package/dist/dom/semantics.d.ts +35 -0
  70. package/dist/dom/semantics.js +184 -0
  71. package/dist/dom/semantics.js.map +1 -0
  72. package/package.json +2 -2
  73. package/src/canvas/QUICKSTART.md +4 -4
  74. package/src/canvas/README.md +55 -29
  75. package/src/canvas/accessibility.ts +507 -115
  76. package/src/canvas/dispatch.ts +78 -0
  77. package/src/canvas/editing.ts +697 -0
  78. package/src/canvas/events.ts +89 -142
  79. package/src/canvas/focus.ts +216 -0
  80. package/src/canvas/index.ts +8 -1
  81. package/src/canvas/paint.ts +339 -42
  82. package/src/canvas/renderer.ts +218 -70
  83. package/src/canvas/selection.ts +52 -210
  84. package/src/canvas/text-geometry.ts +311 -0
  85. package/src/canvas/text.ts +43 -9
  86. package/src/canvas/types.ts +14 -1
  87. package/src/canvas/variants.ts +24 -5
  88. package/src/dom/a11y-styles.ts +65 -0
  89. package/src/dom/applicators/aria.ts +41 -0
  90. package/src/dom/applicators/events.ts +38 -7
  91. package/src/dom/applicators/index.ts +5 -0
  92. package/src/dom/components/hypenapp.ts +244 -272
  93. package/src/dom/components/index.ts +7 -0
  94. package/src/dom/components/spinner.ts +31 -13
  95. package/src/dom/components/tabs.ts +76 -0
  96. package/src/dom/components/visuallyhidden.ts +30 -0
  97. package/src/dom/operability.ts +312 -0
  98. package/src/dom/renderer.ts +455 -63
  99. package/src/dom/route-focus.ts +98 -0
  100. package/src/dom/semantics.ts +199 -0
  101. package/dist/canvas/input.d.ts +0 -76
  102. package/dist/canvas/input.js +0 -207
  103. package/dist/canvas/input.js.map +0 -1
  104. package/src/canvas/input.ts +0 -251
@@ -3,8 +3,30 @@
3
3
  *
4
4
  * Renders Hypen patches to the DOM
5
5
  */
6
- import { frameworkLoggers } from "@hypen-space/core/logger";
6
+ import { frameworkLoggers, isDebugMode } from "@hypen-space/core/logger";
7
7
  const log = frameworkLoggers.renderer;
8
+ /**
9
+ * Split a text template into literal and `@{state.path}` binding segments,
10
+ * so per-update interpolation walks pre-split paths instead of re-running
11
+ * the regex over the whole template.
12
+ */
13
+ function compileTextTemplate(template) {
14
+ const bindingPattern = /@\{([^}]+)\}/g;
15
+ const segments = [];
16
+ let last = 0;
17
+ let match;
18
+ while ((match = bindingPattern.exec(template)) !== null) {
19
+ if (match.index > last) {
20
+ segments.push(template.slice(last, match.index));
21
+ }
22
+ segments.push({ match: match[0], keys: match[1].split(".") });
23
+ last = match.index + match[0].length;
24
+ }
25
+ if (last < template.length) {
26
+ segments.push(template.slice(last));
27
+ }
28
+ return segments;
29
+ }
8
30
  /** Element types that treat the "action" prop as an onClick handler */
9
31
  const ACTIONABLE_TYPES = new Set(["button", "link", "card"]);
10
32
  /**
@@ -23,13 +45,20 @@ const COMPONENT_HTML_ATTRS = {
23
45
  checkbox: new Set(["checked", "disabled", "name"]),
24
46
  radio: new Set(["checked", "disabled", "name", "value"]),
25
47
  link: new Set(["href", "target", "rel"]),
48
+ // Route URL changes to the handler so it reconnects the embedded app
49
+ // instead of the generic text branch overwriting the subtree.
50
+ hypenapp: new Set(["0", "url"]),
26
51
  };
27
52
  import { ComponentRegistry } from "./components/index.js";
28
53
  import { ApplicatorRegistry } from "./applicators/index.js";
54
+ import { applySemantics } from "./semantics.js";
55
+ import { makeKeyboardActivatable, makeFocusTrap, makeRovingTablist, makeRovingListbox, focusDialogOnOpen, restoreDialogFocus, installDialogEscape, } from "./operability.js";
56
+ import { findRouteFocusTarget, focusRouteTarget } from "./route-focus.js";
29
57
  import { canvasHandler, canvasApplicators } from "./canvas/index.js";
30
58
  import { CanvasRenderer } from "../canvas/renderer.js";
31
59
  import { RerenderTracker, defaultDebugConfig } from "./debug.js";
32
60
  import { setEngine, disposeHypenElement } from "./element-data.js";
61
+ import { ensureA11yStyles } from "./a11y-styles.js";
33
62
  export class DOMRenderer {
34
63
  container;
35
64
  nodes = new Map();
@@ -42,19 +71,57 @@ export class DOMRenderer {
42
71
  globalContext = null;
43
72
  componentInstances = new Map();
44
73
  debugTracker;
74
+ /**
75
+ * Text elements whose template contains an `@{...}` binding, with the
76
+ * template pre-compiled into segments. Only these nodes are visited on
77
+ * state changes — static text never re-interpolates.
78
+ */
79
+ textBindings = new Map();
45
80
  // Canvas subtree routing — Canvas components get their own CanvasRenderer
46
81
  // and all descendant patches are forwarded to it instead of the DOM.
47
82
  canvasRenderers = new Map();
48
83
  canvasSubtreeMap = new Map(); // nodeId → canvasRootId
84
+ // Parent/child links within canvas subtrees, so removes can prune the
85
+ // routing maps without scanning every entry ever recorded.
86
+ canvasNodeChildren = new Map();
87
+ canvasNodeParent = new Map();
49
88
  canvasElements = new Map();
50
89
  /** Props stashed at create-time so we can forward them to CanvasRenderer */
51
90
  pendingCreateProps = new Map();
52
- constructor(container, engine, debugConfig) {
91
+ /**
92
+ * Focus-restore memory for the Router subtree cache, keyed by detached
93
+ * subtree root id: the element that held focus when the route was
94
+ * detached, restored on cached re-`attach`. Entries are dropped on
95
+ * `remove` (Router LRU eviction) so focus restore can never target an
96
+ * evicted NodeId — the route-focus contract (see route-focus.ts).
97
+ */
98
+ routeFocusMemory = new Map();
99
+ /** See `DOMRendererOptions.routeFocus` — `"off"` disables `focusIncomingRoute`. */
100
+ routeFocus;
101
+ /** Ids created with `semantics.role === "dialog"`, so insert/remove hooks can act. */
102
+ dialogIds = new Set();
103
+ /**
104
+ * Open dialog id → the element that held focus before the dialog took it
105
+ * (the trigger), restored on dialog remove/detach when still connected.
106
+ * Presence of an entry means "already focused" — re-inserts (moves) of an
107
+ * open dialog never re-run the mount focus.
108
+ */
109
+ dialogOpeners = new Map();
110
+ /**
111
+ * Dialog ids inserted during the current batch. Mount focus runs after the
112
+ * whole batch, because a dialog's focusable children may be inserted after
113
+ * the dialog's own insert patch.
114
+ */
115
+ pendingDialogMounts = [];
116
+ constructor(container, engine, debugConfig, options) {
53
117
  this.container = container;
54
118
  this.engine = engine;
55
119
  this.components = new ComponentRegistry();
56
120
  this.applicators = new ApplicatorRegistry();
57
121
  this.debugTracker = new RerenderTracker({ ...defaultDebugConfig, ...debugConfig });
122
+ this.routeFocus = options?.routeFocus ?? "auto";
123
+ // Inject the global reduced-motion + focus-visible stylesheet once.
124
+ ensureA11yStyles();
58
125
  // Match the canvas/iOS/Android renderers: text without an explicit
59
126
  // `.color()` defaults to black. Without this, DOM text would inherit
60
127
  // whatever the host page's body color is — a dark shell makes every
@@ -83,36 +150,74 @@ export class DOMRenderer {
83
150
  * the DOMRenderer or the owning CanvasRenderer.
84
151
  */
85
152
  applyPatches(patches) {
86
- // Phase 1: scan inserts to discover new canvas-subtree members
87
- for (const patch of patches) {
88
- if (patch.type === "insert" || patch.type === "move") {
89
- const parentId = patch.parentId;
90
- const childId = patch.id;
91
- if (!parentId || !childId)
92
- continue;
93
- // Is the parent a canvas root or inside a canvas subtree?
94
- const canvasRootId = this.canvasRenderers.has(parentId) ? parentId
95
- : this.canvasSubtreeMap.get(parentId);
153
+ // Canvas routing only matters once a canvas root exists; the common
154
+ // no-canvas case applies the batch directly with no extra passes.
155
+ let canvasBatches = null;
156
+ let domPatches = patches;
157
+ if (this.canvasRenderers.size > 0 || this.canvasSubtreeMap.size > 0) {
158
+ // Phase 1: scan inserts to discover new canvas-subtree members
159
+ for (const patch of patches) {
160
+ if (patch.type === "insert" || patch.type === "move") {
161
+ const parentId = patch.parentId;
162
+ const childId = patch.id;
163
+ if (!parentId || !childId)
164
+ continue;
165
+ // Is the parent a canvas root or inside a canvas subtree?
166
+ const canvasRootId = this.canvasRenderers.has(parentId) ? parentId
167
+ : this.canvasSubtreeMap.get(parentId);
168
+ if (canvasRootId) {
169
+ this.registerCanvasMember(childId, parentId, canvasRootId);
170
+ }
171
+ }
172
+ }
173
+ // Phase 2: route patches
174
+ canvasBatches = new Map();
175
+ domPatches = [];
176
+ for (const patch of patches) {
177
+ const canvasRootId = this.getCanvasRouteTarget(patch);
96
178
  if (canvasRootId) {
97
- this.canvasSubtreeMap.set(childId, canvasRootId);
179
+ let batch = canvasBatches.get(canvasRootId);
180
+ if (!batch) {
181
+ batch = [];
182
+ canvasBatches.set(canvasRootId, batch);
183
+ }
184
+ batch.push(patch);
185
+ if (patch.type === "remove" && patch.id) {
186
+ // Removed canvas nodes never come back (engine node ids are
187
+ // never reused), so drop their routing entries now.
188
+ this.pruneCanvasSubtree(patch.id);
189
+ }
190
+ }
191
+ else {
192
+ domPatches.push(patch);
98
193
  }
99
194
  }
100
195
  }
101
- // Phase 2: route patches
102
- const canvasBatches = new Map();
103
- const domPatches = [];
104
- for (const patch of patches) {
105
- const canvasRootId = this.getCanvasRouteTarget(patch);
106
- if (canvasRootId) {
107
- let batch = canvasBatches.get(canvasRootId);
108
- if (!batch) {
109
- batch = [];
110
- canvasBatches.set(canvasRootId, batch);
196
+ // Route-change detection for the focus contract: a batch with a detach
197
+ // (route leaving) plus incoming subtree roots (attach of a cached route,
198
+ // or a fresh top-level create+insert) is a navigation. Initial renders
199
+ // have no detach, so they never steal focus.
200
+ const isNavigation = domPatches.some((p) => p.type === "detach");
201
+ const incomingRoots = [];
202
+ if (isNavigation) {
203
+ const createdInBatch = new Set();
204
+ for (const p of domPatches) {
205
+ if (p.type === "create" && p.id) {
206
+ createdInBatch.add(p.id);
111
207
  }
112
- batch.push(patch);
113
208
  }
114
- else {
115
- domPatches.push(patch);
209
+ for (const p of domPatches) {
210
+ if (p.type === "attach" && p.id) {
211
+ incomingRoots.push(p.id);
212
+ }
213
+ else if (p.type === "insert" &&
214
+ p.id &&
215
+ createdInBatch.has(p.id) &&
216
+ (!p.parentId || !createdInBatch.has(p.parentId))) {
217
+ // A freshly-built subtree root: created in this batch, inserted
218
+ // under a parent that predates the batch (the router's slot).
219
+ incomingRoots.push(p.id);
220
+ }
116
221
  }
117
222
  }
118
223
  // Apply DOM patches normally
@@ -120,12 +225,57 @@ export class DOMRenderer {
120
225
  this.applyPatch(patch);
121
226
  }
122
227
  // Forward canvas-subtree patches to their CanvasRenderer instances
123
- for (const [rootId, batch] of canvasBatches) {
124
- const renderer = this.canvasRenderers.get(rootId);
125
- if (renderer) {
126
- renderer.applyPatches(batch);
228
+ if (canvasBatches) {
229
+ for (const [rootId, batch] of canvasBatches) {
230
+ const renderer = this.canvasRenderers.get(rootId);
231
+ if (renderer) {
232
+ renderer.applyPatches(batch);
233
+ }
127
234
  }
128
235
  }
236
+ if (isNavigation && incomingRoots.length > 0 && this.routeFocus !== "off") {
237
+ this.focusIncomingRoute(incomingRoots[0]);
238
+ }
239
+ // Dialog mount focus runs last: a dialog opened by this batch takes
240
+ // focus even when the batch was also a navigation (modal wins).
241
+ this.flushDialogMounts();
242
+ }
243
+ /**
244
+ * Apply the dialog focus contract to dialogs inserted by the finished
245
+ * batch: focus the first focusable descendant, else the dialog itself, and
246
+ * remember the previously-focused element (the trigger) for restore on
247
+ * close. Deferred to batch end so descendants inserted after the dialog's
248
+ * own insert patch are visible to the focus search.
249
+ */
250
+ flushDialogMounts() {
251
+ if (this.pendingDialogMounts.length === 0)
252
+ return;
253
+ const pending = this.pendingDialogMounts;
254
+ this.pendingDialogMounts = [];
255
+ for (const id of pending) {
256
+ if (this.dialogOpeners.has(id))
257
+ continue; // already open — a move, not a mount
258
+ const dialog = this.nodes.get(id);
259
+ if (!dialog)
260
+ continue;
261
+ this.dialogOpeners.set(id, focusDialogOnOpen(dialog));
262
+ }
263
+ }
264
+ /**
265
+ * Apply the route-focus contract to a freshly-shown route subtree: restore
266
+ * the remembered focus of a cached route when it is still inside the
267
+ * subtree, otherwise land on the first heading / main landmark / the
268
+ * subtree root (see route-focus.ts for the full contract).
269
+ */
270
+ focusIncomingRoute(rootId) {
271
+ const root = this.nodes.get(rootId);
272
+ if (!root)
273
+ return;
274
+ const remembered = this.routeFocusMemory.get(rootId);
275
+ const target = remembered && (root === remembered || root.contains?.(remembered))
276
+ ? remembered
277
+ : findRouteFocusTarget(root);
278
+ focusRouteTarget(target);
129
279
  }
130
280
  /**
131
281
  * Determine which canvas root (if any) a patch should be routed to.
@@ -161,6 +311,44 @@ export class DOMRenderer {
161
311
  }
162
312
  }
163
313
  }
314
+ /**
315
+ * Record a node as a member of a canvas subtree, tracking its parent link
316
+ * so a later `remove` can prune the whole subtree from the routing maps.
317
+ */
318
+ registerCanvasMember(childId, parentId, canvasRootId) {
319
+ this.canvasSubtreeMap.set(childId, canvasRootId);
320
+ const prevParent = this.canvasNodeParent.get(childId);
321
+ if (prevParent !== undefined && prevParent !== parentId) {
322
+ this.canvasNodeChildren.get(prevParent)?.delete(childId);
323
+ }
324
+ this.canvasNodeParent.set(childId, parentId);
325
+ let siblings = this.canvasNodeChildren.get(parentId);
326
+ if (!siblings) {
327
+ siblings = new Set();
328
+ this.canvasNodeChildren.set(parentId, siblings);
329
+ }
330
+ siblings.add(childId);
331
+ }
332
+ /**
333
+ * Drop a removed canvas node and all of its descendants from the canvas
334
+ * routing maps. The engine emits `remove` only for the subtree root, so
335
+ * descendants are walked via the tracked child links.
336
+ */
337
+ pruneCanvasSubtree(id) {
338
+ const children = this.canvasNodeChildren.get(id);
339
+ if (children) {
340
+ this.canvasNodeChildren.delete(id);
341
+ for (const childId of children) {
342
+ this.pruneCanvasSubtree(childId);
343
+ }
344
+ }
345
+ this.canvasSubtreeMap.delete(id);
346
+ const parentId = this.canvasNodeParent.get(id);
347
+ if (parentId !== undefined) {
348
+ this.canvasNodeParent.delete(id);
349
+ this.canvasNodeChildren.get(parentId)?.delete(id);
350
+ }
351
+ }
164
352
  /**
165
353
  * Update state and interpolate text content
166
354
  */
@@ -178,41 +366,64 @@ export class DOMRenderer {
178
366
  this.interpolateAllText();
179
367
  }
180
368
  /**
181
- * Interpolate state values in all text elements
369
+ * Interpolate state values in all binding-bearing text elements
182
370
  */
183
371
  interpolateAllText() {
184
- for (const [id, element] of this.nodes.entries()) {
185
- if (element.dataset.hypenType === "text" && element.dataset.textTemplate) {
186
- const template = element.dataset.textTemplate;
187
- const interpolated = this.interpolateText(template, this.currentState);
188
- // Track re-render if text actually changed
189
- const currentText = element.textContent;
190
- if (currentText !== interpolated) {
191
- this.debugTracker.trackRerender(id, element, "interpolate");
192
- }
372
+ for (const [id, binding] of this.textBindings) {
373
+ const interpolated = this.interpolateSegments(binding.segments, this.currentState);
374
+ const element = binding.element;
375
+ if (element.textContent !== interpolated) {
376
+ this.debugTracker.trackRerender(id, element, "interpolate");
193
377
  element.textContent = interpolated;
194
378
  }
195
379
  }
196
380
  }
197
381
  /**
198
- * Interpolate state values in text template.
382
+ * Resolve a compiled template against the current state.
199
383
  *
200
- * Bindings use the `@{state.path}` syntax (matching the engine's parser).
384
+ * Bindings use the `@{state.path}` syntax (matching the engine's parser);
385
+ * unresolvable bindings render as their original `@{...}` text.
201
386
  */
202
- interpolateText(template, state) {
203
- return template.replace(/@\{([^}]+)\}/g, (match, path) => {
387
+ interpolateSegments(segments, state) {
388
+ let result = "";
389
+ for (const segment of segments) {
390
+ if (typeof segment === "string") {
391
+ result += segment;
392
+ continue;
393
+ }
204
394
  try {
205
- const value = path.split('.').reduce((obj, key) => {
206
- if (key === 'state')
207
- return state;
208
- return obj?.[key];
209
- }, state);
210
- return value !== undefined ? String(value) : match;
395
+ let value = state;
396
+ for (const key of segment.keys) {
397
+ value = key === "state" ? state : value?.[key];
398
+ }
399
+ result += value !== undefined ? String(value) : segment.match;
211
400
  }
212
401
  catch {
213
- return match;
402
+ result += segment.match;
214
403
  }
215
- });
404
+ }
405
+ return result;
406
+ }
407
+ /**
408
+ * Keep the binding index in sync with the element's stored template:
409
+ * register text elements whose template contains an `@{...}` binding,
410
+ * drop everything else.
411
+ */
412
+ syncTextBinding(id, element) {
413
+ const template = element.dataset.textTemplate;
414
+ if (element.dataset.hypenType === "text" && template && template.includes("@{")) {
415
+ const existing = this.textBindings.get(id);
416
+ if (!existing || existing.template !== template || existing.element !== element) {
417
+ this.textBindings.set(id, {
418
+ element,
419
+ template,
420
+ segments: compileTextTemplate(template),
421
+ });
422
+ }
423
+ }
424
+ else {
425
+ this.textBindings.delete(id);
426
+ }
216
427
  }
217
428
  /**
218
429
  * Apply a single patch.
@@ -224,7 +435,7 @@ export class DOMRenderer {
224
435
  const beforeId = patch.beforeId;
225
436
  switch (patch.type) {
226
437
  case "create":
227
- this.onCreate(id, elementType ?? "container", patch.props || {});
438
+ this.onCreate(id, elementType ?? "container", patch.props || {}, patch.semantics);
228
439
  break;
229
440
  case "setProp":
230
441
  this.onSetProp(id, patch.name, patch.value);
@@ -250,12 +461,31 @@ export class DOMRenderer {
250
461
  case "attach":
251
462
  this.onInsert(parentId, id, beforeId);
252
463
  break;
464
+ case "setSemantics":
465
+ this.onSetSemantics(id, patch.semantics);
466
+ break;
467
+ }
468
+ }
469
+ /**
470
+ * Reactive accessibility update: re-run the create-time semantics
471
+ * translation on the live element with the node's complete re-resolved
472
+ * block. `applySemantics` is idempotent and clears attributes it set on a
473
+ * previous pass that the new block no longer produces. This is the sole
474
+ * ARIA writer for bound self-state — the SetProp that accompanies a bound
475
+ * `.expanded(@state.open)` change does not touch ARIA attributes.
476
+ */
477
+ onSetSemantics(id, semantics) {
478
+ const element = this.nodes.get(id);
479
+ if (!element) {
480
+ log.warn(`setSemantics: element ${id} not found`);
481
+ return;
253
482
  }
483
+ applySemantics(element, semantics);
254
484
  }
255
485
  /**
256
486
  * Create a new element
257
487
  */
258
- onCreate(id, elementType, props) {
488
+ onCreate(id, elementType, props, semantics) {
259
489
  const propsObj = props instanceof Map ? Object.fromEntries(props) : props;
260
490
  let element = this.components.createElement(elementType, propsObj);
261
491
  if (!element) {
@@ -271,12 +501,53 @@ export class DOMRenderer {
271
501
  element.dataset.hypenType = elementType.toLowerCase();
272
502
  element.dataset.hypenId = id;
273
503
  setEngine(element, this.engine);
504
+ // Slot marker: `.slot("name")` lowers to the `slot.0` prop. The engine
505
+ // resolves slots during component expansion, but container components
506
+ // that own their children natively (HypenApp's loading/error slots)
507
+ // need to identify slotted children at the DOM level.
508
+ const slotName = propsObj["slot.0"] ?? propsObj.slot;
509
+ if (typeof slotName === "string" && slotName) {
510
+ element.dataset.hypenSlot = slotName;
511
+ }
512
+ // Apply engine-derived accessibility semantics (role, …) before styling
513
+ // props, so a redundant-role check sees the final host tag.
514
+ applySemantics(element, semantics);
515
+ // Dialog-like containers trap keyboard focus so Tab cycles within the
516
+ // dialog rather than escaping to the page behind it. Mount focus and
517
+ // restore-to-trigger run on insert/remove (tracked via dialogIds) — the
518
+ // element isn't in the document yet at create time. Escape closes only
519
+ // when the author declared an onClose action; without one, closing is
520
+ // app state and Escape does nothing.
521
+ if (semantics?.role === "dialog") {
522
+ makeFocusTrap(element);
523
+ this.dialogIds.add(id);
524
+ const onClose = propsObj.onClose ?? propsObj["onClose.0"];
525
+ if (onClose) {
526
+ installDialogEscape(element, onClose);
527
+ }
528
+ }
529
+ // Tablists get the WAI-ARIA roving-tabindex keyboard contract: arrows
530
+ // move focus among the role="tab" children, Home/End jump to the ends,
531
+ // only the focused tab stays in the page Tab order, and printable
532
+ // characters typeahead to the next matching tab.
533
+ if (semantics?.role === "tablist") {
534
+ makeRovingTablist(element);
535
+ }
536
+ // Listboxes get the same roving + typeahead contract over their
537
+ // role="option" children (no-op on a native <select> host, where the
538
+ // browser owns the keyboard behaviour).
539
+ if (semantics?.role === "listbox") {
540
+ makeRovingListbox(element);
541
+ }
274
542
  this.applicators.applyAll(element, propsObj);
275
- // Actionable components: wire "action" prop as onClick
543
+ // Actionable components: wire "action" prop as onClick, and make
544
+ // non-native hosts (e.g. an actionable Card div) keyboard-operable.
276
545
  if (propsObj.action && ACTIONABLE_TYPES.has(elementType.toLowerCase())) {
277
546
  this.applicators.apply(element, "onClick", propsObj.action);
547
+ makeKeyboardActivatable(element, propsObj.action);
278
548
  }
279
549
  this.nodes.set(id, element);
550
+ this.syncTextBinding(id, element);
280
551
  this.debugTracker.trackRerender(id, element, `create:${elementType}`);
281
552
  // Canvas component: create a CanvasRenderer for its subtree
282
553
  if (elementType.toLowerCase() === "canvas") {
@@ -351,12 +622,14 @@ export class DOMRenderer {
351
622
  // If current template isn't a template, keep it in sync.
352
623
  element.dataset.textTemplate = nextText;
353
624
  }
625
+ this.syncTextBinding(id, element);
354
626
  log.debug(`Updated text content: "${value}"`);
355
627
  return;
356
628
  }
357
629
  // Actionable components: wire "action" prop as onClick
358
630
  if (name === "action" && ACTIONABLE_TYPES.has(element.dataset.hypenType || "")) {
359
631
  this.applicators.apply(element, "onClick", value);
632
+ makeKeyboardActivatable(element, value);
360
633
  return;
361
634
  }
362
635
  // Element-specific HTML attributes (img.src, input.placeholder, etc.)
@@ -407,11 +680,16 @@ export class DOMRenderer {
407
680
  onInsert(parentId, id, beforeId) {
408
681
  const parent = parentId === "root" ? this.container : this.nodes.get(parentId);
409
682
  const child = this.nodes.get(id);
410
- log.debug(`Inserting ${id} into ${parentId}`, {
411
- parent: parent ? `${parent.tagName}#${parent.id || 'no-id'}` : 'null',
412
- child: child ? `${child.tagName}#${child.id || 'no-id'}` : 'null',
413
- childText: child?.textContent?.substring(0, 20)
414
- });
683
+ // Gated up front: building the payload reads `textContent`, which
684
+ // serializes the whole subtree's text — too costly to pay when debug
685
+ // logging is off.
686
+ if (isDebugMode()) {
687
+ log.debug(`Inserting ${id} into ${parentId}`, {
688
+ parent: parent ? `${parent.tagName}#${parent.id || 'no-id'}` : 'null',
689
+ child: child ? `${child.tagName}#${child.id || 'no-id'}` : 'null',
690
+ childText: child?.textContent?.substring(0, 20)
691
+ });
692
+ }
415
693
  if (!parent || !child)
416
694
  return;
417
695
  if (parentId === "root") {
@@ -431,6 +709,12 @@ export class DOMRenderer {
431
709
  parent.appendChild(child);
432
710
  }
433
711
  }
712
+ // Dialog entering the document (insert or cached re-attach): queue mount
713
+ // focus for the end of the batch (flushDialogMounts). Already-open
714
+ // dialogs are filtered there, so moves never re-focus.
715
+ if (this.dialogIds.has(id)) {
716
+ this.pendingDialogMounts.push(id);
717
+ }
434
718
  }
435
719
  /**
436
720
  * Move an element within the tree
@@ -459,15 +743,47 @@ export class DOMRenderer {
459
743
  const element = this.nodes.get(id);
460
744
  if (!element)
461
745
  return;
746
+ // Remember where focus was inside the leaving route, so a cached
747
+ // re-`attach` of this subtree can restore it (route-focus contract).
748
+ const active = (element.ownerDocument?.activeElement ??
749
+ (typeof document !== "undefined" ? document.activeElement : null));
750
+ if (active && (element === active || element.contains?.(active))) {
751
+ this.routeFocusMemory.set(id, active);
752
+ }
462
753
  if (element.parentNode) {
463
754
  element.parentNode.removeChild(element);
464
755
  }
756
+ // Any open dialog inside the detached subtree just left the document:
757
+ // restore its trigger (a trigger detached along with it fails the
758
+ // connectivity check and is skipped — the route-focus contract owns
759
+ // focus for the incoming route). Runs after the DOM unlink so the
760
+ // connectivity check reflects the detach. dialogIds keeps the id — a
761
+ // cached re-attach re-runs mount focus.
762
+ this.restoreDialogsWithin(element);
465
763
  // If the root gets detached (unlikely but defensible), clear
466
764
  // rootId so a later attach can re-root cleanly.
467
765
  if (this.rootId === id) {
468
766
  this.rootId = null;
469
767
  }
470
768
  }
769
+ /**
770
+ * Close-side of the dialog focus contract: every open dialog at-or-under
771
+ * `subtree` has left the document — restore focus to its remembered
772
+ * trigger (when still connected) and drop the open entry so a later
773
+ * insert/attach counts as a fresh mount.
774
+ */
775
+ restoreDialogsWithin(subtree) {
776
+ if (this.dialogOpeners.size === 0)
777
+ return;
778
+ for (const [dialogId, opener] of this.dialogOpeners) {
779
+ const dialog = this.nodes.get(dialogId);
780
+ if (!dialog || subtree === dialog || subtree.contains?.(dialog)) {
781
+ this.dialogOpeners.delete(dialogId);
782
+ if (dialog)
783
+ restoreDialogFocus(dialog, opener);
784
+ }
785
+ }
786
+ }
471
787
  /**
472
788
  * Remove an element from the tree
473
789
  */
@@ -481,9 +797,12 @@ export class DOMRenderer {
481
797
  this.canvasRenderers.delete(id);
482
798
  this.canvasElements.delete(id);
483
799
  // Remove all subtree entries pointing to this canvas root
484
- for (const [nodeId, rootId] of this.canvasSubtreeMap) {
485
- if (rootId === id)
486
- this.canvasSubtreeMap.delete(nodeId);
800
+ const members = this.canvasNodeChildren.get(id);
801
+ if (members) {
802
+ this.canvasNodeChildren.delete(id);
803
+ for (const childId of members) {
804
+ this.pruneCanvasSubtree(childId);
805
+ }
487
806
  }
488
807
  }
489
808
  // Dispose event listeners and other resources before removing from DOM
@@ -491,7 +810,16 @@ export class DOMRenderer {
491
810
  if (element.parentNode) {
492
811
  element.parentNode.removeChild(element);
493
812
  }
813
+ // Restore-to-trigger for any open dialog in the removed subtree, before
814
+ // the node map forgets it. The removed subtree is gone for good, so the
815
+ // dialog id is dropped too.
816
+ this.restoreDialogsWithin(element);
494
817
  this.nodes.delete(id);
818
+ this.textBindings.delete(id);
819
+ this.dialogIds.delete(id);
820
+ // Router LRU eviction: this subtree is gone for good — focus restore
821
+ // must never target it again (route-focus contract).
822
+ this.routeFocusMemory.delete(id);
495
823
  if (this.rootId === id) {
496
824
  this.rootId = null;
497
825
  }
@@ -513,13 +841,20 @@ export class DOMRenderer {
513
841
  this.canvasRenderers.clear();
514
842
  this.canvasElements.clear();
515
843
  this.canvasSubtreeMap.clear();
844
+ this.canvasNodeChildren.clear();
845
+ this.canvasNodeParent.clear();
516
846
  // Dispose all element resources before clearing
517
847
  for (const element of this.nodes.values()) {
518
848
  disposeHypenElement(element);
519
849
  }
520
850
  this.container.innerHTML = "";
521
851
  this.nodes.clear();
852
+ this.textBindings.clear();
522
853
  this.rootId = null;
854
+ this.dialogIds.clear();
855
+ this.dialogOpeners.clear();
856
+ this.pendingDialogMounts = [];
857
+ this.routeFocusMemory.clear();
523
858
  }
524
859
  /**
525
860
  * Get the component registry (for registering custom components)