@zubra/runtime 5.6.0 → 5.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/context.js ADDED
@@ -0,0 +1,100 @@
1
+ // Context from the app: a `<zb-context>` element that answers the Context Protocol for the
2
+ // elements inside it, so an app's theme, locale or current user reaches every component without
3
+ // being passed down as props.
4
+ //
5
+ // <zb-context .context=${theme} .value=${'dark'}> …elements that inject(theme)… </zb-context>
6
+ //
7
+ // It's a plain custom element with two properties, so every framework can use it as it is:
8
+ // Svelte `<zb-context {context} {value}>`, Solid `prop:context`, Angular `[context]`, Lit
9
+ // `.context`, plain HTML `el.value = …`. React and Vue have `ZubraContext` components
10
+ // (`@zubra/runtime/react`, `@zubra/runtime/vue`) that wrap it. The element is `display:
11
+ // contents`, so it adds no box to the layout.
12
+ //
13
+ // The value can be a plain value or a signal; subscribers hear each change either way. Any
14
+ // Context Protocol consumer is answered: Zubra's `inject()`, Lit's `@consume`, and so on. A
15
+ // consumer that asked before the element was given its context asks again when the element
16
+ // announces it with a `context-provider` event (children connect after their parent, so one
17
+ // given its context before it's in the document is there when they first ask).
18
+ import { Signal } from './signal.js';
19
+ import { effect, root } from './effect.js';
20
+ import { ContextProviderEvent } from './element.js';
21
+
22
+ const isSignal = (value) => value instanceof Signal.State || value instanceof Signal.Computed;
23
+ const unwrap = (value) => (isSignal(value) ? value.get() : value);
24
+
25
+ const Base = globalThis.HTMLElement ?? class {};
26
+
27
+ export class ZubraContextElement extends Base {
28
+ #context = undefined;
29
+ #value = undefined;
30
+ #subscribers = new Set();
31
+ #watching = null; // the effect following a signal value
32
+
33
+ constructor() {
34
+ super();
35
+ this.addEventListener('context-request', (event) => {
36
+ if (this.#context === undefined || event.context !== this.#context || event.contextTarget === this) return;
37
+ event.stopPropagation();
38
+ const current = Signal.subtle.untrack(() => unwrap(this.#value));
39
+ if (!event.subscribe) return event.callback(current);
40
+ const subscriber = { callback: event.callback };
41
+ subscriber.unsubscribe = () => this.#subscribers.delete(subscriber);
42
+ this.#subscribers.add(subscriber);
43
+ event.callback(current, subscriber.unsubscribe);
44
+ });
45
+ }
46
+
47
+ /** The context key this element provides (from `createContext()`, or any protocol key). */
48
+ get context() {
49
+ return this.#context;
50
+ }
51
+
52
+ set context(key) {
53
+ if (key === this.#context) return;
54
+ // Consumers of the old key keep what they have, but stop hearing from this element.
55
+ this.#subscribers.clear();
56
+ this.#context = key;
57
+ this.#announce();
58
+ }
59
+
60
+ /** The value, or a signal holding it. */
61
+ get value() {
62
+ return this.#value;
63
+ }
64
+
65
+ set value(next) {
66
+ if (Object.is(next, this.#value)) return;
67
+ this.#value = next;
68
+ this.#watching?.dispose();
69
+ this.#watching = null;
70
+ if (isSignal(next)) {
71
+ let first = true;
72
+ this.#watching = root(() => effect(() => {
73
+ const value = next.get();
74
+ if (first) first = false;
75
+ else Signal.subtle.untrack(() => this.#notify(value));
76
+ }));
77
+ }
78
+ this.#notify(Signal.subtle.untrack(() => unwrap(next)));
79
+ }
80
+
81
+ connectedCallback() {
82
+ if (!this.style.display) this.style.display = 'contents';
83
+ }
84
+
85
+ #notify(value) {
86
+ for (const subscriber of [...this.#subscribers]) subscriber.callback(value, subscriber.unsubscribe);
87
+ }
88
+
89
+ #announce() {
90
+ if (this.#context !== undefined && this.isConnected) this.dispatchEvent(new ContextProviderEvent(this.#context, this));
91
+ }
92
+ }
93
+
94
+ /** Define `<zb-context>` (once; a no-op where there's no `customElements`, as on a server). */
95
+ export function defineContextElement() {
96
+ const registry = globalThis.customElements;
97
+ if (registry && !registry.get('zb-context')) registry.define('zb-context', ZubraContextElement);
98
+ }
99
+
100
+ defineContextElement();
package/src/element.js CHANGED
@@ -63,6 +63,15 @@ const clone = (value) => (value && typeof value === 'object' ? structuredClone(v
63
63
  /** Attribute text → prop value, typed by the prop's default (JSON for arrays and objects). */
64
64
  function parseAttribute(spec, raw) {
65
65
  const fallback = spec.default;
66
+ // An enum prop (`<script lang="ts">`): the member whose value the text is, or the member it names.
67
+ if (spec.enum) {
68
+ if (raw === null) return clone(fallback);
69
+ const found = Object.values(spec.enum).find((value) => String(value) === raw);
70
+ if (found !== undefined) return found;
71
+ if (Object.hasOwn(spec.enum, raw)) return spec.enum[raw];
72
+ console.warn(`zubra: attribute "${spec.attribute}" is "${raw}", not one of ${Object.keys(spec.enum).join(', ')}`);
73
+ return clone(fallback);
74
+ }
66
75
  if (typeof fallback === 'boolean') return fallback ? raw !== 'false' : raw !== null;
67
76
  if (raw === null) return clone(fallback);
68
77
  if (typeof fallback === 'number') return Number(raw);
@@ -1564,6 +1573,18 @@ function defineElement(tag, definition) {
1564
1573
  const live = new Set(); // WeakRefs to the elements, kept only for hot reloads
1565
1574
  // @hot-end
1566
1575
  const byAttribute = Object.fromEntries(Object.entries(props).map(([name, spec]) => [spec.attribute, name]));
1576
+ // HTML lowercases attribute names, so `maxValue` in a page arrives as `maxvalue`, which isn't
1577
+ // the prop's attribute (`max-value`): the element would ignore it without a word.
1578
+ const lowercased = Object.entries(props)
1579
+ .filter(([name, spec]) => name !== name.toLowerCase() && spec.attribute !== name.toLowerCase())
1580
+ .map(([name, spec]) => [name.toLowerCase(), name, spec.attribute]);
1581
+ const checkAttributeCase = (element) => {
1582
+ for (const [lower, name, attribute] of lowercased) {
1583
+ if (element.hasAttribute(lower) && !element.hasAttribute(attribute)) {
1584
+ console.warn(`zubra: <${tag}> ignores the attribute "${lower}": HTML lowercases attribute names, and the prop ${name} is the attribute "${attribute}"${see('writing-a-component')}`);
1585
+ }
1586
+ }
1587
+ };
1567
1588
 
1568
1589
  let internals; // (element) => its ElementInternals; set in the static block below
1569
1590
  class ZubraElement extends HTMLElement {
@@ -1658,7 +1679,10 @@ function defineElement(tag, definition) {
1658
1679
  // Server markup without its comments: start once the code that puts them back is here.
1659
1680
  loadRemark().then(() => { if (this.isConnected && !this.#scope) this.connectedCallback(); });
1660
1681
  return;
1661
- } else this.#boot();
1682
+ } else {
1683
+ if (lowercased.length) checkAttributeCase(this);
1684
+ this.#boot();
1685
+ }
1662
1686
  this.#cleanups = this.#hooks.mount.map((fn) => fn()).filter((cleanup) => typeof cleanup === 'function');
1663
1687
  this.#started();
1664
1688
  }
package/src/forms.js ADDED
@@ -0,0 +1,125 @@
1
+ // Form libraries: a Zubra control as a field of React Hook Form, TanStack Form, VeeValidate or
2
+ // Felte. Each helper turns what the library hands you for a field into the props (or the Svelte
3
+ // action) a Zubra control takes, through its two-way binding: a `<prop>` and its `<prop>change`
4
+ // event (`value` and `valuechange` by default).
5
+ //
6
+ // React Hook Form <Stepper {...registerProps(register('seats'))} />
7
+ // <Controller name="seats" render={({ field }) => <Stepper {...fieldProps(field)} />} />
8
+ // TanStack Form <form.Field name="seats">{(field) => <Stepper {...fieldProps(field)} />}</form.Field>
9
+ // VeeValidate <Stepper v-bind="vueFieldProps(useField('seats'))" />
10
+ // Felte <zb-stepper use:felteControl={createField('seats')}>
11
+ //
12
+ // Nothing here imports a library: each helper reads the shape the library documents. Native
13
+ // form submission needs none of this; a Zubra control with `form-associated` is already a form
14
+ // control (FormData, `<form method=post>`, server actions).
15
+
16
+ const handler = (prop) => `on${prop.toLowerCase()}change`; // the React wrapper takes any casing
17
+ const unref = (value) => (value && typeof value === 'object' && value.__v_isRef ? value.value : value);
18
+
19
+ /**
20
+ * React Hook Form's `register(name)` as props for a wrapped Zubra control: the control's
21
+ * `value`, its `valuechange` reported as a change, blur reported as a blur, and the element as
22
+ * the ref (so `reset()` and `setValue()` write its `value`, and `setFocus()` focuses it). For a
23
+ * control whose model isn't `value`, use `fieldProps` with a `<Controller>`.
24
+ */
25
+ export function registerProps(registration) {
26
+ const { name, ref, onChange, onBlur, disabled } = registration;
27
+ return {
28
+ name,
29
+ ref,
30
+ ...(disabled === undefined ? {} : { disabled }),
31
+ onvaluechange: (event) => onChange({ type: 'change', target: { name, value: event.detail } }),
32
+ onBlur: (event) => onBlur({ type: 'blur', target: { name, value: event.currentTarget?.value } }),
33
+ };
34
+ }
35
+
36
+ /**
37
+ * A field's props for a wrapped Zubra control in React, from React Hook Form's `<Controller>`
38
+ * (`{ value, onChange, onBlur, name, ref }`) or TanStack Form's field API
39
+ * (`{ state: { value }, handleChange, handleBlur, name }`). `prop` names the control's model
40
+ * (`value` by default).
41
+ */
42
+ export function fieldProps(field, { prop = 'value' } = {}) {
43
+ const tanstack = typeof field.handleChange === 'function';
44
+ const value = tanstack ? field.state.value : field.value;
45
+ const change = tanstack ? field.handleChange : field.onChange;
46
+ const blur = tanstack ? field.handleBlur : field.onBlur;
47
+ return {
48
+ name: field.name,
49
+ [prop]: value,
50
+ [handler(prop)]: (event) => change(event.detail),
51
+ onBlur: () => blur(),
52
+ ...(field.ref ? { ref: field.ref } : {}),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * A field's props for a wrapped Zubra control in Vue, from VeeValidate's `useField()` (whose
58
+ * `value` is a ref) or the `field` a `<Field v-slot="{ field }">` gives. `prop` names the
59
+ * control's model (`value` by default).
60
+ */
61
+ export function vueFieldProps(field, { prop = 'value' } = {}) {
62
+ const change = field.handleChange ?? field.onChange;
63
+ const blur = field.handleBlur ?? field.onBlur;
64
+ const value = 'value' in field ? unref(field.value) : field.modelValue;
65
+ return {
66
+ name: unref(field.name),
67
+ [prop]: value,
68
+ [`onUpdate:${prop}`]: (next) => (Array.isArray(change) ? change.forEach((fn) => fn(next)) : change(next)),
69
+ // The control's own focus leaves it: `blur` doesn't bubble out of its inner input, `focusout` does.
70
+ onFocusout: (event) => (Array.isArray(blur) ? blur.forEach((fn) => fn(event)) : blur?.(event)),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Felte: a Svelte action (or a plain function for `@felte/vanilla`) that connects a Zubra control
76
+ * to a field from Felte's `createField(name)`. Felte reads native inputs, so the field keeps a
77
+ * hidden one in step with the control: each `<prop>change` is reported as input, focus leaving
78
+ * the control as a blur, and Felte's own writes (`reset()`, `setFields()`) reach the control. Returns `{ destroy }`.
79
+ *
80
+ * <zb-stepper use:felteControl={createField('seats')}></zb-stepper>
81
+ */
82
+ export function felteControl(node, created, { prop = 'value' } = {}) {
83
+ const field = created?.field ? created : null;
84
+ if (!field) throw new TypeError("zubra: felteControl() needs the result of Felte's createField(name)");
85
+ const action = field.field(node) ?? {};
86
+ // Felte puts its hidden input in place, after the control, on the next task. Start it at the
87
+ // control's value; keep a number a number (Felte reads `type="number"` inputs as numbers, and this
88
+ // one stays hidden); and pass Felte's own writes to it (`reset()`, `setFields()`) to the control.
89
+ let writing = false;
90
+ const start = setTimeout(() => {
91
+ const input = node.nextElementSibling;
92
+ if (input?.localName === 'input' && input.type === 'hidden') {
93
+ if (typeof node[prop] === 'number') {
94
+ input.type = 'number';
95
+ input.hidden = true;
96
+ }
97
+ const own = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
98
+ Object.defineProperty(input, 'value', {
99
+ configurable: true,
100
+ get: () => own.get.call(input),
101
+ set: (value) => {
102
+ own.set.call(input, value);
103
+ if (!writing) node[prop] = input.type === 'number' && value !== '' ? Number(value) : value;
104
+ },
105
+ });
106
+ }
107
+ report(node[prop]);
108
+ });
109
+ const report = (value) => {
110
+ writing = true;
111
+ try { field.onInput(value); } finally { writing = false; }
112
+ };
113
+ const change = (event) => report(event.detail);
114
+ const blur = () => field.onBlur();
115
+ node.addEventListener(prop.toLowerCase() + 'change', change);
116
+ node.addEventListener('focusout', blur);
117
+ return {
118
+ destroy() {
119
+ clearTimeout(start);
120
+ node.removeEventListener(prop.toLowerCase() + 'change', change);
121
+ node.removeEventListener('focusout', blur);
122
+ action.destroy?.();
123
+ },
124
+ };
125
+ }
@@ -0,0 +1,52 @@
1
+ // Links inside components, through the app's router: a plain `<a href>` in a Zubra component
2
+ // navigates with the router the app already has (React Router, Next.js, Vue Router, SvelteKit,
3
+ // Angular Router…) instead of reloading the page. Not a router: one listener that hands the
4
+ // app's router the URL, and otherwise leaves the link alone.
5
+ //
6
+ // routeLinks((to) => router.push(to)) // Vue Router; returns a function that stops it
7
+ // useRouteLinks(useNavigate()) // React Router (`@zubra/runtime/react`)
8
+ // useRouteLinks(useRouter().push) // Next.js
9
+ // routeLinks((to) => goto(to)) // SvelteKit, in onMount
10
+ // routeLinks((to) => router.navigateByUrl(to)) // Angular
11
+ //
12
+ // Without JavaScript, or for a link it leaves alone, the link is a link. It leaves alone: a click
13
+ // something already handled (`preventDefault()`), a modified or non-primary click (new tab,
14
+ // window, download), a `target` other than `_self`, `download`, `rel="external"`, another
15
+ // origin, and a link to a place on the current page (`#…`). Only links inside a Zubra component
16
+ // are routed by default (the app's own links are its router's business); `within` changes that.
17
+ //
18
+ // If the app's router already intercepts the Navigation API's `navigate` event (Baseline since
19
+ // January 2026), links in components already go through it, and none of this is needed.
20
+
21
+ /** A Zubra component's host: its class carries the compiled `zubra` metadata. */
22
+ export const isComponent = (node) => Boolean(node?.constructor?.zubra);
23
+
24
+ /**
25
+ * Route clicks on links inside Zubra components through `navigate(to, { event, anchor, url })`,
26
+ * where `to` is the path, query and hash (`/seats?row=b#top`). Returns a function that stops it.
27
+ * `within(node)` decides which ancestors count (by default, a Zubra component's host); `root` is
28
+ * where to listen (by default, the document).
29
+ */
30
+ export function routeLinks(navigate, { within = isComponent, root = globalThis.document } = {}) {
31
+ if (typeof navigate !== 'function') throw new TypeError('zubra: routeLinks() needs a function that navigates, such as (to) => router.push(to)');
32
+ if (!root) return () => {};
33
+ const click = (event) => {
34
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
35
+ const path = event.composedPath();
36
+ const at = path.findIndex((node) => node.localName === 'a' && node.hasAttribute?.('href'));
37
+ if (at < 0) return;
38
+ const anchor = path[at];
39
+ if (!path.slice(at + 1).some((node) => node.nodeType === 1 && within(node))) return;
40
+ const target = anchor.getAttribute('target');
41
+ if ((target && target !== '_self') || anchor.hasAttribute('download')) return;
42
+ if ((anchor.getAttribute('rel') ?? '').split(/\s+/).includes('external')) return;
43
+ const url = new URL(anchor.href, anchor.baseURI);
44
+ const here = new URL(globalThis.location.href);
45
+ if (url.origin !== here.origin) return;
46
+ if (url.hash && url.pathname === here.pathname && url.search === here.search) return;
47
+ event.preventDefault();
48
+ navigate(url.pathname + url.search + url.hash, { event, anchor, url });
49
+ };
50
+ root.addEventListener('click', click);
51
+ return () => root.removeEventListener('click', click);
52
+ }
package/src/outlet.js ADDED
@@ -0,0 +1,79 @@
1
+ // Outlets (experimental): a place in a Zubra component where the app's framework renders its
2
+ // own components, with the app's context, state and events, through a portal.
3
+ //
4
+ // <!-- row-list.zb -->
5
+ // <zb-each items={items} as="item" key={item.id}>
6
+ // <li><zb-outlet name="row" item={item}></zb-outlet></li>
7
+ // </zb-each>
8
+ //
9
+ // // React
10
+ // <RowList items={rows} renderRow={(item) => <Row item={item} />} />
11
+ //
12
+ // Slots let the consumer put content *in* a component; an outlet lets the component ask the
13
+ // consumer for content *per item*, which is what render props and scoped slots do inside one
14
+ // framework. The component stays the owner of the structure (the list, its keys, its order);
15
+ // the framework renders what goes in each outlet, as a portal, so React context, hooks and
16
+ // event handlers work there as anywhere else in the app.
17
+ //
18
+ // Protocol: a connected `<zb-outlet>` fires a bubbling, composed `outletconnect` event. A
19
+ // listener that serves outlets of that name calls `event.claim({ change, disconnect })` and
20
+ // stops it: `change(outlet)` is called when the outlet's `item` changes, `disconnect(outlet)`
21
+ // when it leaves the document. Outlets connected before anyone listened are found with
22
+ // `outletsOf(host)`. A keyed move disconnects and reconnects the outlet node itself, so a
23
+ // renderer that keys its portal by the node (as the React adapter does) keeps what's in it.
24
+ const Base = globalThis.HTMLElement ?? class {};
25
+
26
+ export class ZubraOutletElement extends Base {
27
+ #item = undefined;
28
+ /** Who renders into this outlet: `{ change, disconnect }`, or null. */
29
+ owner = null;
30
+
31
+ /** Which of the consumer's renderers fills it. */
32
+ get name() {
33
+ return this.getAttribute('name') ?? '';
34
+ }
35
+
36
+ /** The data the component gives the renderer for this outlet. */
37
+ get item() {
38
+ return this.#item;
39
+ }
40
+
41
+ set item(value) {
42
+ if (Object.is(value, this.#item)) return;
43
+ this.#item = value;
44
+ this.owner?.change(this);
45
+ }
46
+
47
+ connectedCallback() {
48
+ if (!this.style.display) this.style.display = 'contents';
49
+ if (this.owner) return;
50
+ const event = new Event('outletconnect', { bubbles: true, composed: true });
51
+ event.outlet = this;
52
+ event.claim = (owner) => {
53
+ this.owner = owner;
54
+ event.stopPropagation();
55
+ };
56
+ this.dispatchEvent(event);
57
+ }
58
+
59
+ disconnectedCallback() {
60
+ const owner = this.owner;
61
+ this.owner = null;
62
+ owner?.disconnect(this);
63
+ }
64
+ }
65
+
66
+ /** The unclaimed outlets inside `host` (its light DOM and its shadow root), in document order. */
67
+ export function outletsOf(host) {
68
+ const found = [...host.querySelectorAll('zb-outlet')];
69
+ if (host.shadowRoot) found.push(...host.shadowRoot.querySelectorAll('zb-outlet'));
70
+ return found.filter((outlet) => !outlet.owner);
71
+ }
72
+
73
+ /** Define `<zb-outlet>` (once; a no-op without `customElements`). */
74
+ export function defineOutletElement() {
75
+ const registry = globalThis.customElements;
76
+ if (registry && !registry.get('zb-outlet')) registry.define('zb-outlet', ZubraOutletElement);
77
+ }
78
+
79
+ defineOutletElement();
package/src/react.js CHANGED
@@ -15,9 +15,29 @@
15
15
  // works the same way.
16
16
  import * as React from 'react';
17
17
  import { serverContent } from './server.js';
18
+ import { outletsOf } from './outlet.js';
19
+ import { routeLinks } from './navigation.js';
20
+ import './context.js';
21
+ import { subscribe, read } from './bridge.js';
18
22
 
19
23
  const useIsomorphicLayoutEffect = typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect;
20
24
 
25
+ // `renderRow` serves `<zb-outlet name="row">`, `renderDetailPanel` serves `detail-panel`.
26
+ const outletName = (key) => key.slice(6).replace(/[A-Z]/g, (letter, at) => (at ? '-' : '') + letter.toLowerCase());
27
+ const isRenderer = (key, value) => key.length > 6 && key.startsWith('render') && /[A-Z]/.test(key[6]) && typeof value === 'function';
28
+ // react-dom's createPortal, loaded when an element first has an outlet to fill: a page that
29
+ // uses no outlets (or maps only `react` in an import map) never imports react-dom from here.
30
+ let createPortal = null;
31
+ const portals = () => (createPortal ? Promise.resolve() : import('react-dom').then((dom) => { createPortal = dom.createPortal; }));
32
+
33
+ // A stable key per outlet, for its portal.
34
+ const outletKeys = new WeakMap();
35
+ let nextOutlet = 0;
36
+ const outletKey = (outlet) => {
37
+ if (!outletKeys.has(outlet)) outletKeys.set(outlet, `zb-outlet-${++nextOutlet}`);
38
+ return outletKeys.get(outlet);
39
+ };
40
+
21
41
  const clone = (value) => (value && typeof value === 'object' ? structuredClone(value) : value);
22
42
 
23
43
  /** `zb-star-rating` → `ZbStarRating`. */
@@ -55,14 +75,69 @@ export function wrap(Element) {
55
75
  const values = {};
56
76
  const listeners = {};
57
77
  const rest = {};
78
+ const renderers = {};
58
79
  for (const key of Object.keys(all)) {
59
80
  if (key === 'children') continue;
60
81
  const event = key.length > 2 && key.startsWith('on') ? key.slice(2).toLowerCase() : null;
61
82
  if (Object.hasOwn(props, key)) values[key] = all[key];
62
83
  else if (event && events.includes(event)) listeners[event] = all[key];
84
+ else if (isRenderer(key, all[key])) renderers[outletName(key)] = all[key];
63
85
  else rest[key] = all[key];
64
86
  }
65
87
 
88
+ // Outlets (experimental, outlet.js): `render<Name>` fills each `<zb-outlet name>` the element
89
+ // renders, through a portal, so what's rendered there is part of this React tree (context,
90
+ // state, events). `outlets` maps each claimed outlet to its current item.
91
+ const served = React.useRef(renderers);
92
+ served.current = renderers;
93
+ const [outlets, setOutlets] = React.useState(() => new Map());
94
+ useIsomorphicLayoutEffect(() => {
95
+ const node = element.current;
96
+ const claimed = new Set();
97
+ const owner = {
98
+ change: (outlet) => setOutlets((map) => new Map(map).set(outlet, outlet.item)),
99
+ disconnect: (outlet) => {
100
+ claimed.delete(outlet);
101
+ setOutlets((map) => {
102
+ const next = new Map(map);
103
+ next.delete(outlet);
104
+ return next;
105
+ });
106
+ },
107
+ };
108
+ const take = (outlets) => {
109
+ if (!outlets.length) return;
110
+ for (const outlet of outlets) {
111
+ outlet.owner = owner;
112
+ claimed.add(outlet);
113
+ }
114
+ const add = () => setOutlets((map) => {
115
+ const next = new Map(map);
116
+ for (const outlet of outlets) if (claimed.has(outlet)) next.set(outlet, outlet.item);
117
+ return next;
118
+ });
119
+ if (createPortal) add();
120
+ else portals().then(add);
121
+ };
122
+ const connected = (event) => {
123
+ if (!Object.hasOwn(served.current, event.outlet.name)) return;
124
+ event.claim(owner);
125
+ take([event.outlet]);
126
+ };
127
+ node.addEventListener('outletconnect', connected);
128
+ // Outlets the element rendered before this listener existed.
129
+ take(outletsOf(node).filter((outlet) => Object.hasOwn(served.current, outlet.name)));
130
+ return () => {
131
+ node.removeEventListener('outletconnect', connected);
132
+ for (const outlet of claimed) outlet.owner = null;
133
+ };
134
+ }, []);
135
+ const filled = [];
136
+ for (const [outlet, item] of outlets) {
137
+ const render = renderers[outlet.name];
138
+ if (render && createPortal) filled.push(createPortal(render(item), outlet, outletKey(outlet)));
139
+ }
140
+
66
141
  // Subscribe once per event; always call the latest handler.
67
142
  const handlers = React.useRef(listeners);
68
143
  handlers.current = listeners;
@@ -114,11 +189,70 @@ export function wrap(Element) {
114
189
  // @zubra/server's injectShadowRoots; this works without one.)
115
190
  if (React.Children.count(all.children) === 0) {
116
191
  const html = typeof window === 'undefined' ? serverContent(Element, { props: json ? JSON.parse(json) : {}, name: rest.name }) : '';
117
- return React.createElement(tag, { ...host, dangerouslySetInnerHTML: { __html: html }, suppressHydrationWarning: true });
192
+ return React.createElement(React.Fragment, null,
193
+ React.createElement(tag, { ...host, dangerouslySetInnerHTML: { __html: html }, suppressHydrationWarning: true }), filled);
118
194
  }
119
- return React.createElement(tag, host, all.children);
195
+ return React.createElement(React.Fragment, null, React.createElement(tag, host, all.children), filled);
120
196
  });
121
197
  Component.displayName = pascal(tag);
122
198
  Component.element = Element;
123
199
  return Component;
124
200
  }
201
+
202
+ /**
203
+ * Route links inside Zubra components through the app's router while this component is mounted:
204
+ * `useRouteLinks(useNavigate())` (React Router), `useRouteLinks(useRouter().push)` (Next.js).
205
+ * The function can change between renders. Options as for `routeLinks` (`@zubra/runtime/navigation`).
206
+ */
207
+ export function useRouteLinks(navigate, options) {
208
+ const latest = React.useRef(navigate);
209
+ latest.current = navigate;
210
+ React.useEffect(() => routeLinks((to, info) => latest.current(to, info), options), []);
211
+ }
212
+
213
+ /**
214
+ * Provide a Context Protocol value to every element inside: an app's theme, locale or current user,
215
+ * read by components with `inject(context)` (or Lit's `@consume`).
216
+ *
217
+ * <ZubraContext context={theme} value={dark ? 'dark' : 'light'}>…</ZubraContext>
218
+ *
219
+ * `value` can also be a signal. It renders a `<zb-context>` element, which is `display: contents`.
220
+ */
221
+ export function ZubraContext({ context, value, children }) {
222
+ const ref = React.useRef(null);
223
+ useIsomorphicLayoutEffect(() => {
224
+ ref.current.context = context;
225
+ ref.current.value = value;
226
+ });
227
+ // React 19 sets these as properties when it creates the element, before the children are in
228
+ // the document, so they're answered on their first request; React 18 sets them in the effect,
229
+ // and the element's announcement makes the children ask again.
230
+ const early = legacy || typeof window === 'undefined' ? {} : { context, value };
231
+ return React.createElement('zb-context', { ...early, ref, style: { display: 'contents' } }, children);
232
+ }
233
+
234
+ /**
235
+ * Read a Zubra signal (or a function of signals) in a React component, re-rendering when it
236
+ * changes: a store shared with elements, a `computed`, a `@zubra/sync` field.
237
+ *
238
+ * const items = useSignal(cart, { deep: true }) // write with cart.set(...) or cart.get().push(...)
239
+ *
240
+ * Built on `useSyncExternalStore`, so it's safe under concurrent rendering and on the server
241
+ * (where it reads the value once). With `deep: true` a mutation inside an array or object
242
+ * re-renders too; the value is the same object, so memoize on the returned value with care.
243
+ * Pass a stable source (module-level, or from `useMemo`): a new function each render
244
+ * re-subscribes each render.
245
+ */
246
+ export function useSignal(source, { deep = false } = {}) {
247
+ const store = React.useMemo(() => {
248
+ let version = 0;
249
+ return {
250
+ subscribe: (notify) => subscribe(source, () => { version++; notify(); }, { deep }),
251
+ // Without `deep`, the value itself is the snapshot (React compares it with Object.is);
252
+ // with it, a counter that each notification moves, so a mutation counts as a change.
253
+ snapshot: deep ? () => version : () => read(source),
254
+ };
255
+ }, [source, deep]);
256
+ React.useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot);
257
+ return read(source);
258
+ }