@zubra/runtime 0.0.0-stage → 5.0.2

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/src/element.js ADDED
@@ -0,0 +1,1837 @@
1
+ // define(): the custom element a compiled component becomes.
2
+ //
3
+ // The compiler hands over a template and a list of parts (see src/emit_js.rs). Each part has a
4
+ // path to a node and a function computing what goes there. On first connection the element
5
+ // either clones the template (fresh) or finds the same nodes in markup the server already
6
+ // rendered (hydration), then binds every part with an effect. Hydration never re-renders: a
7
+ // part only writes when its value differs from what is already in the DOM.
8
+ //
9
+ // Paths count element and comment children only. The browser merges and drops text nodes, so
10
+ // text is reached through its element (an only child) or through an empty-comment anchor.
11
+ import { Signal } from './signal.js';
12
+ import { wrap, raw } from './deep.js';
13
+ import { effect, scope, root as rootScope, tick, batch } from './effect.js';
14
+ import { RESOURCE } from './resource.js';
15
+
16
+ const { untrack } = Signal.subtle;
17
+ const TEXT = 3;
18
+ const COMMENT = 8;
19
+
20
+ // ---- values --------------------------------------------------------------------------------
21
+
22
+ const isSignal = (value) => value instanceof Signal.State || value instanceof Signal.Computed;
23
+
24
+ /** Unwrap a signal, so templates can read `{count}` for `const count = state(0)`. */
25
+ /** Where a message's topic is documented. */
26
+ const see = (section) => ` (see https://github.com/journey-to-code-org/zubra#${section})`;
27
+
28
+ export const $r = (value) => (isSignal(value) ? value.get() : value);
29
+
30
+ /** Text for a `{…}` in content: null/undefined render nothing; objects are an error. */
31
+ export function $t(value) {
32
+ value = $r(value);
33
+ if (value == null) return '';
34
+ if (typeof value === 'object') {
35
+ console.error('zubra: cannot render an object as text; access a field or iterate it instead', value);
36
+ return '';
37
+ }
38
+ return String(value);
39
+ }
40
+
41
+ /** The same rule the compiler applies at build time (src/validate.rs `is_safe_url`). */
42
+ export function isSafeUrl(value) {
43
+ const url = String(value).trim();
44
+ if (!url || /[\u0000-\u001f\u007f-\u009f]/.test(url) || url.startsWith('//')) return false;
45
+ const beforePath = url.split(/[/?#]/)[0];
46
+ const colon = beforePath.indexOf(':');
47
+ if (colon < 0) return true;
48
+ return ['http', 'https', 'mailto', 'tel'].includes(beforePath.slice(0, colon).toLowerCase());
49
+ }
50
+
51
+ const clone = (value) => (value && typeof value === 'object' ? structuredClone(value) : value);
52
+
53
+ /** Attribute text → prop value, typed by the prop's default (JSON for arrays and objects). */
54
+ function parseAttribute(spec, raw) {
55
+ const fallback = spec.default;
56
+ if (typeof fallback === 'boolean') return fallback ? raw !== 'false' : raw !== null;
57
+ if (raw === null) return clone(fallback);
58
+ if (typeof fallback === 'number') return Number(raw);
59
+ if (typeof fallback === 'string') return raw;
60
+ if (fallback === undefined || fallback === null) {
61
+ try { return JSON.parse(raw); } catch { return raw; }
62
+ }
63
+ // A framework's server rendering stringified an object it sets as a property once it runs
64
+ // (Svelte does this): not given yet, rather than invalid.
65
+ if (raw === '[object Object]') return clone(fallback);
66
+ try {
67
+ return JSON.parse(raw);
68
+ } catch {
69
+ console.warn(`zubra: attribute "${spec.attribute}" is not valid JSON:`, raw);
70
+ return clone(fallback);
71
+ }
72
+ }
73
+
74
+ // ---- hooks ---------------------------------------------------------------------------------
75
+
76
+ let current = null; // lifecycle hooks of the component whose setup is running
77
+
78
+ function hooks(name) {
79
+ if (!current) throw new Error(`zubra: ${name}() must be called while a component is setting up${see('script-state-and-hooks')}`);
80
+ return current;
81
+ }
82
+
83
+ /**
84
+ * The signal itself, where a script passes a signal `const` along (`watch(signal(count), …)`).
85
+ * It returns what it's given: since 3.0 a signal `const` in a script means its value, and the
86
+ * compiler leaves `signal(name)`'s argument alone, so it's the signal. (`plan/decisions/3.0-scope.md`.)
87
+ */
88
+ export const signal = (value) => value;
89
+
90
+ /** The original object behind reactive state (`let items = []` holds a proxy of the array). */
91
+ export { raw };
92
+
93
+ export const state = (value, options) => {
94
+ // @hot (left out of the production build: scripts/build-production.js)
95
+ // During a hot reload, a component's state keeps its value: by position, like hooks, unless
96
+ // the edit changed what kind of value it holds.
97
+ const kept = current?.kept;
98
+ const at = current?.states?.length ?? 0;
99
+ if (kept && at < kept.length && kind(kept[at]) === kind(value)) value = kept[at];
100
+ // @hot-end
101
+ const signal = new Signal.State(wrap(value), options);
102
+ // An array or plain object assigned later is wrapped the same way, so `items = [...]` followed
103
+ // by `items.push(x)` keeps updating.
104
+ signal.set = (next) => Signal.State.prototype.set.call(signal, wrap(next));
105
+ current?.states?.push(signal);
106
+ return signal;
107
+ };
108
+ /**
109
+ * State that follows a prop until it's assigned, and starts over when the prop changes:
110
+ *
111
+ * let left = from(seconds) // a timer's time left: counts down, resets on a new length
112
+ * let running = from(seconds, false) // false, and false again whenever `seconds` changes
113
+ *
114
+ * With a second argument, the state starts as that value instead, each time the first changes.
115
+ * In a `.zb` script the compiler passes both as functions (`from(() => seconds)`), so the state
116
+ * can follow them. Outside one, pass a function or a signal.
117
+ */
118
+ export function from(source, start) {
119
+ const read = (value) => (typeof value === 'function' ? value() : isSignal(value) ? value.get() : value);
120
+ // A fresh box each time the source changes, so an assignment made against one value of the
121
+ // source is dropped once it moves on (even back to the same value).
122
+ const latest = new Signal.Computed(() => {
123
+ const value = read(source);
124
+ return { value: start === undefined ? value : read(start) };
125
+ });
126
+ const assigned = new Signal.State(null);
127
+ const linked = new Signal.Computed(() => {
128
+ const base = latest.get();
129
+ const own = assigned.get();
130
+ return wrap(own && own.base === base ? own.value : base.value);
131
+ });
132
+ linked.set = (value) => assigned.set({ base: untrack(() => latest.get()), value });
133
+ // @hot (left out of the production build: scripts/build-production.js)
134
+ // A hot reload keeps an assigned value, by position, like state().
135
+ const kept = current?.kept;
136
+ const at = current?.states?.length ?? 0;
137
+ if (kept && at < kept.length && kind(kept[at]) === kind(untrack(() => linked.get()))) linked.set(kept[at]);
138
+ // @hot-end
139
+ current?.states?.push(linked);
140
+ return linked;
141
+ }
142
+ // @hot
143
+ const kind = (value) => (value === null || value === undefined ? 'empty' : Array.isArray(value) ? 'array' : typeof value);
144
+ // @hot-end
145
+ export const computed = (fn, options) => new Signal.Computed(fn, options);
146
+
147
+ /**
148
+ * Whether the element has started: false while its markup is the server's (and on the server),
149
+ * true from when it hydrates (or renders) in the browser. A signal, read like a value in the
150
+ * template, for what only works with JavaScript:
151
+ *
152
+ * const live = hydrated()
153
+ * <zb-if test={!live}><button>Apply</button></zb-if>
154
+ *
155
+ * The host also gets the custom state `hydrated`, for CSS: `:host(:state(hydrated))`, or
156
+ * `my-filters:state(hydrated)` from the page.
157
+ */
158
+ export function hydrated() {
159
+ return hooks('hydrated').hydrated;
160
+ }
161
+
162
+ /** Run when the element is connected; may return a cleanup for when it is disconnected. */
163
+ export function onMount(fn) {
164
+ hooks('onMount').mount.push(fn);
165
+ }
166
+
167
+ /** Run when the element is disconnected. */
168
+ export function onUnmount(fn) {
169
+ hooks('onUnmount').unmount.push(fn);
170
+ }
171
+
172
+ /**
173
+ * The component's props: reading one tracks it (in `computed`, `effect`, the template), and
174
+ * writing one updates the element as if it had been set from outside.
175
+ */
176
+ export function props() {
177
+ return hooks('props').props;
178
+ }
179
+
180
+ /**
181
+ * The element whose setup is running, for custom hooks that need it (to read an attribute,
182
+ * dispatch an event, observe its size, …).
183
+ */
184
+ export function host() {
185
+ return hooks('host').host;
186
+ }
187
+
188
+ /**
189
+ * Fire a DOM event from the component: `emit('valuechange', value)`. The compiler binds `emit`
190
+ * to each element, so handlers and timers can call it long after setup, and declares every
191
+ * event it sees. Outside a compiled component, use the `emit` that `setup` receives.
192
+ */
193
+ export function emit(name) {
194
+ throw new Error(`zubra: emit("${name}") is bound to each element by the compiler; outside a compiled component, use the emit that setup() receives`);
195
+ }
196
+
197
+ /**
198
+ * An id unique to this element and call, for `id`/`for`/`aria-*` attributes. Stable for the
199
+ * element's life; based on the host's own `id` when it has one, so it can be made to match
200
+ * server-rendered markup.
201
+ */
202
+ export function id() {
203
+ const component = hooks('id');
204
+ const base = component.host.id || `${component.host.localName}-${component.instance}`;
205
+ return `${base}-${++component.ids}`;
206
+ }
207
+
208
+ // ---- form-associated components ------------------------------------------------------------
209
+ //
210
+ // With `form-associated` on the host, the element is a form control built on the platform's
211
+ // ElementInternals: it submits a value, takes part in validation, reset and restore, and is
212
+ // disabled by a disabled <fieldset> — and exposes the same API as native controls.
213
+
214
+ function formHooks(name) {
215
+ const component = hooks(name);
216
+ if (!component.internals) {
217
+ throw new Error(`zubra: ${name}() needs form-associated on the component's host tag${see('forms')}`);
218
+ }
219
+ return component;
220
+ }
221
+
222
+ /** What the form submits for this control: follows `source` (a signal, function or value). */
223
+ export function formValue(source) {
224
+ const component = formHooks('formValue');
225
+ const { internals } = component;
226
+ component.formValued = true;
227
+ effect(() => {
228
+ const value = $r(typeof source === 'function' ? source() : source);
229
+ internals.setFormValue(value == null ? null : value instanceof FormData || value instanceof File ? value : String(value));
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Validation: `check()` returns an error message, or '' / null when the value is valid. Re-runs
235
+ * when what it reads changes; the form won't submit while it reports an error.
236
+ */
237
+ const FOCUSABLE = 'input:not([type=hidden]), select, textarea, button, [tabindex]:not([tabindex="-1"]), [contenteditable]';
238
+
239
+ export function validity(check) {
240
+ const component = formHooks('validity');
241
+ const { internals, host } = component;
242
+ // The browser shows the message at the anchor and focuses it when a form won't submit: the
243
+ // first focusable element the component rendered (the host itself isn't focusable).
244
+ const anchor = () => {
245
+ for (const node of (host.shadowRoot ?? host).querySelectorAll(FOCUSABLE)) {
246
+ if (!node.closest('[data-zb-slotted]')) return node;
247
+ }
248
+ return host;
249
+ };
250
+ let message = '';
251
+ const apply = () => {
252
+ if (message) internals.setValidity({ customError: true }, message, anchor());
253
+ else internals.setValidity({});
254
+ };
255
+ effect(() => {
256
+ message = String($r(check()) || '');
257
+ apply();
258
+ });
259
+ component.mount.push(apply); // once rendered, the anchor is inside
260
+ }
261
+
262
+ /** The form was reset. */
263
+ export function onFormReset(fn) {
264
+ formHooks('onFormReset').formReset.push(fn);
265
+ }
266
+
267
+ /** The browser restored a value (back/forward navigation, autofill): `fn(state, mode)`. */
268
+ export function onFormRestore(fn) {
269
+ formHooks('onFormRestore').formRestore.push(fn);
270
+ }
271
+
272
+ /** The control was disabled or enabled (its own attribute, or a disabled <fieldset>). */
273
+ export function onFormDisabled(fn) {
274
+ formHooks('onFormDisabled').formDisabled.push(fn);
275
+ }
276
+
277
+ // ---- context (the Web Components Community Group Context Protocol) -------------------------
278
+ //
279
+ // A consumer dispatches a bubbling, composed `context-request` event carrying the context key
280
+ // and a callback; the nearest provider for that key stops it and calls back with the value,
281
+ // and with `subscribe: true` keeps calling back when the value changes, passing an
282
+ // `unsubscribe` function. Providers announce themselves with a `context-provider` event, so a
283
+ // consumer that asked before its provider existed can ask again. Because it is plain DOM events,
284
+ // zubra components share context with any other implementation (Lit's @lit/context, …).
285
+
286
+ export class ContextRequestEvent extends Event {
287
+ constructor(context, contextTarget, callback, subscribe) {
288
+ super('context-request', { bubbles: true, composed: true });
289
+ this.context = context;
290
+ this.contextTarget = contextTarget;
291
+ this.callback = callback;
292
+ this.subscribe = subscribe;
293
+ }
294
+ }
295
+
296
+ export class ContextProviderEvent extends Event {
297
+ constructor(context, contextTarget) {
298
+ super('context-provider', { bubbles: true, composed: true });
299
+ this.context = context;
300
+ this.contextTarget = contextTarget;
301
+ }
302
+ }
303
+
304
+ /** A context key, with the value consumers get when no provider answers. */
305
+ export function createContext(defaultValue) {
306
+ return Object.freeze({ defaultValue });
307
+ }
308
+
309
+ /**
310
+ * Provide `value` (a plain value or a signal) for `context` to every element inside this one.
311
+ * Must be called during setup.
312
+ */
313
+ export function provide(context, value) {
314
+ const host = hooks('provide').host;
315
+ const subscribers = new Set();
316
+ host.addEventListener('context-request', (event) => {
317
+ if (event.context !== context || event.contextTarget === host) return;
318
+ event.stopPropagation();
319
+ if (!event.subscribe) return event.callback($r(value));
320
+ const subscriber = { callback: event.callback };
321
+ subscriber.unsubscribe = () => subscribers.delete(subscriber);
322
+ subscribers.add(subscriber);
323
+ event.callback($r(value), subscriber.unsubscribe);
324
+ });
325
+ watch(() => $r(value), (next) => {
326
+ for (const subscriber of [...subscribers]) subscriber.callback(next, subscriber.unsubscribe);
327
+ });
328
+ host.dispatchEvent(new ContextProviderEvent(context, host));
329
+ }
330
+
331
+ /**
332
+ * Read `context` from the nearest provider above this element, as a signal that follows the
333
+ * provider's value. Must be called during setup. Without a provider the value is `fallback`
334
+ * (by default the context's own default). Works with any Context Protocol provider: pass its
335
+ * key as `context`.
336
+ */
337
+ export function inject(context, fallback = context?.defaultValue) {
338
+ const host = hooks('inject').host;
339
+ const value = new Signal.State(fallback);
340
+ let unsubscribe = null;
341
+ let answered = false;
342
+ let generation = 0;
343
+ const request = () => {
344
+ const mine = ++generation;
345
+ answered = false;
346
+ host.dispatchEvent(new ContextRequestEvent(context, host, (next, stop) => {
347
+ if (mine !== generation) return stop?.(); // an answer to a request we've moved past
348
+ answered = true;
349
+ if (stop) unsubscribe = stop;
350
+ value.set(next);
351
+ }, true));
352
+ };
353
+ const lateProvider = (event) => {
354
+ if (!answered && event.context === context && event.contextTarget !== host) request();
355
+ };
356
+ // Ask now, so the first render already has the value; ask again whenever the element is
357
+ // re-attached (it may have moved under a different provider).
358
+ let firstMount = true;
359
+ request();
360
+ onMount(() => {
361
+ if (!firstMount) request();
362
+ firstMount = false;
363
+ document.addEventListener('context-provider', lateProvider);
364
+ return () => {
365
+ document.removeEventListener('context-provider', lateProvider);
366
+ generation++;
367
+ unsubscribe?.();
368
+ unsubscribe = null;
369
+ };
370
+ });
371
+ return new Signal.Computed(() => value.get());
372
+ }
373
+
374
+ /** Call `callback(next, previous)` when `source()` changes (not on the first run). */
375
+ export function watch(source, callback) {
376
+ let first = true;
377
+ let previous;
378
+ effect(() => {
379
+ const next = $r(typeof source === 'function' ? source() : source);
380
+ if (!first && !Object.is(next, previous)) {
381
+ const old = previous;
382
+ untrack(() => callback(next, old));
383
+ }
384
+ first = false;
385
+ previous = next;
386
+ });
387
+ }
388
+
389
+ // ---- finding parts -------------------------------------------------------------------------
390
+ //
391
+ // A range is a run of sibling nodes: an element's children, or the nodes between a region's
392
+ // `<?zb [?>` and its matching `<?zb ]?>`. Within a range, positions count elements and comments
393
+ // only, and a whole region counts as one position (its start marker), so a list's length never
394
+ // shifts the paths of what comes after it.
395
+
396
+ // `[!` opens a slot region holding supplied content (static, left alone on hydration).
397
+ // `[?` opens a region the server left for the element to render (partial rendering).
398
+ //
399
+ // The server writes markers as processing instructions (`<?zb [?>`, `<?zb ]?>`, `<?zb ?>`), which
400
+ // HTML minifiers and sanitizers keep. A browser that doesn't parse them yet makes each a comment
401
+ // (`?zb [?`), and client templates and 4.x servers use plain comments (`[`): `marker` reads all three.
402
+ const PI = 7;
403
+ /** A marker's text (`[`, `[!`, `[?`, `]`, or '' for a text anchor), or null for any other node. */
404
+ function marker(node) {
405
+ if (node.nodeType === PI) return node.target === 'zb' ? node.data : null;
406
+ if (node.nodeType !== COMMENT) return null;
407
+ const data = node.data;
408
+ return data.startsWith('?zb ') ? data.slice(4, data.endsWith('?') ? -1 : undefined) : data;
409
+ }
410
+ const isOpen = (node) => { const m = marker(node); return m === '[' || m === '[!' || m === '[?'; };
411
+ /**
412
+ * Whether to bind a region's server markup: not when the server left it to the element. A region
413
+ * the server is still streaming sits in a named range (`<?start name>`…`<?end>`): the element
414
+ * renders it from now on, so it removes the name, and the browser's `<template for>` finds nothing.
415
+ */
416
+ function hydratable(open, hydrating) {
417
+ if (!hydrating || marker(open) !== '[?') return hydrating;
418
+ const named = (node, kind) => node && (node.nodeType === PI ? node.target === kind : marker(node)?.startsWith(`?${kind}`));
419
+ const start = open.previousSibling;
420
+ const end = matchingClose(open)?.nextSibling;
421
+ if (named(start, 'start') && named(end, 'end')) { start.remove(); end.remove(); }
422
+ return false;
423
+ }
424
+ const isClose = (node) => marker(node) === ']';
425
+ /** Change a region's opening marker: a PI keeps its target, a comment takes the plain form. */
426
+ const setMarker = (node, text) => { if (marker(node) !== text) node.data = text; };
427
+
428
+ function matchingClose(open) {
429
+ let depth = 0;
430
+ for (let node = open.nextSibling; node; node = node.nextSibling) {
431
+ if (isOpen(node)) depth++;
432
+ else if (isClose(node)) {
433
+ if (depth === 0) return node;
434
+ depth--;
435
+ }
436
+ }
437
+ return null;
438
+ }
439
+
440
+ const MARKERS = ['[', '[!', '[?', ']', ''];
441
+ /** The nodes that hold a position: elements and markers (not text, nor a stream's `<?start>`). */
442
+ function* positions(first, stop) {
443
+ for (let node = first; node && node !== stop; node = node.nextSibling) {
444
+ if (node.nodeType !== 1 && !MARKERS.includes(marker(node))) continue;
445
+ yield node;
446
+ if (isOpen(node)) {
447
+ node = matchingClose(node);
448
+ if (!node) return;
449
+ }
450
+ }
451
+ }
452
+
453
+ function walk(range, path) {
454
+ let first = range.first;
455
+ let stop = range.stop;
456
+ let node = null;
457
+ for (const index of path) {
458
+ node = null;
459
+ let seen = -1;
460
+ for (const candidate of positions(first, stop)) {
461
+ if (++seen === index) { node = candidate; break; }
462
+ }
463
+ if (!node) return null;
464
+ // The next step goes into this node: an element's children, or a region's contents (a
465
+ // part inside a child component's slot, in server markup, is reached that way).
466
+ if (isOpen(node)) {
467
+ first = node.nextSibling;
468
+ stop = matchingClose(node);
469
+ } else {
470
+ first = node.firstChild;
471
+ stop = null;
472
+ }
473
+ }
474
+ return node;
475
+ }
476
+
477
+ const childRange = (parent) => ({ first: parent.firstChild, stop: null });
478
+ const regionRange = (open, close) => ({ first: open.nextSibling, stop: close });
479
+
480
+ /** The node for every part, or null if the markup doesn't have the template's shape. */
481
+ function locate(range, parts, hydrating = false) {
482
+ const nodes = [];
483
+ for (const part of parts) {
484
+ // In server markup, content given to a child component sits where the child projected it.
485
+ const node = walk(range, hydrating && part.hydrate ? part.hydrate : part.path);
486
+ const fits = !node ? false
487
+ : part.when || part.each || part.slot || part.await ? isOpen(node) && matchingClose(node) !== null
488
+ : part.anchor || part.global ? marker(node) === ''
489
+ : node.nodeType === 1;
490
+ if (!fits) return null;
491
+ nodes.push(node);
492
+ }
493
+ return nodes;
494
+ }
495
+
496
+ // ---- blocks: the contents of an zb-if branch or an zb-each row ----------------------------
497
+
498
+ const blueprints = new WeakMap();
499
+ const SVG = 'http://www.w3.org/2000/svg';
500
+ const MATHML = 'http://www.w3.org/1998/Math/MathML';
501
+
502
+ /**
503
+ * A block's template, parsed once per context. Inside `<svg>` (or `<math>`), the markup is
504
+ * parsed inside that element, so `<circle>` and friends get their namespace: parsed alone, they
505
+ * would be unknown HTML elements.
506
+ */
507
+ // The element a fragment's nodes will go into, for fragments assembled before they're placed.
508
+ const fragmentParents = new WeakMap();
509
+ /** The element `node` is in, or will be in once its fragment is placed. */
510
+ function parentOf(node) {
511
+ const parent = node.parentNode;
512
+ return parent?.nodeType === 11 ? fragmentParents.get(parent) ?? null : parent;
513
+ }
514
+
515
+ function blueprint(source, parent = null) {
516
+ const ns = parent?.namespaceURI;
517
+ const context = ns === SVG && parent.localName !== 'foreignObject' ? 'svg' : ns === MATHML ? 'math' : '';
518
+ let byContext = blueprints.get(source);
519
+ if (!byContext) blueprints.set(source, (byContext = {}));
520
+ let content = byContext[context];
521
+ if (!content) {
522
+ const element = document.createElement('template');
523
+ element.innerHTML = context ? `<${context}>${source.template}</${context}>` : source.template;
524
+ content = element.content;
525
+ if (context) content.replaceChildren(...content.firstChild.childNodes);
526
+ byContext[context] = content;
527
+ }
528
+ return content;
529
+ }
530
+
531
+ function clearBetween(open, close) {
532
+ while (open.nextSibling && open.nextSibling !== close) open.nextSibling.remove();
533
+ }
534
+
535
+ /** Remove everything between `open` and `close` in one operation. */
536
+ function deleteBetween(open, close) {
537
+ if (open.nextSibling === close) return;
538
+ const range = document.createRange();
539
+ range.setStartAfter(open);
540
+ range.setEndBefore(close);
541
+ range.deleteContents();
542
+ }
543
+
544
+ /**
545
+ * Render `block` between `open` and `close`. When `hydrating`, first try to bind to nodes the
546
+ * server already put there; otherwise (or if they don't fit) clone the block's template.
547
+ */
548
+ function mountBlock(block, args, open, close, hydrating, context) {
549
+ const parts = block.parts(...args);
550
+ const nodes = hydrating ? locate(regionRange(open, close), parts, true) : null;
551
+ if (nodes) {
552
+ parts.forEach((part, index) => bind(part, nodes[index], true, context));
553
+ return;
554
+ }
555
+ if (hydrating) console.warn(`zubra: server markup for a region does not match its template; rendering it fresh${see('server-rendering-and-hydration')}`);
556
+ clearBetween(open, close);
557
+ // Find and bind the parts in the fragment, then insert it: child components inside see
558
+ // their props from the start, and can't move slotted nodes before they're found.
559
+ // importNode, not cloneNode: defined custom elements upgrade now, so their properties exist
560
+ // before parts bind (a clone stays in the template's inert document).
561
+ const parent = parentOf(close);
562
+ const fragment = document.importNode(blueprint(block, parent), true);
563
+ fragmentParents.set(fragment, parent);
564
+ const fresh = locate(childRange(fragment), parts);
565
+ parts.forEach((part, index) => bind(part, fresh[index], false, context));
566
+ close.before(fragment);
567
+ }
568
+
569
+ // Markup whose comments were stripped is put back by `remark.js`, loaded only when an element
570
+ // meets such markup (see connectedCallback).
571
+ let remarker = null; // the module, once loaded
572
+ let remarking = null; // its import
573
+ const loadRemark = () => (remarking ??= import('./remark.js').then(
574
+ (module) => (remarker = module),
575
+ () => (remarker = { remark: () => false }),
576
+ ));
577
+ /** Whether `root`'s server markup lost the comments `template` has (its top-level elements are there). */
578
+ function lostComments(root, template) {
579
+ if (!root || !hasContent(root) || !hasComment(template) || hasComment(root)) return false;
580
+ // The page's own children for a slot aren't server markup: the template's top-level elements
581
+ // must be there, in order.
582
+ const children = [...root.children];
583
+ let at = 0;
584
+ for (const node of positions(template.firstChild, null)) {
585
+ if (node.nodeType !== 1) continue;
586
+ while (at < children.length && children[at].localName !== node.localName) at++;
587
+ if (at++ >= children.length) return false;
588
+ }
589
+ return true;
590
+ }
591
+
592
+ /** A range's shape: its top-level positions as tag names and region markers. */
593
+ const hasComment = (root) =>
594
+ !!document.createTreeWalker(root, NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_PROCESSING_INSTRUCTION).nextNode();
595
+ const signature = (range) =>
596
+ [...positions(range.first, range.stop)]
597
+ .map((node) => (node.nodeType === 1 ? node.localName : isOpen(node) ? '[' : '#'))
598
+ .join(' ');
599
+
600
+ // ---- binding parts -------------------------------------------------------------------------
601
+
602
+ // ---- event helpers: the modifiers, as functions (for JSX, and for handlers built in scripts) --
603
+
604
+ /**
605
+ * `function X({ value = model(0) })` in a JSX component marks `value` as two-way (like
606
+ * `value:model={0}` in `.zb`). The compiler reads it; at runtime it's just the default.
607
+ */
608
+ export const model = (value) => value;
609
+
610
+ /** `prevent(fn)`: a handler that calls `event.preventDefault()` first. */
611
+ export const prevent = (fn) => function (event) { event.preventDefault(); return fn?.call(this, event); };
612
+ /** `stop(fn)`: a handler that calls `event.stopPropagation()` first. */
613
+ export const stop = (fn) => function (event) { event.stopPropagation(); return fn?.call(this, event); };
614
+ /** `selfOnly(fn)`: a handler that ignores events from inside the element (`.self`). */
615
+ export const selfOnly = (fn) => function (event) { if (event.target === event.currentTarget) return fn?.call(this, event); };
616
+ /** `submit(fn)`: after the handler, submit the element's form, with validation (`.submit`). */
617
+ export const submit = (fn) => function (event) {
618
+ const result = fn?.call(this, event);
619
+ formOf(event.currentTarget)?.requestSubmit();
620
+ return result;
621
+ };
622
+ const formOf = (node) => node.form ?? node.closest('form') ?? node.getRootNode().host?.form;
623
+
624
+ const KEY_NAMES = {
625
+ enter: 'Enter', tab: 'Tab', space: ' ', esc: 'Escape', escape: 'Escape', up: 'ArrowUp', down: 'ArrowDown',
626
+ left: 'ArrowLeft', right: 'ArrowRight', arrowup: 'ArrowUp', arrowdown: 'ArrowDown', arrowleft: 'ArrowLeft',
627
+ arrowright: 'ArrowRight', delete: 'Delete', backspace: 'Backspace', home: 'Home', end: 'End', pageup: 'PageUp', pagedown: 'PageDown',
628
+ };
629
+ /**
630
+ * `keys('shift+enter', fn)`: a keyboard handler for one key, with exactly the modifier keys named
631
+ * (the same rule as `onkeydown.shift.enter` in a template). Several combinations: `'enter, space'`.
632
+ */
633
+ export function keys(combinations, fn) {
634
+ const wanted = String(combinations).split(',').map((combination) => {
635
+ const pieces = combination.trim().toLowerCase().split('+');
636
+ const key = pieces.pop();
637
+ // `mod` is ⌘ on Apple platforms and Ctrl elsewhere.
638
+ const mod = pieces.includes('mod');
639
+ return { key: KEY_NAMES[key] ?? key, shift: pieces.includes('shift'), ctrl: pieces.includes('ctrl') || (mod && !APPLE()), alt: pieces.includes('alt'), meta: pieces.includes('meta') || (mod && APPLE()) };
640
+ });
641
+ return function (event) {
642
+ const key = event.key ?? '';
643
+ const match = wanted.some((w) => (w.key.length === 1 ? key.toLowerCase() === w.key : key === w.key)
644
+ && w.shift === event.shiftKey && w.ctrl === event.ctrlKey && w.alt === event.altKey && w.meta === event.metaKey);
645
+ if (match) return fn?.call(this, event);
646
+ };
647
+ }
648
+
649
+ let apple;
650
+ const APPLE = () => (apple ??= /mac|iphone|ipad|ipod/i.test(navigator.userAgentData?.platform ?? navigator.platform ?? ''));
651
+
652
+ /**
653
+ * An event part's listener and its options: the handler, read when the event fires, behind the
654
+ * modifiers' checks (`.self`, key filters) and effects (`.prevent`, `.stop`).
655
+ */
656
+ function listen(part, node) {
657
+ const m = part.modifiers;
658
+ // `.mod` is ⌘ on Apple platforms and Ctrl elsewhere.
659
+ const ctrl = m && (m.ctrl || (m.mod && !APPLE()));
660
+ const meta = m && (m.meta || (m.mod && APPLE()));
661
+ const listener = (event) => {
662
+ if (m) {
663
+ if (m.self && event.target !== node) return;
664
+ if (m.keys) {
665
+ const key = event.key ?? '';
666
+ if (!m.keys.some((wanted) => (wanted.length === 1 ? key.toLowerCase() === wanted : key === wanted))) return;
667
+ // With a key, the named modifier keys must be held and the others not.
668
+ if (!!m.shift !== event.shiftKey || !!ctrl !== event.ctrlKey || !!m.alt !== event.altKey || !!meta !== event.metaKey) return;
669
+ } else if ((m.shift && !event.shiftKey) || (ctrl && !event.ctrlKey) || (m.alt && !event.altKey) || (meta && !event.metaKey)) {
670
+ return;
671
+ }
672
+ if (m.prevent) event.preventDefault();
673
+ if (m.stop) event.stopPropagation();
674
+ }
675
+ const handler = $r(part.handler());
676
+ if (typeof handler === 'function') handler.call(node, event);
677
+ // `.submit`: the form the element belongs to (or, in a shadow root, the host's form).
678
+ if (m?.submit) formOf(node)?.requestSubmit();
679
+ };
680
+ const options = m && (m.once || m.capture || m.passive)
681
+ ? { once: !!m.once, capture: !!m.capture, passive: !!m.passive }
682
+ : undefined;
683
+ return [listener, options];
684
+ }
685
+
686
+ function bind(part, node, hydrating, context) {
687
+ if (part.text) {
688
+ effect(() => {
689
+ const text = part.text();
690
+ if (node.textContent !== text) node.textContent = text;
691
+ });
692
+ } else if (part.anchor) {
693
+ effect(() => {
694
+ const text = part.anchor();
695
+ let target = node.nextSibling;
696
+ if (!target || target.nodeType !== TEXT) {
697
+ target = document.createTextNode('');
698
+ node.after(target);
699
+ }
700
+ if (target.data !== text) target.data = text;
701
+ });
702
+ } else if (part.global) {
703
+ // <zb-window>/<zb-document>: listening while this part of the template is rendered.
704
+ const target = part.global === 'window' ? window : document;
705
+ effect(() => {
706
+ const [listener, options] = listen(part, target);
707
+ target.addEventListener(part.on, listener, options);
708
+ return () => target.removeEventListener(part.on, listener, options);
709
+ });
710
+ } else if (part.on) {
711
+ // The handler is read when the event fires, so swapping it never re-subscribes.
712
+ const [listener, options] = listen(part, node);
713
+ node.addEventListener(part.on, listener, options);
714
+ } else if (part.bind) {
715
+ // A part whose code lives in its own entry (`{...spread}`: @zubra/runtime/spread), so
716
+ // components that don't use it don't load it.
717
+ part.bind(part, node);
718
+ } else if (part.classes) {
719
+ // A computed class beside class: toggles: token by token, leaving the toggles' alone.
720
+ let previous = [];
721
+ effect(() => {
722
+ const next = String($r(part.classes()) ?? '').split(/\s+/).filter(Boolean);
723
+ for (const token of previous) if (!next.includes(token)) node.classList.remove(token);
724
+ for (const token of next) node.classList.add(token);
725
+ previous = next;
726
+ });
727
+ } else if (part.toggle) {
728
+ effect(() => {
729
+ node.classList.toggle(part.toggle, !!$r(part.value()));
730
+ });
731
+ } else if (part.style) {
732
+ // One property at a time, through the CSSOM: a value can't add declarations.
733
+ effect(() => {
734
+ const value = $r(part.value());
735
+ if (value == null || value === false) node.style.removeProperty(part.style);
736
+ else if (node.style.getPropertyValue(part.style) !== String(value)) node.style.setProperty(part.style, String(value));
737
+ });
738
+ } else if (part.ref) {
739
+ effect(() => {
740
+ const assign = $r(part.ref());
741
+ if (typeof assign !== 'function') return;
742
+ untrack(() => assign(node));
743
+ return () => untrack(() => assign(null));
744
+ });
745
+ } else if (part.property) {
746
+ // A child component's prop: always a property, so objects and arrays arrive as they are.
747
+ effect(() => {
748
+ node[part.property] = $r(part.value());
749
+ });
750
+ } else if (part.attribute) {
751
+ bindAttribute(part, node);
752
+ } else if (part.when) {
753
+ bindWhen(part, node, hydrating, context);
754
+ } else if (part.await) {
755
+ bindAwait(part, node, hydrating, context);
756
+ } else if (part.each) {
757
+ bindEach(part, node, hydrating, context);
758
+ } else if (part.slot) {
759
+ bindSlot(part, node, hydrating, context);
760
+ }
761
+ }
762
+
763
+ /** The signal saying whether slot `name` has content (shared by hasSlot() and the slot). */
764
+ function filledSignal(filled, name) {
765
+ let signal = filled.get(name);
766
+ if (!signal) filled.set(name, (signal = new Signal.State(false)));
767
+ return signal;
768
+ }
769
+
770
+ /**
771
+ * Whether content was given for slot `name` (`'default'` for the default slot), as a signal that
772
+ * follows the content: for wrapping a slot in markup only when it's used.
773
+ *
774
+ * const hasFooter = hasSlot('footer')
775
+ * <zb-if test={hasFooter}><footer><zb-slot name="footer"></zb-slot></footer></zb-if>
776
+ */
777
+ export function hasSlot(name = 'default') {
778
+ return filledSignal(hooks('hasSlot').filled, name);
779
+ }
780
+
781
+ /**
782
+ * zb-slot in light DOM: the host's own children for this slot, moved in once at startup, or the
783
+ * fallback block. The provided nodes are kept, so a slot inside an zb-if branch gets them back
784
+ * when the branch comes back.
785
+ */
786
+ function bindSlot(part, open, hydrating, context) {
787
+ const close = matchingClose(open);
788
+ const slot = slotRecord(context, part.slot);
789
+ if (hydrating && marker(open) === '[!' && !slot.nodes.length) {
790
+ // The server already put supplied content here.
791
+ for (let node = open.nextSibling; node !== close; node = node.nextSibling) slot.nodes.push(node);
792
+ }
793
+ const holder = scope(); // owns the fallback while it shows
794
+ let fallback = null;
795
+ let first = hydrating;
796
+ const region = {
797
+ open,
798
+ close,
799
+ provided: false,
800
+ update() {
801
+ const hydrate = first;
802
+ first = false;
803
+ region.provided = slot.nodes.some(isContent);
804
+ if (region.provided) {
805
+ if (fallback) { fallback.dispose(); fallback = null; clearBetween(open, close); }
806
+ setMarker(open, '[!');
807
+ place(slot.nodes, open, context.slotted);
808
+ return;
809
+ }
810
+ // Only markers and whitespace (a framework's bookkeeping): they wait in the park, where
811
+ // the framework can still add content next to them, while the fallback shows.
812
+ if (slot.nodes.length) slot.park.append(...slot.nodes);
813
+ if (part.fallback) {
814
+ if (fallback) return;
815
+ if (!hydrate || marker(open) !== '[') clearBetween(open, close);
816
+ setMarker(open, '[');
817
+ fallback = holder.run(() => scope(() => mountBlock(part.fallback, [], open, close, hydrate, context)));
818
+ } else {
819
+ clearBetween(open, close);
820
+ setMarker(open, '[');
821
+ }
822
+ },
823
+ };
824
+ // The region exists while this effect does (an zb-if branch switching away disposes it):
825
+ // then the slotted nodes are parked, to come back when the branch does.
826
+ effect(() => {
827
+ slot.region = region;
828
+ return () => {
829
+ if (slot.region !== region) return;
830
+ slot.region = null;
831
+ slot.park.append(...slot.nodes);
832
+ holder.dispose();
833
+ };
834
+ });
835
+ region.update();
836
+ }
837
+
838
+ // ---- slotted content (light DOM) -------------------------------------------------------------
839
+ //
840
+ // In light DOM the host's children are the template, so content given to the element lives in
841
+ // its slot regions instead. Frameworks keep managing that content through the host — React
842
+ // calls host.insertBefore(node, sibling) and host.removeChild(node), Vue sets textContent — so
843
+ // the host's child methods are routed to the slots, and a MutationObserver notices content
844
+ // removed through its real parent. Each slot keeps its nodes in order; while its region isn't
845
+ // rendered (inside an zb-if branch that's off) they're parked in a fragment.
846
+
847
+ function slotRecord(context, name) {
848
+ let slot = context.slots.get(name);
849
+ if (!slot) {
850
+ slot = { name, nodes: [], region: null, park: document.createDocumentFragment(), filled: filledSignal(context.filled, name) };
851
+ context.slots.set(name, slot);
852
+ context.observer?.observe(slot.park, { childList: true });
853
+ }
854
+ return slot;
855
+ }
856
+
857
+ /**
858
+ * Put `nodes` right after `open`, in order, moving only what's out of place. (Anything that left
859
+ * the slot was removed from the region as it left, so nothing else is there.)
860
+ */
861
+ function place(nodes, open, tag) {
862
+ const parent = open.parentNode;
863
+ let cursor = open.nextSibling;
864
+ for (const node of nodes) {
865
+ mark(node, tag);
866
+ if (node === cursor) cursor = cursor.nextSibling;
867
+ else move(parent, node, cursor);
868
+ }
869
+ }
870
+
871
+ // The native methods: a light-DOM host routes its own insertBefore to its slots (for content a
872
+ // framework adds), and a row of its own list isn't such content.
873
+ const nativeMoveBefore = typeof Element !== 'undefined' ? Element.prototype.moveBefore : undefined;
874
+ function move(parent, node, before) {
875
+ try {
876
+ if (nativeMoveBefore && node.isConnected && parent.isConnected) return nativeMoveBefore.call(parent, node, before);
877
+ } catch { /* not movable atomically (different document, …): insert instead */ }
878
+ Node.prototype.insertBefore.call(parent, node, before);
879
+ }
880
+
881
+ const slotName = (node) => (node.nodeType === 1 && node.getAttribute('slot')) || 'default';
882
+ /** An element or non-blank text: what fills a slot (comments and whitespace don't). */
883
+ const isContent = (node) => node.nodeType === 1 || (node.nodeType === TEXT && node.data.trim() !== '');
884
+
885
+ /**
886
+ * The slot for each of `nodes` (an element's children, in order). Comments and whitespace are
887
+ * frameworks' markers (Lit's parts, Svelte's anchors): a comment goes with the content after it
888
+ * and whitespace with the content before it, so a framework's range stays together in one slot.
889
+ */
890
+ function assignSlots(nodes) {
891
+ const named = nodes.map((node) => (isContent(node) ? slotName(node) : null));
892
+ const after = (i) => { for (let j = i + 1; j < nodes.length; j++) if (named[j]) return named[j]; return null; };
893
+ const before = (i) => { for (let j = i - 1; j >= 0; j--) if (named[j]) return named[j]; return null; };
894
+ return nodes.map((node, i) => named[i]
895
+ ?? (node.nodeType === COMMENT ? after(i) ?? before(i) : before(i) ?? after(i))
896
+ ?? 'default');
897
+ }
898
+ let nativeText, nativeHTML; // looked up on first use, so the module loads without a DOM
899
+
900
+ const ROUTED = ['insertBefore', 'appendChild', 'append', 'removeChild', 'replaceChild', 'replaceChildren', 'textContent', 'innerHTML'];
901
+
902
+ /** Route the host's child methods to its slots (see above). */
903
+ function routeChildren(host, context, slots, tag) {
904
+ const holding = (node) => {
905
+ for (const slot of context.slots.values()) if (slot.nodes.includes(node)) return slot;
906
+ return null;
907
+ };
908
+ // Drop content that left through its real parent (or was moved elsewhere), except `keep`,
909
+ // which is arriving now.
910
+ const prune = (slot, keep) => {
911
+ const home = slot.region?.provided ? slot.region.open.parentNode : slot.park;
912
+ const kept = slot.nodes.filter((node) => node === keep || node.parentNode === home);
913
+ for (const node of slot.nodes) if (!kept.includes(node)) unmark(node, context.slotted);
914
+ const changed = kept.length !== slot.nodes.length;
915
+ slot.nodes = kept;
916
+ return changed;
917
+ };
918
+ const show = (slot, keep) => {
919
+ prune(slot, keep);
920
+ slot.filled.set(slot.nodes.some(isContent));
921
+ if (slot.region) slot.region.update();
922
+ else slot.park.append(...slot.nodes);
923
+ };
924
+ const take = (node) => {
925
+ const slot = holding(node);
926
+ if (slot) slot.nodes.splice(slot.nodes.indexOf(node), 1);
927
+ return slot;
928
+ };
929
+ const insert = (node, before) => {
930
+ if (node.nodeType === 11) {
931
+ for (const child of [...node.childNodes]) insert(child, before);
932
+ return;
933
+ }
934
+ const from = take(node);
935
+ // A marker or whitespace goes in the slot of what it's inserted before.
936
+ const name = isContent(node) ? slotName(node) : (before && holding(before)?.name) || 'default';
937
+ if (!slots.includes(name) && isContent(node)) console.warn(`zubra: <${tag}> has no slot named "${name}"; that content is not shown${see('slots')}`);
938
+ const slot = slotRecord(context, name);
939
+ const at = before ? slot.nodes.indexOf(before) : -1;
940
+ if (at >= 0) slot.nodes.splice(at, 0, node);
941
+ else slot.nodes.push(node);
942
+ if (from && from !== slot) show(from);
943
+ show(slot, node);
944
+ };
945
+ const remove = (node) => {
946
+ const slot = take(node);
947
+ if (!slot) return false;
948
+ unmark(node, context.slotted);
949
+ node.remove();
950
+ show(slot);
951
+ return true;
952
+ };
953
+ const clear = () => {
954
+ for (const slot of context.slots.values()) {
955
+ for (const node of slot.nodes.splice(0)) {
956
+ unmark(node, context.slotted);
957
+ node.remove();
958
+ }
959
+ show(slot);
960
+ }
961
+ };
962
+ const asNodes = (items) => items.map((item) => (item instanceof Node ? item : document.createTextNode(String(item))));
963
+ Object.defineProperties(host, {
964
+ insertBefore: { value(node, before) { insert(node, before ?? null); return node; }, configurable: true },
965
+ appendChild: { value(node) { insert(node, null); return node; }, configurable: true },
966
+ append: { value(...items) { for (const node of asNodes(items)) insert(node, null); }, configurable: true },
967
+ removeChild: {
968
+ value(node) {
969
+ if (!remove(node)) Node.prototype.removeChild.call(host, node); // throws as natively
970
+ return node;
971
+ },
972
+ configurable: true,
973
+ },
974
+ replaceChild: {
975
+ value(node, old) {
976
+ if (!holding(old)) return Node.prototype.replaceChild.call(host, node, old);
977
+ insert(node, old);
978
+ remove(old);
979
+ return old;
980
+ },
981
+ configurable: true,
982
+ },
983
+ replaceChildren: { value(...items) { clear(); for (const node of asNodes(items)) insert(node, null); }, configurable: true },
984
+ textContent: {
985
+ get() { return (nativeText ??= Object.getOwnPropertyDescriptor(Node.prototype, 'textContent')).get.call(host); },
986
+ set(value) { clear(); if (value != null && value !== '') insert(document.createTextNode(String(value)), null); },
987
+ configurable: true,
988
+ },
989
+ innerHTML: {
990
+ get() { return (nativeHTML ??= Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML')).get.call(host); },
991
+ set(value) {
992
+ clear();
993
+ const template = document.createElement('template');
994
+ template.innerHTML = value;
995
+ insert(document.importNode(template.content, true), null);
996
+ },
997
+ configurable: true,
998
+ },
999
+ });
1000
+
1001
+ // Content removed or moved away through its real parent (Vue removes via child.parentNode),
1002
+ // and content whose `slot` attribute changed (it moves to that slot, as with native slots).
1003
+ context.observer = new MutationObserver((records) => {
1004
+ for (const record of records) {
1005
+ const slot = record.type === 'attributes' && holding(record.target);
1006
+ if (slot && slotName(record.target) !== slot.name) insert(record.target, null);
1007
+ }
1008
+ // Each slot's nodes are what's actually in it: frameworks also insert and remove content
1009
+ // next to their own markers there, without going through the host.
1010
+ for (const slot of context.slots.values()) {
1011
+ let actual;
1012
+ if (slot.region?.provided) {
1013
+ actual = [];
1014
+ for (let node = slot.region.open.nextSibling; node && node !== slot.region.close; node = node.nextSibling) actual.push(node);
1015
+ } else {
1016
+ actual = [...slot.park.childNodes];
1017
+ }
1018
+ if (actual.length === slot.nodes.length && actual.every((node, i) => node === slot.nodes[i])) continue;
1019
+ for (const node of slot.nodes) if (!actual.includes(node)) unmark(node, context.slotted);
1020
+ slot.nodes = actual;
1021
+ // An element that belongs to another slot (added next to a marker here) goes there.
1022
+ for (const node of actual) if (node.nodeType === 1 && slotName(node) !== slot.name) insert(node, null);
1023
+ show(slot);
1024
+ }
1025
+ // Content a parent adds inside a slot itself (its zb-if or zb-each there) is slotted too.
1026
+ if (context.slotted) {
1027
+ for (const slot of context.slots.values()) {
1028
+ if (!slot.region) continue;
1029
+ for (let node = slot.region.open.nextSibling; node && node !== slot.region.close; node = node.nextSibling) {
1030
+ mark(node, context.slotted);
1031
+ }
1032
+ }
1033
+ }
1034
+ });
1035
+ context.observer.observe(host, { childList: true, subtree: true, attributes: true, attributeFilter: ['slot'] });
1036
+ for (const slot of context.slots.values()) context.observer.observe(slot.park, { childList: true });
1037
+ }
1038
+
1039
+ // Form controls' live state: bound as properties, so `value={draft}` keeps up with typing and
1040
+ // `checked={done}` with clicks.
1041
+ const LIVE = new Set(['input.value', 'textarea.value', 'select.value', 'input.checked', 'option.selected']);
1042
+
1043
+ function bindAttribute(part, node) {
1044
+ const name = part.attribute;
1045
+ // For live state: the last value written, so the property is written only when the bound
1046
+ // value changes (what the user typed stays until then).
1047
+ let written;
1048
+ effect(() => {
1049
+ const value = $r(part.value());
1050
+ // Another library's custom element: a property when it has one (React 19's rule).
1051
+ if (part.prop && part.prop in node) {
1052
+ node[part.prop] = value;
1053
+ return;
1054
+ }
1055
+ if (part.boolean) {
1056
+ if (typeof value !== 'boolean') {
1057
+ console.error(`zubra: "${name}" is a boolean attribute; got`, value);
1058
+ return;
1059
+ }
1060
+ // A control's live state is its property (the attribute is only the default once the
1061
+ // user has touched it).
1062
+ if (LIVE.has(`${node.localName}.${name}`)) {
1063
+ if (value !== written) node[name] = written = value;
1064
+ return;
1065
+ }
1066
+ node.toggleAttribute(name, value);
1067
+ return;
1068
+ }
1069
+ if (LIVE.has(`${node.localName}.${name}`)) {
1070
+ const text = value == null ? '' : String(value);
1071
+ if (text !== written) {
1072
+ // Unchanged text isn't written: that would move the caret and lose a selection.
1073
+ if (node[name] !== text) node[name] = text;
1074
+ written = text;
1075
+ // A <select>'s options may come from a list inside it that renders after this part:
1076
+ // until the option exists the value doesn't take, so set it again once they're there.
1077
+ if (node.localName === 'select' && node.value !== text) {
1078
+ queueMicrotask(() => {
1079
+ if (written === text && node.value !== text) node.value = text;
1080
+ });
1081
+ }
1082
+ }
1083
+ return;
1084
+ }
1085
+ if (value == null) return node.removeAttribute(name);
1086
+ if (typeof value === 'object' && !part.prop) {
1087
+ console.error(`zubra: cannot write an object to the "${name}" attribute`, value);
1088
+ return node.removeAttribute(name);
1089
+ }
1090
+ const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
1091
+ // No URL yet (a prop's "" default, before the app sets it): no attribute, and nothing to report.
1092
+ if (part.url && text.trim() === '') return node.removeAttribute(name);
1093
+ if (part.url && !isSafeUrl(text)) {
1094
+ console.error(`zubra: blocked an unsafe URL in "${name}"${see('safety')}:`, text);
1095
+ return node.removeAttribute(name);
1096
+ }
1097
+ if (node.getAttribute(name) !== text) node.setAttribute(name, text);
1098
+ });
1099
+ }
1100
+
1101
+ /** zb-if: re-render only when the chosen branch changes, not whenever the test re-runs. */
1102
+ function bindWhen(part, open, hydrating, context) {
1103
+ hydrating = hydratable(open, hydrating);
1104
+ const close = matchingClose(open);
1105
+ const branch = new Signal.Computed(() => ($r(part.when()) ? 0 : 1));
1106
+ let first = hydrating;
1107
+ effect(() => {
1108
+ const block = part.blocks[branch.get()];
1109
+ const hydrate = first;
1110
+ first = false;
1111
+ // Effects created while mounting belong to this effect, so switching branches disposes them.
1112
+ untrack(() => {
1113
+ if (block) mountBlock(block, [], open, close, hydrate, context);
1114
+ else clearBetween(open, close);
1115
+ });
1116
+ });
1117
+ }
1118
+
1119
+ /**
1120
+ * zb-await: the pending block while the value is a promise (anything with `then`), then the
1121
+ * `zb-then` block with the resolved value, or the `zb-catch` block with the error. A value
1122
+ * that isn't a promise shows `zb-then` at once. Only the latest value's outcome is shown. A
1123
+ * `resource()` is followed through its states, so reloading it shows the pending content again.
1124
+ *
1125
+ * - An array value waits for every promise and resource in it, as `Promise.all` does, and
1126
+ * resolves to the array of their values (4.2).
1127
+ * - `zb-catch` gets `retry()`: it refreshes the resources and evaluates the value again, so a
1128
+ * `load()` call is made again (4.2).
1129
+ * - `delay`: the pending block shows only once the value has been pending that long; until
1130
+ * then the previous content stays (4.2).
1131
+ * - `keep`: once settled, a reload keeps the settled content, and the host has
1132
+ * `:state(loading)` while it runs (4.2).
1133
+ */
1134
+ const loadingCounts = new WeakMap();
1135
+ function markLoading(states, step) {
1136
+ if (!states) return;
1137
+ const count = (loadingCounts.get(states) ?? 0) + step;
1138
+ loadingCounts.set(states, count);
1139
+ states[count > 0 ? 'add' : 'delete']('loading');
1140
+ }
1141
+ function bindAwait(part, open, hydrating, context) {
1142
+ hydrating = hydratable(open, hydrating);
1143
+ const close = matchingClose(open);
1144
+ let first = hydrating;
1145
+ let latest = null;
1146
+ let settled = false; // a zb-then or zb-catch block is showing
1147
+ const attempt = new Signal.State(0);
1148
+ // The value, evaluated again only when what it reads changes or retry() asks: a resource
1149
+ // settling re-runs the effect below, and mustn't call `load()` again.
1150
+ const current = new Signal.Computed(() => (attempt.get(), $r(part.await())));
1151
+ const retry = () => {
1152
+ for (const value of [].concat(untrack(() => current.get()))) $r(value)?.[RESOURCE] && $r(value).refresh();
1153
+ attempt.set(untrack(() => attempt.get()) + 1);
1154
+ };
1155
+ effect(() => {
1156
+ const value = current.get();
1157
+ const many = Array.isArray(value);
1158
+ const token = (latest = {});
1159
+ // Blocks mounted later (when the promise settles) still belong to this run.
1160
+ const owner = scope();
1161
+ let timer;
1162
+ let loading = 0;
1163
+ const show = (index, payload) => untrack(() => owner.run(() => {
1164
+ clearTimeout(timer);
1165
+ const block = part.blocks[index];
1166
+ const hydrate = first; // the server rendered this branch, unless it left the region to us
1167
+ first = false;
1168
+ settled = index > 0;
1169
+ if (block) mountBlock(block, index ? [new Signal.State(payload), new Signal.State(retry)] : [], open, close, hydrate, context);
1170
+ else clearBetween(open, close);
1171
+ }));
1172
+ const done = (index, payload) => {
1173
+ if (latest !== token || owner.disposed) return;
1174
+ if (loading) markLoading(context?.states, (loading = 0) - 1);
1175
+ show(index, payload);
1176
+ };
1177
+ // Each value: a resource's state, a promise, or a plain value.
1178
+ let pending = false;
1179
+ let failed;
1180
+ const promises = (many ? value : [value]).map((item) => {
1181
+ item = $r(item);
1182
+ if (item?.[RESOURCE]) {
1183
+ const status = item.status;
1184
+ if (status === 'error') failed ??= [item.error];
1185
+ pending ||= status === 'pending';
1186
+ return item.value;
1187
+ }
1188
+ if (item !== null && (typeof item === 'object' || typeof item === 'function') && typeof item.then === 'function') {
1189
+ pending = true;
1190
+ return item;
1191
+ }
1192
+ return item;
1193
+ });
1194
+ if (failed) done(2, failed[0]);
1195
+ else if (pending) {
1196
+ const delay = part.delay ? Number(untrack(part.delay)) : 0;
1197
+ if (part.keep && settled) markLoading(context?.states, (loading = 1));
1198
+ else if (delay > 0 && !first) timer = setTimeout(() => latest === token && !owner.disposed && show(0), delay);
1199
+ else show(0);
1200
+ // A pending resource re-runs this effect when it settles; promises are waited for here.
1201
+ if (!(many ? value : [value]).some((item) => $r(item)?.[RESOURCE] && $r(item).status === 'pending')) {
1202
+ Promise.all(promises).then((resolved) => done(1, many ? resolved : resolved[0]), (error) => done(2, error));
1203
+ }
1204
+ } else done(1, many ? promises : promises[0]);
1205
+ return () => {
1206
+ clearTimeout(timer);
1207
+ if (loading) markLoading(context?.states, -1);
1208
+ };
1209
+ });
1210
+ }
1211
+
1212
+ /**
1213
+ * zb-each: one row per item. Each row holds its item and index as signals, so changing one
1214
+ * item updates only that row's parts.
1215
+ *
1216
+ * - Without a key, rows are matched by position: a new array updates rows in place, and rows are
1217
+ * only created or removed at the end.
1218
+ * - With `key={…}`, rows follow their key: reordering moves the existing rows (with whatever DOM
1219
+ * state they have, like a half-typed input), using `moveBefore()` where the browser has it so
1220
+ * focus and iframes survive the move.
1221
+ */
1222
+ function bindEach(part, open, hydrating, context) {
1223
+ hydrating = hydratable(open, hydrating);
1224
+ const close = matchingClose(open);
1225
+ // Rows belong to this scope, not to the effect below, so re-running the effect (the list
1226
+ // changed) doesn't dispose them.
1227
+ const rowsOwner = scope();
1228
+ let rows = [];
1229
+
1230
+ const createRow = (value, index, key, rowOpen, rowClose, hydrateRow) => {
1231
+ if (!rowOpen) {
1232
+ // A new row is assembled in a fragment, then placed.
1233
+ rowOpen = document.createComment('[');
1234
+ rowClose = document.createComment(']');
1235
+ const fragment = new DocumentFragment();
1236
+ fragmentParents.set(fragment, parentOf(open));
1237
+ fragment.append(rowOpen, rowClose);
1238
+ }
1239
+ const item = new Signal.State(value);
1240
+ const position = new Signal.State(index);
1241
+ const owner = rowsOwner.run(() => scope(() => mountBlock(part.block, [item, position], rowOpen, rowClose, hydrateRow, context)));
1242
+ return { key, item, position, open: rowOpen, close: rowClose, owner };
1243
+ };
1244
+ const nodesOf = (row) => {
1245
+ const nodes = [];
1246
+ for (let node = row.open; ; node = node.nextSibling) {
1247
+ nodes.push(node);
1248
+ if (node === row.close) return nodes;
1249
+ }
1250
+ };
1251
+ const removeRow = (row) => {
1252
+ row.owner.dispose();
1253
+ for (const node of nodesOf(row)) node.remove();
1254
+ };
1255
+ const placeRow = (row, before) => {
1256
+ const parent = close.parentNode;
1257
+ for (const node of nodesOf(row)) move(parent, node, before);
1258
+ };
1259
+ const keysOf = (list) => {
1260
+ const seen = new Set();
1261
+ return list.map((value, index) => {
1262
+ let key = part.key(value, index);
1263
+ if (seen.has(key)) {
1264
+ console.error('zubra: duplicate zb-each key', key, `(row ${index + 1}); that row is not keyed${see('directives')}`);
1265
+ key = Symbol('duplicate');
1266
+ }
1267
+ seen.add(key);
1268
+ return key;
1269
+ });
1270
+ };
1271
+
1272
+ let first = hydrating;
1273
+ effect(() => {
1274
+ let list = $r(part.each());
1275
+ if (!Array.isArray(list)) {
1276
+ if (list != null) console.error('zubra: zb-each items must be an array; got', list);
1277
+ list = [];
1278
+ }
1279
+ // Reactive state (deep.js) tracks by property: reading the length and each index here means
1280
+ // `items.push(x)`, `items.splice(…)` and `items[i] = x` re-run this effect.
1281
+ for (let index = 0, { length } = list; index < length; index++) list[index];
1282
+ const hydrate = first;
1283
+ first = false;
1284
+ untrack(() => {
1285
+ const keys = part.key ? keysOf(list) : null;
1286
+ if (hydrate) {
1287
+ const existing = [...positions(open.nextSibling, close)];
1288
+ if (existing.length === list.length && existing.every(isOpen)) {
1289
+ rows = existing.map((rowOpen, index) =>
1290
+ createRow(list[index], index, keys?.[index], rowOpen, matchingClose(rowOpen), true));
1291
+ return;
1292
+ }
1293
+ console.warn(`zubra: server markup for a list does not match its items; rendering it fresh${see('server-rendering-and-hydration')}`);
1294
+ clearBetween(open, close);
1295
+ }
1296
+
1297
+ if (!keys) {
1298
+ const shared = Math.min(rows.length, list.length);
1299
+ for (let index = 0; index < shared; index++) {
1300
+ rows[index].item.set(list[index]);
1301
+ rows[index].position.set(index);
1302
+ }
1303
+ if (rows.length < list.length) {
1304
+ const fragment = new DocumentFragment();
1305
+ for (let index = rows.length; index < list.length; index++) {
1306
+ const row = createRow(list[index], index);
1307
+ fragment.append(...nodesOf(row));
1308
+ rows.push(row);
1309
+ }
1310
+ close.before(fragment);
1311
+ }
1312
+ if (!list.length && rows.length) {
1313
+ batch(() => { for (const row of rows) row.owner.dispose(); });
1314
+ deleteBetween(open, close);
1315
+ rows = [];
1316
+ }
1317
+ batch(() => { for (const row of rows.splice(list.length)) removeRow(row); });
1318
+ return;
1319
+ }
1320
+
1321
+ // Keyed: reuse the row for each key, create the missing ones, drop the rest, then walk the
1322
+ // new order and move only rows that aren't already where they belong.
1323
+ const byKey = new Map(rows.map((row) => [row.key, row]));
1324
+ // Nothing reused (cleared, or replaced wholesale): drop every row at once, then build the
1325
+ // new ones in one fragment and insert it once.
1326
+ if (rows.length && !keys.some((key) => byKey.has(key))) {
1327
+ batch(() => { for (const row of rows) row.owner.dispose(); });
1328
+ deleteBetween(open, close);
1329
+ rows = [];
1330
+ byKey.clear();
1331
+ }
1332
+ if (!rows.length) {
1333
+ const fragment = new DocumentFragment();
1334
+ rows = list.map((value, index) => {
1335
+ const row = createRow(value, index, keys[index]);
1336
+ fragment.append(...nodesOf(row));
1337
+ return row;
1338
+ });
1339
+ close.before(fragment);
1340
+ return;
1341
+ }
1342
+ const next = list.map((value, index) => {
1343
+ const row = byKey.get(keys[index]);
1344
+ if (!row) return createRow(value, index, keys[index]);
1345
+ byKey.delete(keys[index]);
1346
+ row.item.set(value);
1347
+ row.position.set(index);
1348
+ return row;
1349
+ });
1350
+ batch(() => { for (const row of byKey.values()) removeRow(row); });
1351
+ let at = open.nextSibling;
1352
+ while (at && at.nodeType === TEXT) at = at.nextSibling;
1353
+ for (const row of next) {
1354
+ if (row.open === at) {
1355
+ at = row.close.nextSibling;
1356
+ continue;
1357
+ }
1358
+ placeRow(row, at ?? close);
1359
+ }
1360
+ rows = next;
1361
+ });
1362
+ });
1363
+ }
1364
+
1365
+ const hasContent = (root) =>
1366
+ [...root.childNodes].some((node) => node.nodeType !== TEXT || node.data.trim() !== '');
1367
+
1368
+ // ---- define --------------------------------------------------------------------------------
1369
+
1370
+ const fired = new WeakMap(); // event → the component that dispatched it
1371
+
1372
+ // ---- styles --------------------------------------------------------------------------------
1373
+ //
1374
+ // A component's stylesheet is one constructed CSSStyleSheet shared by every instance: adopted
1375
+ // by each shadow root of a shadow component, and, for a light-DOM component, by the document (or
1376
+ // the shadow root it lives in). `adoptStyles()` adds app-wide sheets — Tailwind's output, a
1377
+ // design system — to every zubra shadow root, since page CSS doesn't reach inside them.
1378
+
1379
+ const globalSheets = [];
1380
+ const shadowRoots = new Set(); // WeakRef<ShadowRoot>
1381
+ const adopted = new WeakMap(); // Document | ShadowRoot → Set<CSSStyleSheet>
1382
+
1383
+ function toSheet(css) {
1384
+ if (css instanceof CSSStyleSheet) return css;
1385
+ const sheet = new CSSStyleSheet();
1386
+ sheet.replaceSync(String(css));
1387
+ return sheet;
1388
+ }
1389
+
1390
+ function adopt(root, sheets) {
1391
+ let has = adopted.get(root);
1392
+ if (!has) adopted.set(root, (has = new Set()));
1393
+ const added = sheets.filter((sheet) => !has.has(sheet));
1394
+ for (const sheet of added) has.add(sheet);
1395
+ if (added.length) root.adoptedStyleSheets = [...root.adoptedStyleSheets, ...added];
1396
+ }
1397
+
1398
+ /**
1399
+ * Add stylesheets (CSS text or `CSSStyleSheet`s) to every zubra shadow root, now and later — for
1400
+ * app-wide CSS such as Tailwind's output, which page styles can't carry into shadow DOM.
1401
+ * Light-DOM components already get page CSS. Returns the sheets.
1402
+ */
1403
+ export function adoptStyles(...styles) {
1404
+ const sheets = styles.map(toSheet);
1405
+ globalSheets.push(...sheets);
1406
+ for (const ref of [...shadowRoots]) {
1407
+ const root = ref.deref();
1408
+ if (root) adopt(root, sheets);
1409
+ else shadowRoots.delete(ref);
1410
+ }
1411
+ return sheets;
1412
+ }
1413
+
1414
+ /** The attribute that marks content slotted into a styled light-DOM component (value: its tag). */
1415
+ const SLOTTED = 'data-zb-slotted';
1416
+ const unmark = (node, tag) => {
1417
+ if (tag && node.nodeType === 1 && node.getAttribute(SLOTTED) === tag) node.removeAttribute(SLOTTED);
1418
+ };
1419
+ const mark = (node, tag) => {
1420
+ if (tag && node.nodeType === 1 && node.getAttribute(SLOTTED) !== tag) node.setAttribute(SLOTTED, tag);
1421
+ };
1422
+
1423
+ /** What adapters and tools need: the tag, each prop's attribute and default, the events. */
1424
+ function describe(tag, { props, shadow, form, slots, events, styled }) {
1425
+ return Object.freeze({
1426
+ tag,
1427
+ props: Object.freeze(Object.fromEntries(Object.entries(props).map(([name, spec]) =>
1428
+ [name, Object.freeze({ attribute: spec.attribute, default: spec.default, required: !!spec.required, model: !!spec.model })]))),
1429
+ events: Object.freeze([...events]),
1430
+ slots: Object.freeze([...slots]),
1431
+ shadow,
1432
+ form,
1433
+ styled,
1434
+ });
1435
+ }
1436
+
1437
+ const serverElements = new Map();
1438
+ function serverElement(tag, options) {
1439
+ if (!serverElements.has(tag)) {
1440
+ serverElements.set(tag, class ZubraServerElement {
1441
+ static zubra = describe(tag, options);
1442
+ constructor() { throw new TypeError(`zubra: <${tag}> can't be created without a DOM`); }
1443
+ });
1444
+ }
1445
+ return serverElements.get(tag);
1446
+ }
1447
+
1448
+ /**
1449
+ * Register a compiled component. `props` maps prop names to `{ attribute, default, required }`;
1450
+ * `template` is the element's inner HTML with dynamic parts left empty; `setup($p)` runs the
1451
+ * component's script once per element and returns its parts.
1452
+ */
1453
+ /**
1454
+ * The version of the compiled-code interface: what `$define()`'s arguments and parts mean, and the
1455
+ * server markup they hydrate. 3 (5.0) is 2 with processing-instruction markers; this runtime reads
1456
+ * 2's comment markers too, so modules compiled by 3.x and 4.x still run.
1457
+ */
1458
+ export const ABI = 3;
1459
+ const READS = [2, 3];
1460
+
1461
+ /**
1462
+ * What modules compiled for runtime 1.x call. Their compiled shape differs, so they're refused
1463
+ * with a clear message instead of misbehaving.
1464
+ */
1465
+ export function define(tag) {
1466
+ throw new Error(`zubra: <${tag}> was compiled for @zubra/runtime 1.x; recompile it with zubra 2 (the CLI, @zubra/compiler or @zubra/unplugin)`);
1467
+ }
1468
+
1469
+ /** @internal Register a compiled component: what the compiler's JavaScript output calls. */
1470
+ export function $define(tag, definition) {
1471
+ if (!READS.includes(definition?.abi)) {
1472
+ throw new Error(`zubra: <${tag}> was compiled for a different @zubra/runtime (interface ${definition?.abi ?? 1}, this runtime is ${ABI}); recompile it with the matching zubra`);
1473
+ }
1474
+ return defineElement(tag, definition);
1475
+ }
1476
+
1477
+ // @hot (left out of the production build: scripts/build-production.js)
1478
+ /** What a hot reload can't change without a full page reload: the element's interface. */
1479
+ const shape = ({ props = {}, shadow = false, form = false, events = [], styled = false, inPage = false }) => JSON.stringify([props, shadow, form, events, styled, inPage]);
1480
+ const HOT = Symbol('zubra.hot');
1481
+ // @hot-end
1482
+
1483
+ function defineElement(tag, definition) {
1484
+ const { props = {}, shadow = false, form = false, events = [], styled = false, inPage = false } = definition;
1485
+ let { template = '', slots = [], styles = null, setup } = definition;
1486
+ // On a server (no DOM), a stand-in with the same `zubra` description, so a module that imports a
1487
+ // component loads there: adapters render the host tag, and `@zubra/runtime/server` renders it.
1488
+ if (typeof HTMLElement === 'undefined') return serverElement(tag, { props, shadow, form, slots, events, styled });
1489
+ let sheet; // created on first use, so define() needs no document
1490
+ const ownSheets = () => (styles == null ? [] : [(sheet ??= toSheet(styles))]);
1491
+ const existing = customElements.get(tag);
1492
+ if (existing) {
1493
+ // @hot (left out of the production build: scripts/build-production.js)
1494
+ // The same tag again: in development (with hot module replacement), a new version of the
1495
+ // component, swapped into the live elements; otherwise the first definition stands.
1496
+ if (globalThis.__ZUBRA_HOT__ && existing[HOT]) existing.hotReloaded = existing[HOT](definition);
1497
+ // @hot-end
1498
+ return existing;
1499
+ }
1500
+ const componentTemplate = { content: blueprint({ template }) };
1501
+ let templateSignature = signature(childRange(componentTemplate.content));
1502
+ let instances = 0;
1503
+ // @hot (left out of the production build: scripts/build-production.js)
1504
+ const live = new Set(); // WeakRefs to the elements, kept only for hot reloads
1505
+ // @hot-end
1506
+ const byAttribute = Object.fromEntries(Object.entries(props).map(([name, spec]) => [spec.attribute, name]));
1507
+
1508
+ let internals; // (element) => its ElementInternals; set in the static block below
1509
+ class ZubraElement extends HTMLElement {
1510
+ static observedAttributes = Object.keys(byAttribute);
1511
+ static zubra = describe(tag, { props, shadow, form, slots, events, styled });
1512
+ static formAssociated = form;
1513
+ static { internals = (element) => element.#internals; }
1514
+ // @hot
1515
+ /** Swap in a new version of the component (see above); false if its interface changed. */
1516
+ static [HOT](next) {
1517
+ if (shape(next) !== shape(definition)) return false;
1518
+ ({ template = '', slots = [], setup } = next);
1519
+ componentTemplate.content = blueprint({ template });
1520
+ templateSignature = signature(childRange(componentTemplate.content));
1521
+ const nextStyles = next.styles ?? null;
1522
+ if (nextStyles !== styles) {
1523
+ styles = nextStyles;
1524
+ sheet?.replaceSync(styles ?? '');
1525
+ }
1526
+ for (const ref of [...live]) {
1527
+ const element = ref.deref();
1528
+ if (element) element.#reload();
1529
+ else live.delete(ref);
1530
+ }
1531
+ return true;
1532
+ }
1533
+ // @hot-end
1534
+ #context = null;
1535
+ #signals = {};
1536
+ #given = new Set(); // props set from outside (property, attribute, or before upgrade)
1537
+ #scope = null;
1538
+ #hooks = null;
1539
+ #cleanups = [];
1540
+ #internals = null; // form-associated only
1541
+ #states = null; // the custom states (CustomStateSet), for :state(hydrated)
1542
+ #disabled = false;
1543
+
1544
+ static {
1545
+ for (const name of Object.keys(props)) {
1546
+ Object.defineProperty(this.prototype, name, {
1547
+ get() { return this.#signals[name].get(); },
1548
+ set(value) { this.#given.add(name); this.#signals[name].set(value); },
1549
+ configurable: true,
1550
+ enumerable: true,
1551
+ });
1552
+ }
1553
+ }
1554
+
1555
+ constructor() {
1556
+ super();
1557
+ const internals = this.attachInternals();
1558
+ if (form) this.#internals = internals;
1559
+ this.#states = internals.states ?? null;
1560
+ // An event the component fires is its own: an inner element's event of the same name
1561
+ // (a native `change` bubbling out of light DOM, a nested component's `valuechange`) stops
1562
+ // here, so listeners outside see only the component's, as with a built-in element.
1563
+ for (const name of events) {
1564
+ this.addEventListener(name, (event) => {
1565
+ if (fired.get(event) !== this) event.stopImmediatePropagation();
1566
+ });
1567
+ }
1568
+ for (const [name, spec] of Object.entries(props)) {
1569
+ this.#signals[name] = new Signal.State(clone(spec.default));
1570
+ // A framework may have set the property before the element was defined; that landed
1571
+ // as an own property shadowing the accessor. Take it over.
1572
+ if (Object.hasOwn(this, name)) {
1573
+ const value = this[name];
1574
+ delete this[name];
1575
+ this.#given.add(name);
1576
+ this.#signals[name].set(value);
1577
+ }
1578
+ }
1579
+ }
1580
+
1581
+ attributeChangedCallback(attribute, _old, value) {
1582
+ const name = byAttribute[attribute];
1583
+ this[name] = parseAttribute(props[name], value);
1584
+ }
1585
+
1586
+ connectedCallback() {
1587
+ // A light-DOM component's styles go where it is: the document, or a shadow root.
1588
+ if (!shadow && styles != null) {
1589
+ // A light-DOM component's sheet goes to the root it's in, unless the bundler already put
1590
+ // it in the page (`inPage`) and that root is the document.
1591
+ // (A callback can run after the element was moved out again: then the root is a detached
1592
+ // tree, which has no stylesheets of its own.)
1593
+ const root = this.getRootNode();
1594
+ if (root.adoptedStyleSheets && !(inPage && root === document)) adopt(root, ownSheets());
1595
+ }
1596
+ if (this.#scope) this.#scope.resume();
1597
+ else if (!remarker && lostComments(shadow ? this.shadowRoot : this, componentTemplate.content)) {
1598
+ // Server markup without its comments: start once the code that puts them back is here.
1599
+ loadRemark().then(() => { if (this.isConnected && !this.#scope) this.connectedCallback(); });
1600
+ return;
1601
+ } else this.#boot();
1602
+ this.#cleanups = this.#hooks.mount.map((fn) => fn()).filter((cleanup) => typeof cleanup === 'function');
1603
+ this.#started();
1604
+ }
1605
+
1606
+ /** The element is live: hydrated() turns true, and the host gets :state(hydrated). */
1607
+ #started() {
1608
+ this.#hooks.hydrated.set(true);
1609
+ this.#states?.add('hydrated');
1610
+ }
1611
+
1612
+ disconnectedCallback() {
1613
+ if (!this.#scope) return; // (not started: waiting for remark.js)
1614
+ for (const cleanup of this.#cleanups.splice(0)) cleanup();
1615
+ for (const fn of this.#hooks.unmount) fn();
1616
+ // Paused, not disposed: long-lived signals stop holding this element, and if it comes
1617
+ // back only the parts whose inputs changed meanwhile are re-run.
1618
+ this.#scope.pause();
1619
+ }
1620
+
1621
+ /**
1622
+ * Dispatch the component's event. It bubbles (and leaves a shadow root); `before…` events
1623
+ * are cancelable, like the platform's. Returns false if a listener called preventDefault().
1624
+ */
1625
+ #emit(name, detail) {
1626
+ const event = new CustomEvent(name, {
1627
+ detail, bubbles: true, composed: shadow, cancelable: name.startsWith('before'),
1628
+ });
1629
+ fired.set(event, this);
1630
+ return this.dispatchEvent(event);
1631
+ }
1632
+
1633
+ /** Resolves once pending prop changes have reached the DOM. */
1634
+ get updateComplete() {
1635
+ return tick();
1636
+ }
1637
+
1638
+ // Form-associated lifecycle (only called by the browser when `formAssociated` is true).
1639
+ formResetCallback() {
1640
+ for (const fn of this.#hooks?.formReset ?? []) fn();
1641
+ }
1642
+ formStateRestoreCallback(state, mode) {
1643
+ for (const fn of this.#hooks?.formRestore ?? []) fn(state, mode);
1644
+ }
1645
+ formDisabledCallback(disabled) {
1646
+ this.#disabled = disabled;
1647
+ for (const fn of this.#hooks?.formDisabled ?? []) fn(disabled);
1648
+ }
1649
+
1650
+ // @hot
1651
+ /** Re-run the (new) script and template, keeping props, state and slotted content. */
1652
+ #reload() {
1653
+ if (!this.#scope) return;
1654
+ const connected = this.isConnected;
1655
+ if (connected) {
1656
+ for (const cleanup of this.#cleanups.splice(0)) cleanup();
1657
+ for (const fn of this.#hooks.unmount) fn();
1658
+ }
1659
+ const kept = this.#hooks.states.map((signal) => untrack(() => signal.get()));
1660
+ const slotted = shadow ? [] : [...this.#context.slots.values()].flatMap((slot) => slot.nodes);
1661
+ this.#context.observer?.disconnect();
1662
+ // The host's child methods are routed to the old slots (see routeChildren); back to native.
1663
+ for (const key of ROUTED) delete this[key];
1664
+ this.#scope.dispose();
1665
+ this.#scope = null;
1666
+ if (shadow) this.shadowRoot.replaceChildren();
1667
+ else Element.prototype.replaceChildren.call(this, ...slotted);
1668
+ this.#boot(kept, true);
1669
+ if (connected) {
1670
+ this.#cleanups = this.#hooks.mount.map((fn) => fn()).filter((cleanup) => typeof cleanup === 'function');
1671
+ this.#started();
1672
+ }
1673
+ }
1674
+ // @hot-end
1675
+
1676
+ #boot(kept = null, reloading = false) {
1677
+ const element = this;
1678
+ // Props a framework's server rendering wrote as JSON (the adapters' `zb-props`), for
1679
+ // starting with them before the framework sets them as properties.
1680
+ let given;
1681
+ if (!reloading) try { given = JSON.parse(this.getAttribute('zb-props')); } catch {}
1682
+ for (const name in given) if (Object.hasOwn(props, name) && !this.#given.has(name)) this[name] = given[name];
1683
+ // The server's stand-in for this control's value, for submitting before JavaScript
1684
+ // (@zubra/server's injectShadowRoots): the element's own form value takes over now.
1685
+ if (form) for (const child of this.querySelectorAll(':scope>input[data-zb-fallback]')) child.remove();
1686
+ // @hot (left out of the production build: scripts/build-production.js)
1687
+ if (globalThis.__ZUBRA_HOT__ && !reloading) live.add(new WeakRef(this));
1688
+ // @hot-end
1689
+ const root = shadow ? (this.shadowRoot ?? this.attachShadow({ mode: 'open' })) : this;
1690
+ if (shadow) {
1691
+ adopt(root, [...globalSheets, ...ownSheets()]);
1692
+ shadowRoots.add(new WeakRef(root));
1693
+ // The server's copy of the styles, for before this ran; the adopted sheet replaces it.
1694
+ const serverStyles = root.firstElementChild;
1695
+ if (serverStyles?.localName === 'style' && serverStyles.hasAttribute('data-zubra')) {
1696
+ serverStyles.remove();
1697
+ }
1698
+ }
1699
+ const $p = {};
1700
+ for (const name of Object.keys(props)) {
1701
+ // A two-way prop (`value:model`) fires `<prop>change` when the component assigns it.
1702
+ const change = props[name].model ? `${name.toLowerCase()}change` : null;
1703
+ Object.defineProperty($p, name, {
1704
+ get: () => element.#signals[name].get(),
1705
+ set: (value) => {
1706
+ const signal = element.#signals[name];
1707
+ if (change && Object.is(untrack(() => signal.get()), value)) return;
1708
+ signal.set(value);
1709
+ if (change) element.#emit(change, value);
1710
+ },
1711
+ enumerable: true,
1712
+ });
1713
+ }
1714
+ this.#hooks = {
1715
+ mount: [], unmount: [], host: this, props: $p, instance: ++instances, ids: 0, filled: new Map(), states: [], kept, hydrated: new Signal.State(false),
1716
+ internals: this.#internals, formReset: [], formRestore: [], formDisabled: [],
1717
+ };
1718
+ // The element's own root: an effect that inserted it (a list row) doesn't own it, so
1719
+ // re-running that effect can't dispose the element's effects.
1720
+ this.#scope = rootScope(() => {
1721
+ const previous = current;
1722
+ current = this.#hooks;
1723
+ let parts;
1724
+ // A default computed from the script (`count={start}` where `const start = Date.now()`),
1725
+ // unless the prop was given.
1726
+ const initial = (name, compute) => {
1727
+ if (!this.#given.has(name)) this.#signals[name].set(compute());
1728
+ };
1729
+ try { parts = setup ? setup($p, (name, detail) => this.#emit(name, detail), initial) : []; } finally { current = previous; }
1730
+ // A form control with one two-way prop: that's what it submits, and what a form reset
1731
+ // restores, unless the script says otherwise.
1732
+ const models = Object.keys(props).filter((name) => props[name].model);
1733
+ if (form && models.length === 1) {
1734
+ const [name] = models;
1735
+ if (!this.#hooks.formValued) {
1736
+ effect(() => {
1737
+ const value = $p[name];
1738
+ this.#internals.setFormValue(value == null || value === false || value === ''
1739
+ ? null
1740
+ : value === true ? 'on' : typeof value === 'object' ? JSON.stringify(value) : String(value));
1741
+ });
1742
+ }
1743
+ if (!this.#hooks.formReset.length) {
1744
+ const initialValue = untrack(() => $p[name]);
1745
+ this.#hooks.formReset.push(() => { $p[name] = initialValue; });
1746
+ }
1747
+ }
1748
+
1749
+ const context = { slots: new Map(), slotted: styled && !shadow ? tag : null, filled: this.#hooks.filled, states: this.#states };
1750
+ this.#context = context;
1751
+ const server = hasContent(root);
1752
+ // Server markup whose comments were stripped: put them back, where the template says.
1753
+ const stripped = server && !reloading && hasComment(componentTemplate.content) && !hasComment(root);
1754
+ const restored = stripped && !!remarker?.remark(root, componentTemplate.content, parts, { blueprint, isOpen, isClose, read: $r, untrack, State: Signal.State });
1755
+ // Server markup has the template's shape; anything else is the page's own children.
1756
+ const nodes = server && !reloading && signature(childRange(root)) === templateSignature
1757
+ ? locate(childRange(root), parts, true)
1758
+ : null;
1759
+ if (nodes) {
1760
+ parts.forEach((part, index) => bind(part, nodes[index], true, context));
1761
+ } else {
1762
+ if (server && !shadow && slots.length) {
1763
+ // Not server markup: the page's own children for this element, to be slotted.
1764
+ const children = [...root.childNodes];
1765
+ assignSlots(children).forEach((name, i) => slotRecord(context, name).nodes.push(children[i]));
1766
+ for (const [name, slot] of context.slots) {
1767
+ if (!slots.includes(name) && slot.nodes.some(isContent)) console.warn(`zubra: <${tag}> has no slot named "${name}"; that content is not shown${see('slots')}`);
1768
+ }
1769
+ } else if (server) {
1770
+ // No comments at all where the template has markers: something stripped them, and
1771
+ // they couldn't be put back for sure.
1772
+ console.warn(stripped && !restored
1773
+ ? `zubra: <${tag}>'s hydration markers (<?zb [?>, <?zb ]?>, …) are missing, so it's rendered fresh. A sanitizer or a fragment parser removed them; keep processing instructions (and comments, for markup from a 4.x server)${see('hydration-markers')}`
1774
+ : `zubra: <${tag}>'s markup doesn't match its template; rendering it fresh${see('server-rendering-and-hydration')}`);
1775
+ }
1776
+ // Find and bind the parts before inserting (see mountBlock).
1777
+ const fragment = document.importNode(componentTemplate.content, true);
1778
+ const fresh = locate(childRange(fragment), parts);
1779
+ parts.forEach((part, index) => bind(part, fresh[index], false, context));
1780
+ root.replaceChildren(fragment);
1781
+ }
1782
+ if (!shadow) {
1783
+ // Content for slots that aren't rendered now waits in their park.
1784
+ for (const slot of context.slots.values()) {
1785
+ if (!slot.region) slot.park.append(...slot.nodes);
1786
+ slot.filled.set(slot.nodes.some(isContent));
1787
+ }
1788
+ routeChildren(this, context, slots, tag);
1789
+ } else if (this.#hooks.filled.size) {
1790
+ // Shadow DOM: the host's children are assigned by the browser; follow them.
1791
+ const follow = () => {
1792
+ for (const [name, signal] of this.#hooks.filled) {
1793
+ signal.set([...this.childNodes].some((node) => isContent(node) && (node.nodeType === 1 ? (node.getAttribute('slot') ?? 'default') : 'default') === name));
1794
+ }
1795
+ };
1796
+ follow();
1797
+ this.#hooks.mount.push(() => {
1798
+ follow();
1799
+ const observer = new MutationObserver(follow);
1800
+ observer.observe(this, { childList: true, subtree: true, attributes: true, attributeFilter: ['slot'], characterData: true });
1801
+ return () => observer.disconnect();
1802
+ });
1803
+ }
1804
+ });
1805
+ // Disabled (e.g. inside a disabled <fieldset>) before setup registered its hooks. Chromium
1806
+ // always calls formDisabledCallback after connectedCallback, so this never runs there (the
1807
+ // browser tests confirm the behaviour without it); it guards engines that order the
1808
+ // callbacks the other way, which the test suite can't currently exercise.
1809
+ if (this.#disabled) for (const fn of this.#hooks.formDisabled) fn(true);
1810
+ }
1811
+ }
1812
+
1813
+ if (form) {
1814
+ // The API native form controls have, backed by ElementInternals.
1815
+ const api = {
1816
+ form: { get() { return internals(this).form; } },
1817
+ validity: { get() { return internals(this).validity; } },
1818
+ validationMessage: { get() { return internals(this).validationMessage; } },
1819
+ willValidate: { get() { return internals(this).willValidate; } },
1820
+ labels: { get() { return internals(this).labels; } },
1821
+ checkValidity: { value() { return internals(this).checkValidity(); } },
1822
+ reportValidity: { value() { return internals(this).reportValidity(); } },
1823
+ };
1824
+ if (!('name' in props)) {
1825
+ api.name = {
1826
+ get() { return this.getAttribute('name'); },
1827
+ set(value) { this.setAttribute('name', value); },
1828
+ };
1829
+ }
1830
+ for (const [key, descriptor] of Object.entries(api)) {
1831
+ Object.defineProperty(ZubraElement.prototype, key, { ...descriptor, configurable: true });
1832
+ }
1833
+ }
1834
+
1835
+ customElements.define(tag, ZubraElement);
1836
+ return ZubraElement;
1837
+ }