@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
package/README.md
CHANGED
|
@@ -13,9 +13,14 @@ ships one, this package deletes code instead of components being rewritten.
|
|
|
13
13
|
| `src/element.js` hooks | Plain functions (see the project's hooks decision) | `state`, `from`, `computed`, `watch`, `onMount`, `onUnmount`, `id`, `props`, `host`, `emit`, `hydrated` |
|
|
14
14
|
| `src/element.js` slots | Light-DOM slotting with the host's child methods routed to the slots, so frameworks can keep managing the children; native `<slot>` in shadow DOM | Done |
|
|
15
15
|
| `src/react.js`, `src/vue.js` | Adapters: an element as a React (18, 19) or Vue 3 component | Done |
|
|
16
|
+
| `src/testing.js` | `mount(Element, { framework })`: a component through plain HTML, the React adapter or the Vue adapter, for one test per framework in any browser test runner | Done |
|
|
17
|
+
| `src/navigation.js` | Links inside components through the app's router: `routeLinks(navigate)`; `useRouteLinks` in the React and Vue adapters. Not a router | Done |
|
|
18
|
+
| `src/forms.js` | A control as a form library's field: React Hook Form (`registerProps`, `fieldProps`), TanStack Form (`fieldProps`), VeeValidate (`vueFieldProps`), Felte (`felteControl`) | Done |
|
|
19
|
+
| `src/bridge.js` | Signals read from framework code: `subscribe`, and `toStore` (the Svelte store contract and an RxJS-style Subscribable); React and Vue `useSignal` | Done |
|
|
16
20
|
| `types/` | TypeScript declarations for every entry point | Done |
|
|
17
21
|
| `src/element.js` forms | Form-associated custom elements (`ElementInternals`) | `formValue`, `validity`, `onFormReset`, `onFormRestore`, `onFormDisabled`; native control API on the element |
|
|
18
22
|
| `src/element.js` context | [Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md) (`context-request` / `context-provider` events) | `createContext`, `provide`, `inject`; interoperates with any protocol implementation |
|
|
23
|
+
| `src/context.js` | The same protocol, provided from the app: `<zb-context>` with `context` and `value` properties; `ZubraContext` in the React and Vue adapters | Done |
|
|
19
24
|
|
|
20
25
|
## The production build
|
|
21
26
|
|
|
@@ -82,6 +87,25 @@ await tick();
|
|
|
82
87
|
A write to a signal an effect has already read in the same run does not re-run it (same as a
|
|
83
88
|
computed in the reference). Derive the value instead of writing it back.
|
|
84
89
|
|
|
90
|
+
## Signals in framework code
|
|
91
|
+
|
|
92
|
+
A signal elements read can be read by the app too, so state is shared rather than copied in by
|
|
93
|
+
props and back out by events (`@zubra/runtime/bridge`):
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
import { state } from '@zubra/runtime';
|
|
97
|
+
export const cart = state([]); // cart-state.js, imported by elements too
|
|
98
|
+
|
|
99
|
+
// React // Vue
|
|
100
|
+
const items = useSignal(cart, { deep: true }); const items = useSignal(cart, { deep: true }); // a ref
|
|
101
|
+
// Svelte: const items = toStore(cart, { deep: true }), then {$items.length}
|
|
102
|
+
// Solid: from(toStore(cart)) Angular: toSignal(toStore(cart), { requireSync: true })
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`subscribe` and the stores are effects owned by nothing: they last until unsubscribed (Vue's ref
|
|
106
|
+
stops with the scope it was made in; React's hook with the component). `deep: true` reads through
|
|
107
|
+
an array or plain object, so a mutation inside it notifies.
|
|
108
|
+
|
|
85
109
|
## Tests
|
|
86
110
|
|
|
87
111
|
```sh
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Generated by scripts/build-production.js from src/bridge.js: the runtime without hot reloading or the DevTools hook. Do not edit.
|
|
2
|
+
// Signal bridges: read (and write) a Zubra signal from a framework's own code.
|
|
3
|
+
//
|
|
4
|
+
// import { shared } from './cart-state.js' // export const shared = state([])
|
|
5
|
+
// import { toStore } from '@zubra/runtime/bridge'
|
|
6
|
+
// const cart = toStore(shared, { deep: true })
|
|
7
|
+
//
|
|
8
|
+
// Svelte: {$cart.length} (the store contract)
|
|
9
|
+
// Solid: const items = from(cart) (solid-js `from`)
|
|
10
|
+
// Angular: items = toSignal(cart, { requireSync: true }) (a Subscribable)
|
|
11
|
+
// RxJS: from(cart) (Symbol.observable)
|
|
12
|
+
// React: useSignal(shared) from '@zubra/runtime/react'
|
|
13
|
+
// Vue: useSignal(shared) from '@zubra/runtime/vue'
|
|
14
|
+
//
|
|
15
|
+
// The adapters carry props, events and two-way bindings across the element boundary; this
|
|
16
|
+
// carries state: a signal that elements and the app's code both read, without copying it into
|
|
17
|
+
// the framework's state and back. Any `Signal.State` or `Signal.Computed` works (`state()`,
|
|
18
|
+
// `computed()`, `from()`, a `@zubra/sync` field, a native `Signal` once browsers ship one), as
|
|
19
|
+
// does a function that reads signals (it's tracked like a computed).
|
|
20
|
+
//
|
|
21
|
+
// `deep: true` also tracks what's inside an array or plain object, so `items.push(x)` notifies
|
|
22
|
+
// as `items = [...items, x]` does. Without it a mutation inside the value isn't a change.
|
|
23
|
+
import { Signal } from './signal.js';
|
|
24
|
+
import { effect, root } from './effect.js';
|
|
25
|
+
|
|
26
|
+
const isSignal = (value) => value instanceof Signal.State || value instanceof Signal.Computed;
|
|
27
|
+
|
|
28
|
+
function check(source, name) {
|
|
29
|
+
if (typeof source !== 'function' && !isSignal(source)) {
|
|
30
|
+
throw new TypeError(`zubra: ${name}() needs a signal (state(), computed(), a Signal.State or Signal.Computed) or a function that reads signals`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The source's current value: a signal's `get()`, or the function's result. */
|
|
35
|
+
export const read = (source) => (typeof source === 'function' ? source() : source.get());
|
|
36
|
+
|
|
37
|
+
/** Whether the source can be written: a `Signal.State`, or a signal with its own `set` (`from()`). */
|
|
38
|
+
export const writable = (source) => typeof source?.set === 'function';
|
|
39
|
+
|
|
40
|
+
// Read every property of an array or plain object (through state's proxy, each read is tracked),
|
|
41
|
+
// so a change anywhere inside notifies.
|
|
42
|
+
function touch(value, seen = new Set()) {
|
|
43
|
+
if (value === null || typeof value !== 'object' || seen.has(value)) return;
|
|
44
|
+
if (!Array.isArray(value)) {
|
|
45
|
+
const proto = Object.getPrototypeOf(value);
|
|
46
|
+
if (proto !== Object.prototype && proto !== null) return;
|
|
47
|
+
}
|
|
48
|
+
seen.add(value);
|
|
49
|
+
if (Array.isArray(value)) for (let index = 0; index < value.length; index++) touch(value[index], seen);
|
|
50
|
+
else for (const key of Object.keys(value)) touch(value[key], seen);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Call `callback(value)` now and whenever `source` changes, until the returned function is
|
|
55
|
+
* called. Changes are batched like the runtime's own effects: one call per microtask, with the
|
|
56
|
+
* latest value. Not owned by any component, so it lives until it's unsubscribed.
|
|
57
|
+
*/
|
|
58
|
+
export function subscribe(source, callback, { deep = false } = {}) {
|
|
59
|
+
check(source, 'subscribe');
|
|
60
|
+
const owner = root(() => {
|
|
61
|
+
effect(() => {
|
|
62
|
+
const value = read(source);
|
|
63
|
+
if (deep) touch(value);
|
|
64
|
+
Signal.subtle.untrack(() => callback(value));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
return () => owner.dispose();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The source as a store: the Svelte store contract (`subscribe(run)` calls `run` now and on each
|
|
72
|
+
* change, returning the unsubscribe function), which is also an RxJS-style Subscribable
|
|
73
|
+
* (`subscribe({ next })` returns something with `unsubscribe()`), so Angular's `toSignal`, Solid's
|
|
74
|
+
* `from` and RxJS's `from` take it as it is. `set` and `update` are there when the source is
|
|
75
|
+
* writable (a Svelte writable store, so `bind:value={$store}` works).
|
|
76
|
+
*/
|
|
77
|
+
export function toStore(source, options = {}) {
|
|
78
|
+
check(source, 'toStore');
|
|
79
|
+
const store = {
|
|
80
|
+
get: () => read(source),
|
|
81
|
+
subscribe(run) {
|
|
82
|
+
const next = typeof run === 'function' ? run : (value) => run?.next?.(value);
|
|
83
|
+
const stop = subscribe(source, next, options);
|
|
84
|
+
const unsubscribe = () => stop();
|
|
85
|
+
unsubscribe.unsubscribe = unsubscribe;
|
|
86
|
+
return unsubscribe;
|
|
87
|
+
},
|
|
88
|
+
[Symbol.observable ?? '@@observable']() {
|
|
89
|
+
return this;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
if (writable(source)) {
|
|
93
|
+
store.set = (value) => source.set(value);
|
|
94
|
+
store.update = (fn) => source.set(fn(Signal.subtle.untrack(() => read(source))));
|
|
95
|
+
}
|
|
96
|
+
return store;
|
|
97
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Generated by scripts/build-production.js from src/context.js: the runtime without hot reloading or the DevTools hook. Do not edit.
|
|
2
|
+
// Context from the app: a `<zb-context>` element that answers the Context Protocol for the
|
|
3
|
+
// elements inside it, so an app's theme, locale or current user reaches every component without
|
|
4
|
+
// being passed down as props.
|
|
5
|
+
//
|
|
6
|
+
// <zb-context .context=${theme} .value=${'dark'}> …elements that inject(theme)… </zb-context>
|
|
7
|
+
//
|
|
8
|
+
// It's a plain custom element with two properties, so every framework can use it as it is:
|
|
9
|
+
// Svelte `<zb-context {context} {value}>`, Solid `prop:context`, Angular `[context]`, Lit
|
|
10
|
+
// `.context`, plain HTML `el.value = …`. React and Vue have `ZubraContext` components
|
|
11
|
+
// (`@zubra/runtime/react`, `@zubra/runtime/vue`) that wrap it. The element is `display:
|
|
12
|
+
// contents`, so it adds no box to the layout.
|
|
13
|
+
//
|
|
14
|
+
// The value can be a plain value or a signal; subscribers hear each change either way. Any
|
|
15
|
+
// Context Protocol consumer is answered: Zubra's `inject()`, Lit's `@consume`, and so on. A
|
|
16
|
+
// consumer that asked before the element was given its context asks again when the element
|
|
17
|
+
// announces it with a `context-provider` event (children connect after their parent, so one
|
|
18
|
+
// given its context before it's in the document is there when they first ask).
|
|
19
|
+
import { Signal } from './signal.js';
|
|
20
|
+
import { effect, root } from './effect.js';
|
|
21
|
+
import { ContextProviderEvent } from './element.js';
|
|
22
|
+
|
|
23
|
+
const isSignal = (value) => value instanceof Signal.State || value instanceof Signal.Computed;
|
|
24
|
+
const unwrap = (value) => (isSignal(value) ? value.get() : value);
|
|
25
|
+
|
|
26
|
+
const Base = globalThis.HTMLElement ?? class {};
|
|
27
|
+
|
|
28
|
+
export class ZubraContextElement extends Base {
|
|
29
|
+
#context = undefined;
|
|
30
|
+
#value = undefined;
|
|
31
|
+
#subscribers = new Set();
|
|
32
|
+
#watching = null; // the effect following a signal value
|
|
33
|
+
|
|
34
|
+
constructor() {
|
|
35
|
+
super();
|
|
36
|
+
this.addEventListener('context-request', (event) => {
|
|
37
|
+
if (this.#context === undefined || event.context !== this.#context || event.contextTarget === this) return;
|
|
38
|
+
event.stopPropagation();
|
|
39
|
+
const current = Signal.subtle.untrack(() => unwrap(this.#value));
|
|
40
|
+
if (!event.subscribe) return event.callback(current);
|
|
41
|
+
const subscriber = { callback: event.callback };
|
|
42
|
+
subscriber.unsubscribe = () => this.#subscribers.delete(subscriber);
|
|
43
|
+
this.#subscribers.add(subscriber);
|
|
44
|
+
event.callback(current, subscriber.unsubscribe);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The context key this element provides (from `createContext()`, or any protocol key). */
|
|
49
|
+
get context() {
|
|
50
|
+
return this.#context;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
set context(key) {
|
|
54
|
+
if (key === this.#context) return;
|
|
55
|
+
// Consumers of the old key keep what they have, but stop hearing from this element.
|
|
56
|
+
this.#subscribers.clear();
|
|
57
|
+
this.#context = key;
|
|
58
|
+
this.#announce();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The value, or a signal holding it. */
|
|
62
|
+
get value() {
|
|
63
|
+
return this.#value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
set value(next) {
|
|
67
|
+
if (Object.is(next, this.#value)) return;
|
|
68
|
+
this.#value = next;
|
|
69
|
+
this.#watching?.dispose();
|
|
70
|
+
this.#watching = null;
|
|
71
|
+
if (isSignal(next)) {
|
|
72
|
+
let first = true;
|
|
73
|
+
this.#watching = root(() => effect(() => {
|
|
74
|
+
const value = next.get();
|
|
75
|
+
if (first) first = false;
|
|
76
|
+
else Signal.subtle.untrack(() => this.#notify(value));
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
this.#notify(Signal.subtle.untrack(() => unwrap(next)));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
connectedCallback() {
|
|
83
|
+
if (!this.style.display) this.style.display = 'contents';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#notify(value) {
|
|
87
|
+
for (const subscriber of [...this.#subscribers]) subscriber.callback(value, subscriber.unsubscribe);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#announce() {
|
|
91
|
+
if (this.#context !== undefined && this.isConnected) this.dispatchEvent(new ContextProviderEvent(this.#context, this));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Define `<zb-context>` (once; a no-op where there's no `customElements`, as on a server). */
|
|
96
|
+
export function defineContextElement() {
|
|
97
|
+
const registry = globalThis.customElements;
|
|
98
|
+
if (registry && !registry.get('zb-context')) registry.define('zb-context', ZubraContextElement);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
defineContextElement();
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
// scope(fn) an ownership root: effects created inside belong to it
|
|
10
10
|
// flush() / tick() apply pending re-runs now / await them
|
|
11
11
|
//
|
|
12
|
+
// Disposal: effect()'s dispose function and every scope are also disposables
|
|
13
|
+
// (`[Symbol.dispose]`), so `using` and `DisposableStack` can own them where the browser has
|
|
14
|
+
// Explicit Resource Management. Nothing here depends on it. Pausing is not disposal: a paused
|
|
15
|
+
// scope resumes; only dispose() (or Symbol.dispose) ends one.
|
|
16
|
+
//
|
|
12
17
|
// Ownership: an effect created while another effect (or a scope) is running belongs to it. When
|
|
13
18
|
// the owner re-runs or is disposed, everything it owns is disposed first, so nothing outlives
|
|
14
19
|
// the code that created it.
|
|
@@ -29,6 +34,12 @@ let scheduled = null; // promise for the pending microtask flush
|
|
|
29
34
|
let flushing = false;
|
|
30
35
|
const effectOf = new WeakMap();
|
|
31
36
|
|
|
37
|
+
/**
|
|
38
|
+
* `Symbol.dispose`, or, where the engine doesn't have it yet, the symbol TypeScript's downlevel
|
|
39
|
+
* `using` and the polyfills look up (`Symbol.for('Symbol.dispose')`).
|
|
40
|
+
*/
|
|
41
|
+
export const DISPOSE = Symbol.dispose ?? Symbol.for('Symbol.dispose');
|
|
42
|
+
|
|
32
43
|
|
|
33
44
|
const watcher = new Signal.subtle.Watcher(() => {
|
|
34
45
|
scheduled ??= new Promise((resolve) => queueMicrotask(() => {
|
|
@@ -93,6 +104,10 @@ class Owner {
|
|
|
93
104
|
this.parent?.children.delete(this);
|
|
94
105
|
}
|
|
95
106
|
|
|
107
|
+
[DISPOSE]() {
|
|
108
|
+
this.dispose();
|
|
109
|
+
}
|
|
110
|
+
|
|
96
111
|
pause() {
|
|
97
112
|
if (this.paused || this.disposed) return;
|
|
98
113
|
this.paused = true;
|
|
@@ -183,7 +198,9 @@ class Scope extends Owner {
|
|
|
183
198
|
export function effect(fn) {
|
|
184
199
|
const e = new Effect(fn, owner);
|
|
185
200
|
e.run(); // if this throws, the effect stays registered and re-runs when its dependencies change
|
|
186
|
-
|
|
201
|
+
const dispose = () => e.dispose();
|
|
202
|
+
dispose[DISPOSE] = dispose;
|
|
203
|
+
return dispose;
|
|
187
204
|
}
|
|
188
205
|
|
|
189
206
|
/**
|
|
@@ -60,6 +60,15 @@ const clone = (value) => (value && typeof value === 'object' ? structuredClone(v
|
|
|
60
60
|
/** Attribute text → prop value, typed by the prop's default (JSON for arrays and objects). */
|
|
61
61
|
function parseAttribute(spec, raw) {
|
|
62
62
|
const fallback = spec.default;
|
|
63
|
+
// An enum prop (`<script lang="ts">`): the member whose value the text is, or the member it names.
|
|
64
|
+
if (spec.enum) {
|
|
65
|
+
if (raw === null) return clone(fallback);
|
|
66
|
+
const found = Object.values(spec.enum).find((value) => String(value) === raw);
|
|
67
|
+
if (found !== undefined) return found;
|
|
68
|
+
if (Object.hasOwn(spec.enum, raw)) return spec.enum[raw];
|
|
69
|
+
console.warn(`zubra: attribute "${spec.attribute}" is "${raw}", not one of ${Object.keys(spec.enum).join(', ')}`);
|
|
70
|
+
return clone(fallback);
|
|
71
|
+
}
|
|
63
72
|
if (typeof fallback === 'boolean') return fallback ? raw !== 'false' : raw !== null;
|
|
64
73
|
if (raw === null) return clone(fallback);
|
|
65
74
|
if (typeof fallback === 'number') return Number(raw);
|
|
@@ -187,12 +196,13 @@ export function emit(name) {
|
|
|
187
196
|
|
|
188
197
|
/**
|
|
189
198
|
* An id unique to this element and call, for `id`/`for`/`aria-*` attributes. Stable for the
|
|
190
|
-
* element's life
|
|
191
|
-
* server-rendered markup.
|
|
199
|
+
* element's life. Based on the host's own `id` when it has one, else on the base the server
|
|
200
|
+
* rendered it with (`data-zb-id`), so the ids in server-rendered markup stay as they are.
|
|
192
201
|
*/
|
|
193
202
|
export function id() {
|
|
194
203
|
const component = hooks('id');
|
|
195
|
-
const
|
|
204
|
+
const host = component.host;
|
|
205
|
+
const base = host.id || host.getAttribute('data-zb-id') || `${host.localName}-${component.instance}`;
|
|
196
206
|
return `${base}-${++component.ids}`;
|
|
197
207
|
}
|
|
198
208
|
|
|
@@ -326,7 +336,18 @@ export function provide(context, value) {
|
|
|
326
336
|
* key as `context`.
|
|
327
337
|
*/
|
|
328
338
|
export function inject(context, fallback = context?.defaultValue) {
|
|
329
|
-
const
|
|
339
|
+
const component = hooks('inject');
|
|
340
|
+
const host = component.host;
|
|
341
|
+
// The value the server rendered with (`data-zb-inject`, by call number) is where it starts,
|
|
342
|
+
// so hydrating doesn't show the default until the provider answers.
|
|
343
|
+
const number = (component.injects = (component.injects ?? 0) + 1);
|
|
344
|
+
const rendered = host.getAttribute('data-zb-inject');
|
|
345
|
+
if (rendered) {
|
|
346
|
+
try {
|
|
347
|
+
const values = JSON.parse(rendered);
|
|
348
|
+
if (Object.hasOwn(values, number)) fallback = values[number];
|
|
349
|
+
} catch { /* not ours: keep the default */ }
|
|
350
|
+
}
|
|
330
351
|
const value = new Signal.State(fallback);
|
|
331
352
|
let unsubscribe = null;
|
|
332
353
|
let answered = false;
|
|
@@ -537,8 +558,27 @@ function departBetween(open, close, motion) {
|
|
|
537
558
|
}
|
|
538
559
|
|
|
539
560
|
/** Remove everything between `open` and `close` in one operation. */
|
|
561
|
+
const nativeTextContent = typeof Node !== 'undefined' ? Object.getOwnPropertyDescriptor(Node.prototype, 'textContent').set : undefined;
|
|
540
562
|
function deleteBetween(open, close) {
|
|
541
563
|
if (open.nextSibling === close) return;
|
|
564
|
+
// The region is everything its parent holds but blank text (a list's own <tbody> or <ul>):
|
|
565
|
+
// emptying the parent and putting the markers and that text back is several times cheaper than
|
|
566
|
+
// deleting a range's contents.
|
|
567
|
+
const parent = open.parentNode;
|
|
568
|
+
const before = [];
|
|
569
|
+
const after = [];
|
|
570
|
+
const blank = (node) => node.nodeType === TEXT && node.data.trim() === '';
|
|
571
|
+
let edge = open.previousSibling;
|
|
572
|
+
while (edge && blank(edge) && before.length < 4) { before.unshift(edge); edge = edge.previousSibling; }
|
|
573
|
+
if (parent && !edge) {
|
|
574
|
+
edge = close.nextSibling;
|
|
575
|
+
while (edge && blank(edge) && after.length < 4) { after.push(edge); edge = edge.nextSibling; }
|
|
576
|
+
if (!edge) {
|
|
577
|
+
nativeTextContent.call(parent, '');
|
|
578
|
+
for (const node of [...before, open, close, ...after]) Node.prototype.insertBefore.call(parent, node, null);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
542
582
|
const range = document.createRange();
|
|
543
583
|
range.setStartAfter(open);
|
|
544
584
|
range.setEndBefore(close);
|
|
@@ -1365,6 +1405,7 @@ function bindEach(part, open, hydrating, context) {
|
|
|
1365
1405
|
}
|
|
1366
1406
|
// `animate`: the rows that stay glide from where they were (@zubra/runtime/motion).
|
|
1367
1407
|
const reorder = () => {
|
|
1408
|
+
const was = new Map(rows.map((row, index) => [row, index]));
|
|
1368
1409
|
const next = list.map((value, index) => {
|
|
1369
1410
|
const row = byKey.get(keys[index]);
|
|
1370
1411
|
if (!row) return createRow(value, index, keys[index]);
|
|
@@ -1374,17 +1415,14 @@ function bindEach(part, open, hydrating, context) {
|
|
|
1374
1415
|
return row;
|
|
1375
1416
|
});
|
|
1376
1417
|
batch(() => { for (const row of byKey.values()) removeRow(row); });
|
|
1377
|
-
//
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
continue;
|
|
1386
|
-
}
|
|
1387
|
-
placeRow(row, at ?? close);
|
|
1418
|
+
// The longest run of rows still in their old order stays put; every other row (and every
|
|
1419
|
+
// new one) goes before the row after it, from the end. A swap moves two rows, not the
|
|
1420
|
+
// rows between them.
|
|
1421
|
+
const stay = inOrder(next.map((row) => was.get(row) ?? -1));
|
|
1422
|
+
let before = close;
|
|
1423
|
+
for (let index = next.length - 1; index >= 0; index--) {
|
|
1424
|
+
if (!stay.has(index)) placeRow(next[index], before);
|
|
1425
|
+
before = next[index].open;
|
|
1388
1426
|
}
|
|
1389
1427
|
rows = next;
|
|
1390
1428
|
};
|
|
@@ -1394,6 +1432,31 @@ function bindEach(part, open, hydrating, context) {
|
|
|
1394
1432
|
});
|
|
1395
1433
|
}
|
|
1396
1434
|
|
|
1435
|
+
/**
|
|
1436
|
+
* The indexes of a longest strictly increasing run in `sequence` (negative entries never count):
|
|
1437
|
+
* for a keyed list, which rows are already in order and needn't move. O(n log n).
|
|
1438
|
+
*/
|
|
1439
|
+
function inOrder(sequence) {
|
|
1440
|
+
const tails = []; // tails[length - 1]: the index ending the best run of that length so far
|
|
1441
|
+
const previous = new Array(sequence.length);
|
|
1442
|
+
for (let index = 0; index < sequence.length; index++) {
|
|
1443
|
+
const value = sequence[index];
|
|
1444
|
+
if (value < 0) continue;
|
|
1445
|
+
let low = 0;
|
|
1446
|
+
let high = tails.length;
|
|
1447
|
+
while (low < high) {
|
|
1448
|
+
const middle = (low + high) >> 1;
|
|
1449
|
+
if (sequence[tails[middle]] < value) low = middle + 1;
|
|
1450
|
+
else high = middle;
|
|
1451
|
+
}
|
|
1452
|
+
previous[index] = low > 0 ? tails[low - 1] : -1;
|
|
1453
|
+
tails[low] = index;
|
|
1454
|
+
}
|
|
1455
|
+
const kept = new Set();
|
|
1456
|
+
for (let index = tails.length ? tails[tails.length - 1] : -1; index >= 0; index = previous[index]) kept.add(index);
|
|
1457
|
+
return kept;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1397
1460
|
const hasContent = (root) =>
|
|
1398
1461
|
[...root.childNodes].some((node) => node.nodeType !== TEXT || node.data.trim() !== '');
|
|
1399
1462
|
|
|
@@ -1523,6 +1586,18 @@ function defineElement(tag, definition) {
|
|
|
1523
1586
|
let templateSignature = signature(childRange(componentTemplate.content));
|
|
1524
1587
|
let instances = 0;
|
|
1525
1588
|
const byAttribute = Object.fromEntries(Object.entries(props).map(([name, spec]) => [spec.attribute, name]));
|
|
1589
|
+
// HTML lowercases attribute names, so `maxValue` in a page arrives as `maxvalue`, which isn't
|
|
1590
|
+
// the prop's attribute (`max-value`): the element would ignore it without a word.
|
|
1591
|
+
const lowercased = Object.entries(props)
|
|
1592
|
+
.filter(([name, spec]) => name !== name.toLowerCase() && spec.attribute !== name.toLowerCase())
|
|
1593
|
+
.map(([name, spec]) => [name.toLowerCase(), name, spec.attribute]);
|
|
1594
|
+
const checkAttributeCase = (element) => {
|
|
1595
|
+
for (const [lower, name, attribute] of lowercased) {
|
|
1596
|
+
if (element.hasAttribute(lower) && !element.hasAttribute(attribute)) {
|
|
1597
|
+
console.warn(`zubra: <${tag}> ignores the attribute "${lower}": HTML lowercases attribute names, and the prop ${name} is the attribute "${attribute}"${see('writing-a-component')}`);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
};
|
|
1526
1601
|
|
|
1527
1602
|
let internals; // (element) => its ElementInternals; set in the static block below
|
|
1528
1603
|
class ZubraElement extends HTMLElement {
|
|
@@ -1597,7 +1672,10 @@ function defineElement(tag, definition) {
|
|
|
1597
1672
|
// Server markup without its comments: start once the code that puts them back is here.
|
|
1598
1673
|
loadRemark().then(() => { if (this.isConnected && !this.#scope) this.connectedCallback(); });
|
|
1599
1674
|
return;
|
|
1600
|
-
} else
|
|
1675
|
+
} else {
|
|
1676
|
+
if (lowercased.length) checkAttributeCase(this);
|
|
1677
|
+
this.#boot();
|
|
1678
|
+
}
|
|
1601
1679
|
this.#cleanups = this.#hooks.mount.map((fn) => fn()).filter((cleanup) => typeof cleanup === 'function');
|
|
1602
1680
|
this.#started();
|
|
1603
1681
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Generated by scripts/build-production.js from src/forms.js: the runtime without hot reloading or the DevTools hook. Do not edit.
|
|
2
|
+
// Form libraries: a Zubra control as a field of React Hook Form, TanStack Form, VeeValidate or
|
|
3
|
+
// Felte. Each helper turns what the library hands you for a field into the props (or the Svelte
|
|
4
|
+
// action) a Zubra control takes, through its two-way binding: a `<prop>` and its `<prop>change`
|
|
5
|
+
// event (`value` and `valuechange` by default).
|
|
6
|
+
//
|
|
7
|
+
// React Hook Form <Stepper {...registerProps(register('seats'))} />
|
|
8
|
+
// <Controller name="seats" render={({ field }) => <Stepper {...fieldProps(field)} />} />
|
|
9
|
+
// TanStack Form <form.Field name="seats">{(field) => <Stepper {...fieldProps(field)} />}</form.Field>
|
|
10
|
+
// VeeValidate <Stepper v-bind="vueFieldProps(useField('seats'))" />
|
|
11
|
+
// Felte <zb-stepper use:felteControl={createField('seats')}>
|
|
12
|
+
//
|
|
13
|
+
// Nothing here imports a library: each helper reads the shape the library documents. Native
|
|
14
|
+
// form submission needs none of this; a Zubra control with `form-associated` is already a form
|
|
15
|
+
// control (FormData, `<form method=post>`, server actions).
|
|
16
|
+
|
|
17
|
+
const handler = (prop) => `on${prop.toLowerCase()}change`; // the React wrapper takes any casing
|
|
18
|
+
const unref = (value) => (value && typeof value === 'object' && value.__v_isRef ? value.value : value);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* React Hook Form's `register(name)` as props for a wrapped Zubra control: the control's
|
|
22
|
+
* `value`, its `valuechange` reported as a change, blur reported as a blur, and the element as
|
|
23
|
+
* the ref (so `reset()` and `setValue()` write its `value`, and `setFocus()` focuses it). For a
|
|
24
|
+
* control whose model isn't `value`, use `fieldProps` with a `<Controller>`.
|
|
25
|
+
*/
|
|
26
|
+
export function registerProps(registration) {
|
|
27
|
+
const { name, ref, onChange, onBlur, disabled } = registration;
|
|
28
|
+
return {
|
|
29
|
+
name,
|
|
30
|
+
ref,
|
|
31
|
+
...(disabled === undefined ? {} : { disabled }),
|
|
32
|
+
onvaluechange: (event) => onChange({ type: 'change', target: { name, value: event.detail } }),
|
|
33
|
+
onBlur: (event) => onBlur({ type: 'blur', target: { name, value: event.currentTarget?.value } }),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A field's props for a wrapped Zubra control in React, from React Hook Form's `<Controller>`
|
|
39
|
+
* (`{ value, onChange, onBlur, name, ref }`) or TanStack Form's field API
|
|
40
|
+
* (`{ state: { value }, handleChange, handleBlur, name }`). `prop` names the control's model
|
|
41
|
+
* (`value` by default).
|
|
42
|
+
*/
|
|
43
|
+
export function fieldProps(field, { prop = 'value' } = {}) {
|
|
44
|
+
const tanstack = typeof field.handleChange === 'function';
|
|
45
|
+
const value = tanstack ? field.state.value : field.value;
|
|
46
|
+
const change = tanstack ? field.handleChange : field.onChange;
|
|
47
|
+
const blur = tanstack ? field.handleBlur : field.onBlur;
|
|
48
|
+
return {
|
|
49
|
+
name: field.name,
|
|
50
|
+
[prop]: value,
|
|
51
|
+
[handler(prop)]: (event) => change(event.detail),
|
|
52
|
+
onBlur: () => blur(),
|
|
53
|
+
...(field.ref ? { ref: field.ref } : {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A field's props for a wrapped Zubra control in Vue, from VeeValidate's `useField()` (whose
|
|
59
|
+
* `value` is a ref) or the `field` a `<Field v-slot="{ field }">` gives. `prop` names the
|
|
60
|
+
* control's model (`value` by default).
|
|
61
|
+
*/
|
|
62
|
+
export function vueFieldProps(field, { prop = 'value' } = {}) {
|
|
63
|
+
const change = field.handleChange ?? field.onChange;
|
|
64
|
+
const blur = field.handleBlur ?? field.onBlur;
|
|
65
|
+
const value = 'value' in field ? unref(field.value) : field.modelValue;
|
|
66
|
+
return {
|
|
67
|
+
name: unref(field.name),
|
|
68
|
+
[prop]: value,
|
|
69
|
+
[`onUpdate:${prop}`]: (next) => (Array.isArray(change) ? change.forEach((fn) => fn(next)) : change(next)),
|
|
70
|
+
// The control's own focus leaves it: `blur` doesn't bubble out of its inner input, `focusout` does.
|
|
71
|
+
onFocusout: (event) => (Array.isArray(blur) ? blur.forEach((fn) => fn(event)) : blur?.(event)),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Felte: a Svelte action (or a plain function for `@felte/vanilla`) that connects a Zubra control
|
|
77
|
+
* to a field from Felte's `createField(name)`. Felte reads native inputs, so the field keeps a
|
|
78
|
+
* hidden one in step with the control: each `<prop>change` is reported as input, focus leaving
|
|
79
|
+
* the control as a blur, and Felte's own writes (`reset()`, `setFields()`) reach the control. Returns `{ destroy }`.
|
|
80
|
+
*
|
|
81
|
+
* <zb-stepper use:felteControl={createField('seats')}></zb-stepper>
|
|
82
|
+
*/
|
|
83
|
+
export function felteControl(node, created, { prop = 'value' } = {}) {
|
|
84
|
+
const field = created?.field ? created : null;
|
|
85
|
+
if (!field) throw new TypeError("zubra: felteControl() needs the result of Felte's createField(name)");
|
|
86
|
+
const action = field.field(node) ?? {};
|
|
87
|
+
// Felte puts its hidden input in place, after the control, on the next task. Start it at the
|
|
88
|
+
// control's value; keep a number a number (Felte reads `type="number"` inputs as numbers, and this
|
|
89
|
+
// one stays hidden); and pass Felte's own writes to it (`reset()`, `setFields()`) to the control.
|
|
90
|
+
let writing = false;
|
|
91
|
+
const start = setTimeout(() => {
|
|
92
|
+
const input = node.nextElementSibling;
|
|
93
|
+
if (input?.localName === 'input' && input.type === 'hidden') {
|
|
94
|
+
if (typeof node[prop] === 'number') {
|
|
95
|
+
input.type = 'number';
|
|
96
|
+
input.hidden = true;
|
|
97
|
+
}
|
|
98
|
+
const own = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
|
|
99
|
+
Object.defineProperty(input, 'value', {
|
|
100
|
+
configurable: true,
|
|
101
|
+
get: () => own.get.call(input),
|
|
102
|
+
set: (value) => {
|
|
103
|
+
own.set.call(input, value);
|
|
104
|
+
if (!writing) node[prop] = input.type === 'number' && value !== '' ? Number(value) : value;
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
report(node[prop]);
|
|
109
|
+
});
|
|
110
|
+
const report = (value) => {
|
|
111
|
+
writing = true;
|
|
112
|
+
try { field.onInput(value); } finally { writing = false; }
|
|
113
|
+
};
|
|
114
|
+
const change = (event) => report(event.detail);
|
|
115
|
+
const blur = () => field.onBlur();
|
|
116
|
+
node.addEventListener(prop.toLowerCase() + 'change', change);
|
|
117
|
+
node.addEventListener('focusout', blur);
|
|
118
|
+
return {
|
|
119
|
+
destroy() {
|
|
120
|
+
clearTimeout(start);
|
|
121
|
+
node.removeEventListener(prop.toLowerCase() + 'change', change);
|
|
122
|
+
node.removeEventListener('focusout', blur);
|
|
123
|
+
action.destroy?.();
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Generated by scripts/build-production.js from src/navigation.js: the runtime without hot reloading or the DevTools hook. Do not edit.
|
|
2
|
+
// Links inside components, through the app's router: a plain `<a href>` in a Zubra component
|
|
3
|
+
// navigates with the router the app already has (React Router, Next.js, Vue Router, SvelteKit,
|
|
4
|
+
// Angular Router…) instead of reloading the page. Not a router: one listener that hands the
|
|
5
|
+
// app's router the URL, and otherwise leaves the link alone.
|
|
6
|
+
//
|
|
7
|
+
// routeLinks((to) => router.push(to)) // Vue Router; returns a function that stops it
|
|
8
|
+
// useRouteLinks(useNavigate()) // React Router (`@zubra/runtime/react`)
|
|
9
|
+
// useRouteLinks(useRouter().push) // Next.js
|
|
10
|
+
// routeLinks((to) => goto(to)) // SvelteKit, in onMount
|
|
11
|
+
// routeLinks((to) => router.navigateByUrl(to)) // Angular
|
|
12
|
+
//
|
|
13
|
+
// Without JavaScript, or for a link it leaves alone, the link is a link. It leaves alone: a click
|
|
14
|
+
// something already handled (`preventDefault()`), a modified or non-primary click (new tab,
|
|
15
|
+
// window, download), a `target` other than `_self`, `download`, `rel="external"`, another
|
|
16
|
+
// origin, and a link to a place on the current page (`#…`). Only links inside a Zubra component
|
|
17
|
+
// are routed by default (the app's own links are its router's business); `within` changes that.
|
|
18
|
+
//
|
|
19
|
+
// If the app's router already intercepts the Navigation API's `navigate` event (Baseline since
|
|
20
|
+
// January 2026), links in components already go through it, and none of this is needed.
|
|
21
|
+
|
|
22
|
+
/** A Zubra component's host: its class carries the compiled `zubra` metadata. */
|
|
23
|
+
export const isComponent = (node) => Boolean(node?.constructor?.zubra);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Route clicks on links inside Zubra components through `navigate(to, { event, anchor, url })`,
|
|
27
|
+
* where `to` is the path, query and hash (`/seats?row=b#top`). Returns a function that stops it.
|
|
28
|
+
* `within(node)` decides which ancestors count (by default, a Zubra component's host); `root` is
|
|
29
|
+
* where to listen (by default, the document).
|
|
30
|
+
*/
|
|
31
|
+
export function routeLinks(navigate, { within = isComponent, root = globalThis.document } = {}) {
|
|
32
|
+
if (typeof navigate !== 'function') throw new TypeError('zubra: routeLinks() needs a function that navigates, such as (to) => router.push(to)');
|
|
33
|
+
if (!root) return () => {};
|
|
34
|
+
const click = (event) => {
|
|
35
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
36
|
+
const path = event.composedPath();
|
|
37
|
+
const at = path.findIndex((node) => node.localName === 'a' && node.hasAttribute?.('href'));
|
|
38
|
+
if (at < 0) return;
|
|
39
|
+
const anchor = path[at];
|
|
40
|
+
if (!path.slice(at + 1).some((node) => node.nodeType === 1 && within(node))) return;
|
|
41
|
+
const target = anchor.getAttribute('target');
|
|
42
|
+
if ((target && target !== '_self') || anchor.hasAttribute('download')) return;
|
|
43
|
+
if ((anchor.getAttribute('rel') ?? '').split(/\s+/).includes('external')) return;
|
|
44
|
+
const url = new URL(anchor.href, anchor.baseURI);
|
|
45
|
+
const here = new URL(globalThis.location.href);
|
|
46
|
+
if (url.origin !== here.origin) return;
|
|
47
|
+
if (url.hash && url.pathname === here.pathname && url.search === here.search) return;
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
navigate(url.pathname + url.search + url.hash, { event, anchor, url });
|
|
50
|
+
};
|
|
51
|
+
root.addEventListener('click', click);
|
|
52
|
+
return () => root.removeEventListener('click', click);
|
|
53
|
+
}
|