@zubra/runtime 5.7.0 → 5.9.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/README.md +24 -0
- package/dist/production/bridge.js +97 -0
- package/dist/production/context.js +101 -0
- package/dist/production/effect.js +18 -1
- package/dist/production/element.js +94 -16
- package/dist/production/forms.js +126 -0
- package/dist/production/navigation.js +53 -0
- package/dist/production/outlet.js +80 -0
- package/dist/production/react.js +136 -2
- package/dist/production/resource.js +6 -2
- package/dist/production/testing.js +154 -0
- package/dist/production/track.js +78 -0
- package/dist/production/vue.js +67 -1
- package/package.json +48 -3
- package/src/bridge.js +96 -0
- package/src/context.js +100 -0
- package/src/effect.js +18 -1
- package/src/element.js +94 -16
- package/src/forms.js +125 -0
- package/src/navigation.js +52 -0
- package/src/outlet.js +79 -0
- package/src/react.js +136 -2
- package/src/resource.js +6 -2
- package/src/testing.js +153 -0
- package/src/track.js +77 -0
- package/src/vue.js +67 -1
- package/types/bridge.d.ts +44 -0
- package/types/context.d.ts +17 -0
- package/types/effect.d.ts +17 -4
- package/types/forms.d.ts +37 -0
- package/types/index.d.ts +6 -3
- package/types/navigation.d.ts +22 -0
- package/types/outlet.d.ts +23 -0
- package/types/react.d.ts +17 -1
- package/types/testing.d.ts +32 -0
- package/types/track.d.ts +19 -0
- package/types/vue.d.ts +15 -0
|
@@ -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(
|
|
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
|
+
}
|
package/src/resource.js
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
// `AbortSignal` it was given, and so does the element's removal; a result that arrives after a
|
|
9
9
|
// newer run started is dropped, so the value is always the latest request's. While a new run is
|
|
10
10
|
// pending the previous value stays (`loading` is true), so the UI doesn't flash empty.
|
|
11
|
+
// `dispose()` (or `using`, through `Symbol.dispose`) stops it early: the run in flight is
|
|
12
|
+
// aborted and nothing re-runs it. Otherwise it lives as long as the scope that created it.
|
|
11
13
|
import { Signal } from './signal.js';
|
|
12
|
-
import { effect } from './effect.js';
|
|
14
|
+
import { DISPOSE, effect } from './effect.js';
|
|
13
15
|
|
|
14
16
|
const { untrack } = Signal.subtle;
|
|
15
17
|
|
|
@@ -29,7 +31,7 @@ export function resource(fetcher, { initial } = {}) {
|
|
|
29
31
|
const version = new Signal.State(0);
|
|
30
32
|
let current = null; // the latest run's promise
|
|
31
33
|
|
|
32
|
-
effect(() => {
|
|
34
|
+
const stop = effect(() => {
|
|
33
35
|
version.get();
|
|
34
36
|
const controller = new AbortController();
|
|
35
37
|
let result;
|
|
@@ -71,5 +73,7 @@ export function resource(fetcher, { initial } = {}) {
|
|
|
71
73
|
get loading() { return status.get() === 'pending'; },
|
|
72
74
|
refresh() { version.set(untrack(() => version.get()) + 1); },
|
|
73
75
|
then(resolve, reject) { return current.then(resolve, reject); },
|
|
76
|
+
dispose: stop,
|
|
77
|
+
[DISPOSE]: stop,
|
|
74
78
|
};
|
|
75
79
|
}
|
package/src/testing.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Testing helpers: mount a Zubra component the way each framework's consumers do, so a library
|
|
2
|
+
// author can run one test through plain HTML, React and Vue, in any browser test runner (Vitest
|
|
3
|
+
// browser mode, Web Test Runner, Playwright's own).
|
|
4
|
+
//
|
|
5
|
+
// import { frameworks, mount } from '@zubra/runtime/testing';
|
|
6
|
+
// import StarRating from './star-rating.zb';
|
|
7
|
+
//
|
|
8
|
+
// for (const framework of await frameworks()) {
|
|
9
|
+
// test(`${framework}: a click sets the value and fires valuechange`, async () => {
|
|
10
|
+
// const view = await mount(StarRating, { framework, props: { value: 2 } });
|
|
11
|
+
// view.element.querySelector('button:nth-of-type(4)').click();
|
|
12
|
+
// expect(view.events('valuechange')).toEqual([4]);
|
|
13
|
+
// await view.set({ value: 1 }); // through the framework, as an app would
|
|
14
|
+
// expect(view.element.value).toBe(1);
|
|
15
|
+
// view.unmount();
|
|
16
|
+
// });
|
|
17
|
+
// }
|
|
18
|
+
//
|
|
19
|
+
// A `<prop>change` event updates the prop the framework passes, as an app binding it two-way
|
|
20
|
+
// would (`v-model`, React's controlled pattern). React and Vue are imported only when asked for, through the same adapters apps use
|
|
21
|
+
// (`@zubra/runtime/react`, `@zubra/runtime/vue`), so events reach the framework's own handlers,
|
|
22
|
+
// props are set the framework's way, and slots are the framework's children.
|
|
23
|
+
|
|
24
|
+
const ALL = ['html', 'react', 'vue'];
|
|
25
|
+
|
|
26
|
+
/** The frameworks that can be mounted here: plain HTML always, React and Vue when installed. */
|
|
27
|
+
export async function frameworks() {
|
|
28
|
+
const found = ['html'];
|
|
29
|
+
for (const name of ['react', 'vue']) {
|
|
30
|
+
try {
|
|
31
|
+
await load(name);
|
|
32
|
+
found.push(name);
|
|
33
|
+
} catch {
|
|
34
|
+
// not installed: skipped
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const loaded = {};
|
|
41
|
+
function load(framework) {
|
|
42
|
+
if (framework === 'react') {
|
|
43
|
+
loaded.react ??= Promise.all([import('react'), import('react-dom'), import('react-dom/client'), import('./react.js')])
|
|
44
|
+
.then(([React, ReactDOM, client, adapter]) => ({ React, flushSync: ReactDOM.flushSync, createRoot: client.createRoot, wrap: adapter.wrap }));
|
|
45
|
+
return loaded.react;
|
|
46
|
+
}
|
|
47
|
+
if (framework === 'vue') {
|
|
48
|
+
loaded.vue ??= Promise.all([import('vue'), import('./vue.js')]).then(([Vue, adapter]) => ({ Vue, wrap: adapter.wrap }));
|
|
49
|
+
return loaded.vue;
|
|
50
|
+
}
|
|
51
|
+
return Promise.resolve(null);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// What the element is doing once a change has landed: its own render, and the framework's.
|
|
55
|
+
const settle = async (element) => {
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve));
|
|
57
|
+
await element?.updateComplete;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Mount `Element` (a compiled component's default export) through `framework` (`html`, `react` or
|
|
62
|
+
* `vue`), with `props` and `slots` (`{ default: 'text', title: 'Heading' }`: text for each slot).
|
|
63
|
+
* Resolves once it has rendered, to:
|
|
64
|
+
* - `element`: the component's host element;
|
|
65
|
+
* - `events(name)`: the `detail` of each `name` event so far, as the framework's handler got it
|
|
66
|
+
* (`events()`: every event as `[name, detail]`);
|
|
67
|
+
* - `set(props)`: change props through the framework (merged with the current ones), and settle;
|
|
68
|
+
* - `unmount()`: remove it the framework's way.
|
|
69
|
+
*/
|
|
70
|
+
export async function mount(Element, { framework = 'html', props = {}, slots = {}, container } = {}) {
|
|
71
|
+
const meta = Element?.zubra;
|
|
72
|
+
if (!meta) throw new TypeError('zubra: mount() needs a compiled zubra element (the default export of a .zb module)');
|
|
73
|
+
if (!ALL.includes(framework)) throw new TypeError(`zubra: mount() knows ${ALL.join(', ')}, not "${framework}"`);
|
|
74
|
+
if (!customElements.get(meta.tag)) customElements.define(meta.tag, Element);
|
|
75
|
+
const host = container ?? document.body.appendChild(document.createElement('div'));
|
|
76
|
+
const log = [];
|
|
77
|
+
// Like an app that binds the component two-way (`v-model`, React's controlled pattern): a
|
|
78
|
+
// `<prop>change` event updates the prop the framework passes back, so its next render doesn't
|
|
79
|
+
// put the old value back.
|
|
80
|
+
const models = Object.fromEntries(Object.keys(meta.props).filter((name) => meta.events.includes(`${name.toLowerCase()}change`)).map((name) => [`${name.toLowerCase()}change`, name]));
|
|
81
|
+
let model = () => {};
|
|
82
|
+
const record = (name) => (event) => {
|
|
83
|
+
log.push([name, event.detail]);
|
|
84
|
+
if (models[name]) model(models[name], event.detail);
|
|
85
|
+
};
|
|
86
|
+
let current = { ...props };
|
|
87
|
+
let view;
|
|
88
|
+
|
|
89
|
+
if (framework === 'html') {
|
|
90
|
+
const element = document.createElement(meta.tag);
|
|
91
|
+
for (const [name, value] of Object.entries(current)) element[name] = value;
|
|
92
|
+
for (const name of meta.events) element.addEventListener(name, record(name));
|
|
93
|
+
for (const [name, text] of Object.entries(slots)) {
|
|
94
|
+
if (name === 'default') element.append(text);
|
|
95
|
+
else element.append(Object.assign(document.createElement('span'), { slot: name, textContent: text }));
|
|
96
|
+
}
|
|
97
|
+
host.append(element);
|
|
98
|
+
view = {
|
|
99
|
+
element,
|
|
100
|
+
set: async (next) => { Object.assign(current, next); for (const [name, value] of Object.entries(next)) element[name] = value; },
|
|
101
|
+
unmount: () => element.remove(),
|
|
102
|
+
};
|
|
103
|
+
} else if (framework === 'react') {
|
|
104
|
+
const { React, flushSync, createRoot, wrap } = await load('react');
|
|
105
|
+
const Component = wrap(Element);
|
|
106
|
+
const handlers = Object.fromEntries(meta.events.map((name) => [`on${name}`, record(name)]));
|
|
107
|
+
const children = Object.entries(slots).map(([name, text]) =>
|
|
108
|
+
name === 'default' ? text : React.createElement('span', { key: name, slot: name }, text));
|
|
109
|
+
let element = null;
|
|
110
|
+
const ref = (node) => { if (node) element = node; };
|
|
111
|
+
const root = createRoot(host);
|
|
112
|
+
const render = () => flushSync(() => root.render(React.createElement(Component, { ...current, ...handlers, ref }, ...children)));
|
|
113
|
+
model = (name, value) => { current[name] = value; queueMicrotask(render); };
|
|
114
|
+
render();
|
|
115
|
+
view = {
|
|
116
|
+
get element() { return element; },
|
|
117
|
+
set: async (next) => { Object.assign(current, next); render(); },
|
|
118
|
+
unmount: () => root.unmount(),
|
|
119
|
+
};
|
|
120
|
+
} else {
|
|
121
|
+
const { Vue, wrap } = await load('vue');
|
|
122
|
+
const Component = wrap(Element);
|
|
123
|
+
const state = Vue.reactive({ ...current });
|
|
124
|
+
model = (name, value) => { current[name] = value; state[name] = value; };
|
|
125
|
+
const handlers = Object.fromEntries(meta.events.map((name) => [`on${name[0].toUpperCase()}${name.slice(1)}`, record(name)]));
|
|
126
|
+
const slotFunctions = Object.fromEntries(Object.entries(slots).map(([name, text]) => [name, () => (name === 'default' ? text : Vue.h('span', text))]));
|
|
127
|
+
const component = Vue.ref(null);
|
|
128
|
+
const app = Vue.createApp({ render: () => Vue.h(Component, { ...state, ...handlers, ref: component }, slotFunctions) });
|
|
129
|
+
app.mount(host);
|
|
130
|
+
view = {
|
|
131
|
+
get element() { return component.value?.element ?? null; },
|
|
132
|
+
set: async (next) => { Object.assign(current, next); Object.assign(state, next); await Vue.nextTick(); },
|
|
133
|
+
unmount: () => app.unmount(),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await settle(view.element);
|
|
138
|
+
const element = () => view.element;
|
|
139
|
+
return {
|
|
140
|
+
framework,
|
|
141
|
+
host,
|
|
142
|
+
get element() { return element(); },
|
|
143
|
+
events: (name) => (name === undefined ? log.slice() : log.filter(([event]) => event === name).map(([, detail]) => detail)),
|
|
144
|
+
async set(next) {
|
|
145
|
+
await view.set(next);
|
|
146
|
+
await settle(element());
|
|
147
|
+
},
|
|
148
|
+
unmount() {
|
|
149
|
+
view.unmount();
|
|
150
|
+
if (!container) host.remove();
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
package/src/track.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Reads after an `await` in a resource's fetcher: `resource(tracked(fetcher))`.
|
|
2
|
+
//
|
|
3
|
+
// const list = resource(tracked(async ({ signal, track }) => {
|
|
4
|
+
// const token = await auth(signal)
|
|
5
|
+
// return load(token, track(() => page.get()), signal)
|
|
6
|
+
// }))
|
|
7
|
+
//
|
|
8
|
+
// The Signals API tracks a read only while a Computed is running. A resource's fetcher runs
|
|
9
|
+
// inside its effect until its first `await`; the code after that runs later, outside it, so its
|
|
10
|
+
// reads are not followed. `tracked()` gives each run `track(read)`: it reads, and when what
|
|
11
|
+
// `read` returns changes, the resource runs the fetcher again, as for a read before the
|
|
12
|
+
// `await`. The compiler writes `tracked()` and the `track()` calls for an async fetcher in a
|
|
13
|
+
// `.zb` script, so this module is loaded only by components that have one.
|
|
14
|
+
//
|
|
15
|
+
// A run that was replaced or has settled, or a resource that was disposed, still reads through
|
|
16
|
+
// `track()`, but no longer subscribes. The reads are watched by an effect owned where `tracked()` was called (a
|
|
17
|
+
// component's setup), so a detached element holds none of them and catches up when it returns.
|
|
18
|
+
import { Signal } from './signal.js';
|
|
19
|
+
import { effect } from './effect.js';
|
|
20
|
+
|
|
21
|
+
const { untrack, currentComputed } = Signal.subtle;
|
|
22
|
+
const THREW = Symbol('threw');
|
|
23
|
+
|
|
24
|
+
function readOnce(computed) {
|
|
25
|
+
try { return computed.get(); } catch { return THREW; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Wraps a resource's fetcher so it is also given `track(read)`, for reads after an `await`. */
|
|
29
|
+
export function tracked(fetcher) {
|
|
30
|
+
if (typeof fetcher !== 'function') throw new TypeError('zubra: tracked() takes a function');
|
|
31
|
+
// Read by each run before it can wait, so the resource's effect runs again when it changes.
|
|
32
|
+
const again = new Signal.State(0);
|
|
33
|
+
// The run whose later reads are watched: set when it makes one, and when it settles.
|
|
34
|
+
const tail = new Signal.State(null);
|
|
35
|
+
let latest = null;
|
|
36
|
+
|
|
37
|
+
// When a read changes, run again. A new read is appended (and seen) without re-running.
|
|
38
|
+
const stop = effect(() => {
|
|
39
|
+
const watched = tail.get();
|
|
40
|
+
if (!watched) return;
|
|
41
|
+
let changed = false;
|
|
42
|
+
for (const read of watched.run.reads) {
|
|
43
|
+
if (!Object.is(readOnce(read.computed), read.seen)) changed = true; // read each, to keep watching it
|
|
44
|
+
}
|
|
45
|
+
if (changed && watched.run === latest) untrack(() => again.set(again.get() + 1));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return (context) => {
|
|
49
|
+
again.get();
|
|
50
|
+
const { signal } = context;
|
|
51
|
+
const run = { reads: [] };
|
|
52
|
+
latest = run;
|
|
53
|
+
// The resource aborts a run when the next one starts, or when it is disposed: then no run
|
|
54
|
+
// follows, and nothing is watched any more.
|
|
55
|
+
signal?.addEventListener('abort', () => queueMicrotask(() => { if (latest === run) stop(); }), { once: true });
|
|
56
|
+
const track = (read) => {
|
|
57
|
+
if (typeof read !== 'function') throw new TypeError('zubra: track() takes a function that reads signals');
|
|
58
|
+
// Before the first `await` the resource's effect is running, and tracks the read itself.
|
|
59
|
+
if (currentComputed()) return read();
|
|
60
|
+
// A replaced or finished run (a timer the fetcher left), or a disposed resource: its value
|
|
61
|
+
// can't depend on the read any more, so read without subscribing.
|
|
62
|
+
if (run !== latest || run.done || signal?.aborted) return untrack(read);
|
|
63
|
+
const computed = new Signal.Computed(read);
|
|
64
|
+
run.reads.push({ computed, seen: readOnce(computed) });
|
|
65
|
+
tail.set({ run });
|
|
66
|
+
return computed.get(); // rethrows what `read` threw
|
|
67
|
+
};
|
|
68
|
+
const settled = () => {
|
|
69
|
+
run.done = true;
|
|
70
|
+
// The newest run has finished: stop watching an older run's later reads.
|
|
71
|
+
if (run === latest && untrack(() => tail.get())?.run !== run) tail.set(run.reads.length ? { run } : null);
|
|
72
|
+
};
|
|
73
|
+
const result = fetcher({ ...context, track });
|
|
74
|
+
Promise.resolve(result).then(settled, settled);
|
|
75
|
+
return result;
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/vue.js
CHANGED
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
// `v-model:<prop>`, and `v-model` binds the `value` pair (or the only pair, if there's one).
|
|
13
13
|
// - Other attributes and listeners (class, style, @click, …) go to the element; slots become its
|
|
14
14
|
// children, named slots with `slot="name"`.
|
|
15
|
-
import { defineComponent, h, withDirectives, Text, Comment, Fragment } from 'vue';
|
|
15
|
+
import { defineComponent, h, withDirectives, Text, Comment, Fragment, customRef, getCurrentScope, onScopeDispose } from 'vue';
|
|
16
16
|
import { serverContent } from './server.js';
|
|
17
|
+
import { routeLinks } from './navigation.js';
|
|
18
|
+
import './context.js';
|
|
19
|
+
import { subscribe, read, writable } from './bridge.js';
|
|
17
20
|
|
|
18
21
|
const clone = (value) => (value && typeof value === 'object' ? structuredClone(value) : value);
|
|
19
22
|
const pascal = (tag) => tag.split(/[-.]/).filter(Boolean).map((part) => part[0].toUpperCase() + part.slice(1)).join('');
|
|
@@ -123,3 +126,66 @@ export function wrap(Element) {
|
|
|
123
126
|
},
|
|
124
127
|
});
|
|
125
128
|
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Route links inside Zubra components through Vue Router (or any `push`) until the current effect
|
|
132
|
+
* scope ends: `useRouteLinks((to) => router.push(to))` in a component's `setup`. Returns the
|
|
133
|
+
* function that stops it.
|
|
134
|
+
*/
|
|
135
|
+
export function useRouteLinks(navigate, options) {
|
|
136
|
+
const stop = routeLinks(navigate, options);
|
|
137
|
+
if (getCurrentScope()) onScopeDispose(stop);
|
|
138
|
+
return stop;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Provide a Context Protocol value to every element inside: an app's theme, locale or current user,
|
|
143
|
+
* read by components with `inject(context)` (or Lit's `@consume`).
|
|
144
|
+
*
|
|
145
|
+
* <ZubraContext :context="theme" :value="dark ? 'dark' : 'light'">…</ZubraContext>
|
|
146
|
+
*
|
|
147
|
+
* `value` can also be a signal. It renders a `<zb-context>` element, which is `display: contents`.
|
|
148
|
+
*/
|
|
149
|
+
export const ZubraContext = defineComponent({
|
|
150
|
+
name: 'ZubraContext',
|
|
151
|
+
props: { context: { default: undefined }, value: { default: undefined } },
|
|
152
|
+
setup(given, { slots }) {
|
|
153
|
+
// Set as DOM properties when the element is created, before its children are inserted.
|
|
154
|
+
return () => h('zb-context', { '.context': given.context, '.value': given.value, style: 'display: contents' }, slots.default?.());
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A Zubra signal (or a function of signals) as a Vue ref: reading `.value` tracks it in
|
|
160
|
+
* templates, computeds and watchers, and assigning `.value` writes the signal when it's writable
|
|
161
|
+
* (a `Signal.State`, `state()`, `from()`), so `v-model="cart"` works on one.
|
|
162
|
+
*
|
|
163
|
+
* const items = useSignal(cart, { deep: true })
|
|
164
|
+
*
|
|
165
|
+
* The subscription ends with the effect scope it was created in (the component's `setup`); outside
|
|
166
|
+
* one, call `stop()` on the ref when done. With `deep: true` a mutation inside an array or object
|
|
167
|
+
* triggers too.
|
|
168
|
+
*/
|
|
169
|
+
export function useSignal(source, { deep = false } = {}) {
|
|
170
|
+
let stop = () => {};
|
|
171
|
+
const ref = customRef((track, trigger) => {
|
|
172
|
+
let first = true;
|
|
173
|
+
stop = subscribe(source, () => {
|
|
174
|
+
if (first) first = false;
|
|
175
|
+
else trigger();
|
|
176
|
+
}, { deep });
|
|
177
|
+
return {
|
|
178
|
+
get() {
|
|
179
|
+
track();
|
|
180
|
+
return read(source);
|
|
181
|
+
},
|
|
182
|
+
set(value) {
|
|
183
|
+
if (!writable(source)) throw new TypeError('zubra: useSignal(): this signal is read-only (a computed); write the state it reads');
|
|
184
|
+
source.set(value);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
Object.defineProperty(ref, 'stop', { value: () => stop() });
|
|
189
|
+
if (getCurrentScope()) onScopeDispose(() => stop());
|
|
190
|
+
return ref;
|
|
191
|
+
}
|