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