@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.
- package/dist/canvas/accessibility.d.ts +118 -11
- package/dist/canvas/accessibility.js +470 -108
- package/dist/canvas/accessibility.js.map +1 -1
- package/dist/canvas/dispatch.d.ts +24 -0
- package/dist/canvas/dispatch.js +62 -0
- package/dist/canvas/dispatch.js.map +1 -0
- package/dist/canvas/editing.d.ts +178 -0
- package/dist/canvas/editing.js +590 -0
- package/dist/canvas/editing.js.map +1 -0
- package/dist/canvas/events.d.ts +28 -23
- package/dist/canvas/events.js +76 -128
- package/dist/canvas/events.js.map +1 -1
- package/dist/canvas/focus.d.ts +78 -0
- package/dist/canvas/focus.js +182 -0
- package/dist/canvas/focus.js.map +1 -0
- package/dist/canvas/index.d.ts +3 -1
- package/dist/canvas/index.js +3 -1
- package/dist/canvas/index.js.map +1 -1
- package/dist/canvas/paint.d.ts +16 -3
- package/dist/canvas/paint.js +283 -41
- package/dist/canvas/paint.js.map +1 -1
- package/dist/canvas/renderer.d.ts +20 -1
- package/dist/canvas/renderer.js +181 -50
- package/dist/canvas/renderer.js.map +1 -1
- package/dist/canvas/selection.js +31 -177
- package/dist/canvas/selection.js.map +1 -1
- package/dist/canvas/text-geometry.d.ts +72 -0
- package/dist/canvas/text-geometry.js +244 -0
- package/dist/canvas/text-geometry.js.map +1 -0
- package/dist/canvas/text.js +42 -9
- package/dist/canvas/text.js.map +1 -1
- package/dist/canvas/types.d.ts +7 -1
- package/dist/canvas/variants.d.ts +4 -1
- package/dist/canvas/variants.js +22 -3
- package/dist/canvas/variants.js.map +1 -1
- package/dist/dom/a11y-styles.d.ts +20 -0
- package/dist/dom/a11y-styles.js +61 -0
- package/dist/dom/a11y-styles.js.map +1 -0
- package/dist/dom/applicators/aria.d.ts +19 -0
- package/dist/dom/applicators/aria.js +36 -0
- package/dist/dom/applicators/aria.js.map +1 -0
- package/dist/dom/applicators/events.d.ts +7 -0
- package/dist/dom/applicators/events.js +35 -8
- package/dist/dom/applicators/events.js.map +1 -1
- package/dist/dom/applicators/index.js +4 -0
- package/dist/dom/applicators/index.js.map +1 -1
- package/dist/dom/components/hypenapp.d.ts +25 -1
- package/dist/dom/components/hypenapp.js +213 -245
- package/dist/dom/components/hypenapp.js.map +1 -1
- package/dist/dom/components/index.js +7 -0
- package/dist/dom/components/index.js.map +1 -1
- package/dist/dom/components/spinner.js +24 -10
- package/dist/dom/components/spinner.js.map +1 -1
- package/dist/dom/components/tabs.d.ts +27 -0
- package/dist/dom/components/tabs.js +69 -0
- package/dist/dom/components/tabs.js.map +1 -0
- package/dist/dom/components/visuallyhidden.d.ts +9 -0
- package/dist/dom/components/visuallyhidden.js +26 -0
- package/dist/dom/components/visuallyhidden.js.map +1 -0
- package/dist/dom/operability.d.ts +100 -0
- package/dist/dom/operability.js +298 -0
- package/dist/dom/operability.js.map +1 -0
- package/dist/dom/renderer.d.ts +98 -5
- package/dist/dom/renderer.js +398 -63
- package/dist/dom/renderer.js.map +1 -1
- package/dist/dom/route-focus.d.ts +42 -0
- package/dist/dom/route-focus.js +88 -0
- package/dist/dom/route-focus.js.map +1 -0
- package/dist/dom/semantics.d.ts +35 -0
- package/dist/dom/semantics.js +184 -0
- package/dist/dom/semantics.js.map +1 -0
- package/package.json +2 -2
- package/src/canvas/QUICKSTART.md +4 -4
- package/src/canvas/README.md +55 -29
- package/src/canvas/accessibility.ts +507 -115
- package/src/canvas/dispatch.ts +78 -0
- package/src/canvas/editing.ts +697 -0
- package/src/canvas/events.ts +89 -142
- package/src/canvas/focus.ts +216 -0
- package/src/canvas/index.ts +8 -1
- package/src/canvas/paint.ts +339 -42
- package/src/canvas/renderer.ts +218 -70
- package/src/canvas/selection.ts +52 -210
- package/src/canvas/text-geometry.ts +311 -0
- package/src/canvas/text.ts +43 -9
- package/src/canvas/types.ts +14 -1
- package/src/canvas/variants.ts +24 -5
- package/src/dom/a11y-styles.ts +65 -0
- package/src/dom/applicators/aria.ts +41 -0
- package/src/dom/applicators/events.ts +38 -7
- package/src/dom/applicators/index.ts +5 -0
- package/src/dom/components/hypenapp.ts +244 -272
- package/src/dom/components/index.ts +7 -0
- package/src/dom/components/spinner.ts +31 -13
- package/src/dom/components/tabs.ts +76 -0
- package/src/dom/components/visuallyhidden.ts +30 -0
- package/src/dom/operability.ts +312 -0
- package/src/dom/renderer.ts +455 -63
- package/src/dom/route-focus.ts +98 -0
- package/src/dom/semantics.ts +199 -0
- package/dist/canvas/input.d.ts +0 -76
- package/dist/canvas/input.js +0 -207
- package/dist/canvas/input.js.map +0 -1
- package/src/canvas/input.ts +0 -251
package/dist/dom/renderer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
renderer
|
|
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,
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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)
|