@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zubra/runtime",
3
- "version": "5.7.0",
3
+ "version": "5.9.0",
4
4
  "description": "Browser runtime for Zubra compiled components, built on the TC39 Signals API",
5
5
  "license": "ISC",
6
6
  "repository": {
@@ -30,6 +30,11 @@
30
30
  "production": "./dist/production/spread.js",
31
31
  "default": "./src/spread.js"
32
32
  },
33
+ "./track": {
34
+ "types": "./types/track.d.ts",
35
+ "production": "./dist/production/track.js",
36
+ "default": "./src/track.js"
37
+ },
33
38
  "./zag": {
34
39
  "types": "./types/zag.d.ts",
35
40
  "production": "./dist/production/zag.js",
@@ -45,6 +50,36 @@
45
50
  "production": "./dist/production/motion.js",
46
51
  "default": "./src/motion.js"
47
52
  },
53
+ "./bridge": {
54
+ "types": "./types/bridge.d.ts",
55
+ "production": "./dist/production/bridge.js",
56
+ "default": "./src/bridge.js"
57
+ },
58
+ "./context": {
59
+ "types": "./types/context.d.ts",
60
+ "production": "./dist/production/context.js",
61
+ "default": "./src/context.js"
62
+ },
63
+ "./forms": {
64
+ "types": "./types/forms.d.ts",
65
+ "production": "./dist/production/forms.js",
66
+ "default": "./src/forms.js"
67
+ },
68
+ "./navigation": {
69
+ "types": "./types/navigation.d.ts",
70
+ "production": "./dist/production/navigation.js",
71
+ "default": "./src/navigation.js"
72
+ },
73
+ "./outlet": {
74
+ "types": "./types/outlet.d.ts",
75
+ "production": "./dist/production/outlet.js",
76
+ "default": "./src/outlet.js"
77
+ },
78
+ "./testing": {
79
+ "types": "./types/testing.d.ts",
80
+ "production": "./dist/production/testing.js",
81
+ "default": "./src/testing.js"
82
+ },
48
83
  "./react": {
49
84
  "types": "./types/react.d.ts",
50
85
  "production": "./dist/production/react.js",
@@ -79,7 +114,8 @@
79
114
  "@angular/forms": ">=16",
80
115
  "@zag-js/vanilla": ">=1.47",
81
116
  "react": ">=18",
82
- "vue": "^3.3"
117
+ "vue": "^3.3",
118
+ "react-dom": ">=18"
83
119
  },
84
120
  "peerDependenciesMeta": {
85
121
  "@angular/core": {
@@ -96,6 +132,9 @@
96
132
  },
97
133
  "vue": {
98
134
  "optional": true
135
+ },
136
+ "react-dom": {
137
+ "optional": true
99
138
  }
100
139
  },
101
140
  "devDependencies": {
@@ -106,6 +145,8 @@
106
145
  "@angular/forms": "22.2.5",
107
146
  "@angular/platform-browser": "22.2.5",
108
147
  "@babel/core": "8.1.0",
148
+ "@felte/vanilla": "0.3.14",
149
+ "@tanstack/react-form": "1.33.5",
109
150
  "@types/react": "19.3.0",
110
151
  "@zag-js/combobox": "1.47.0",
111
152
  "@zag-js/vanilla": "1.47.0",
@@ -116,13 +157,17 @@
116
157
  "react": "19.3.1",
117
158
  "react-dom": "19.3.1",
118
159
  "react-dom18": "npm:react-dom@18.3.1",
160
+ "react-hook-form": "7.92.0",
161
+ "react-router": "8.5.1",
119
162
  "react18": "npm:react@18.3.1",
120
163
  "rxjs": "7.8.2",
121
164
  "signal-polyfill": "0.2.2",
122
165
  "typescript": "^7.0.2",
166
+ "vee-validate": "4.15.1",
123
167
  "vscode-html-languageservice": "5.6.2",
124
168
  "vscode-languageserver-textdocument": "1.0.16",
125
- "vue": "3.5.45"
169
+ "vue": "3.5.45",
170
+ "vue-router": "5.5.0"
126
171
  },
127
172
  "files": [
128
173
  "src",
package/src/bridge.js ADDED
@@ -0,0 +1,96 @@
1
+ // Signal bridges: read (and write) a Zubra signal from a framework's own code.
2
+ //
3
+ // import { shared } from './cart-state.js' // export const shared = state([])
4
+ // import { toStore } from '@zubra/runtime/bridge'
5
+ // const cart = toStore(shared, { deep: true })
6
+ //
7
+ // Svelte: {$cart.length} (the store contract)
8
+ // Solid: const items = from(cart) (solid-js `from`)
9
+ // Angular: items = toSignal(cart, { requireSync: true }) (a Subscribable)
10
+ // RxJS: from(cart) (Symbol.observable)
11
+ // React: useSignal(shared) from '@zubra/runtime/react'
12
+ // Vue: useSignal(shared) from '@zubra/runtime/vue'
13
+ //
14
+ // The adapters carry props, events and two-way bindings across the element boundary; this
15
+ // carries state: a signal that elements and the app's code both read, without copying it into
16
+ // the framework's state and back. Any `Signal.State` or `Signal.Computed` works (`state()`,
17
+ // `computed()`, `from()`, a `@zubra/sync` field, a native `Signal` once browsers ship one), as
18
+ // does a function that reads signals (it's tracked like a computed).
19
+ //
20
+ // `deep: true` also tracks what's inside an array or plain object, so `items.push(x)` notifies
21
+ // as `items = [...items, x]` does. Without it a mutation inside the value isn't a change.
22
+ import { Signal } from './signal.js';
23
+ import { effect, root } from './effect.js';
24
+
25
+ const isSignal = (value) => value instanceof Signal.State || value instanceof Signal.Computed;
26
+
27
+ function check(source, name) {
28
+ if (typeof source !== 'function' && !isSignal(source)) {
29
+ throw new TypeError(`zubra: ${name}() needs a signal (state(), computed(), a Signal.State or Signal.Computed) or a function that reads signals`);
30
+ }
31
+ }
32
+
33
+ /** The source's current value: a signal's `get()`, or the function's result. */
34
+ export const read = (source) => (typeof source === 'function' ? source() : source.get());
35
+
36
+ /** Whether the source can be written: a `Signal.State`, or a signal with its own `set` (`from()`). */
37
+ export const writable = (source) => typeof source?.set === 'function';
38
+
39
+ // Read every property of an array or plain object (through state's proxy, each read is tracked),
40
+ // so a change anywhere inside notifies.
41
+ function touch(value, seen = new Set()) {
42
+ if (value === null || typeof value !== 'object' || seen.has(value)) return;
43
+ if (!Array.isArray(value)) {
44
+ const proto = Object.getPrototypeOf(value);
45
+ if (proto !== Object.prototype && proto !== null) return;
46
+ }
47
+ seen.add(value);
48
+ if (Array.isArray(value)) for (let index = 0; index < value.length; index++) touch(value[index], seen);
49
+ else for (const key of Object.keys(value)) touch(value[key], seen);
50
+ }
51
+
52
+ /**
53
+ * Call `callback(value)` now and whenever `source` changes, until the returned function is
54
+ * called. Changes are batched like the runtime's own effects: one call per microtask, with the
55
+ * latest value. Not owned by any component, so it lives until it's unsubscribed.
56
+ */
57
+ export function subscribe(source, callback, { deep = false } = {}) {
58
+ check(source, 'subscribe');
59
+ const owner = root(() => {
60
+ effect(() => {
61
+ const value = read(source);
62
+ if (deep) touch(value);
63
+ Signal.subtle.untrack(() => callback(value));
64
+ });
65
+ });
66
+ return () => owner.dispose();
67
+ }
68
+
69
+ /**
70
+ * The source as a store: the Svelte store contract (`subscribe(run)` calls `run` now and on each
71
+ * change, returning the unsubscribe function), which is also an RxJS-style Subscribable
72
+ * (`subscribe({ next })` returns something with `unsubscribe()`), so Angular's `toSignal`, Solid's
73
+ * `from` and RxJS's `from` take it as it is. `set` and `update` are there when the source is
74
+ * writable (a Svelte writable store, so `bind:value={$store}` works).
75
+ */
76
+ export function toStore(source, options = {}) {
77
+ check(source, 'toStore');
78
+ const store = {
79
+ get: () => read(source),
80
+ subscribe(run) {
81
+ const next = typeof run === 'function' ? run : (value) => run?.next?.(value);
82
+ const stop = subscribe(source, next, options);
83
+ const unsubscribe = () => stop();
84
+ unsubscribe.unsubscribe = unsubscribe;
85
+ return unsubscribe;
86
+ },
87
+ [Symbol.observable ?? '@@observable']() {
88
+ return this;
89
+ },
90
+ };
91
+ if (writable(source)) {
92
+ store.set = (value) => source.set(value);
93
+ store.update = (fn) => source.set(fn(Signal.subtle.untrack(() => read(source))));
94
+ }
95
+ return store;
96
+ }
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/effect.js CHANGED
@@ -8,6 +8,11 @@
8
8
  // scope(fn) an ownership root: effects created inside belong to it
9
9
  // flush() / tick() apply pending re-runs now / await them
10
10
  //
11
+ // Disposal: effect()'s dispose function and every scope are also disposables
12
+ // (`[Symbol.dispose]`), so `using` and `DisposableStack` can own them where the browser has
13
+ // Explicit Resource Management. Nothing here depends on it. Pausing is not disposal: a paused
14
+ // scope resumes; only dispose() (or Symbol.dispose) ends one.
15
+ //
11
16
  // Ownership: an effect created while another effect (or a scope) is running belongs to it. When
12
17
  // the owner re-runs or is disposed, everything it owns is disposed first, so nothing outlives
13
18
  // the code that created it.
@@ -28,6 +33,12 @@ let scheduled = null; // promise for the pending microtask flush
28
33
  let flushing = false;
29
34
  const effectOf = new WeakMap();
30
35
 
36
+ /**
37
+ * `Symbol.dispose`, or, where the engine doesn't have it yet, the symbol TypeScript's downlevel
38
+ * `using` and the polyfills look up (`Symbol.for('Symbol.dispose')`).
39
+ */
40
+ export const DISPOSE = Symbol.dispose ?? Symbol.for('Symbol.dispose');
41
+
31
42
  // @dev
32
43
  /**
33
44
  * For the DevTools hook (devtools.js), in development only: `label` is what the effect being
@@ -100,6 +111,10 @@ class Owner {
100
111
  this.parent?.children.delete(this);
101
112
  }
102
113
 
114
+ [DISPOSE]() {
115
+ this.dispose();
116
+ }
117
+
103
118
  pause() {
104
119
  if (this.paused || this.disposed) return;
105
120
  this.paused = true;
@@ -193,7 +208,9 @@ class Scope extends Owner {
193
208
  export function effect(fn) {
194
209
  const e = new Effect(fn, owner);
195
210
  e.run(); // if this throws, the effect stays registered and re-runs when its dependencies change
196
- return () => e.dispose();
211
+ const dispose = () => e.dispose();
212
+ dispose[DISPOSE] = dispose;
213
+ return dispose;
197
214
  }
198
215
 
199
216
  /**
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);
@@ -206,12 +215,13 @@ export function emit(name) {
206
215
 
207
216
  /**
208
217
  * An id unique to this element and call, for `id`/`for`/`aria-*` attributes. Stable for the
209
- * element's life; based on the host's own `id` when it has one, so it can be made to match
210
- * server-rendered markup.
218
+ * element's life. Based on the host's own `id` when it has one, else on the base the server
219
+ * rendered it with (`data-zb-id`), so the ids in server-rendered markup stay as they are.
211
220
  */
212
221
  export function id() {
213
222
  const component = hooks('id');
214
- const base = component.host.id || `${component.host.localName}-${component.instance}`;
223
+ const host = component.host;
224
+ const base = host.id || host.getAttribute('data-zb-id') || `${host.localName}-${component.instance}`;
215
225
  return `${base}-${++component.ids}`;
216
226
  }
217
227
 
@@ -345,7 +355,18 @@ export function provide(context, value) {
345
355
  * key as `context`.
346
356
  */
347
357
  export function inject(context, fallback = context?.defaultValue) {
348
- const host = hooks('inject').host;
358
+ const component = hooks('inject');
359
+ const host = component.host;
360
+ // The value the server rendered with (`data-zb-inject`, by call number) is where it starts,
361
+ // so hydrating doesn't show the default until the provider answers.
362
+ const number = (component.injects = (component.injects ?? 0) + 1);
363
+ const rendered = host.getAttribute('data-zb-inject');
364
+ if (rendered) {
365
+ try {
366
+ const values = JSON.parse(rendered);
367
+ if (Object.hasOwn(values, number)) fallback = values[number];
368
+ } catch { /* not ours: keep the default */ }
369
+ }
349
370
  const value = new Signal.State(fallback);
350
371
  let unsubscribe = null;
351
372
  let answered = false;
@@ -556,8 +577,27 @@ function departBetween(open, close, motion) {
556
577
  }
557
578
 
558
579
  /** Remove everything between `open` and `close` in one operation. */
580
+ const nativeTextContent = typeof Node !== 'undefined' ? Object.getOwnPropertyDescriptor(Node.prototype, 'textContent').set : undefined;
559
581
  function deleteBetween(open, close) {
560
582
  if (open.nextSibling === close) return;
583
+ // The region is everything its parent holds but blank text (a list's own <tbody> or <ul>):
584
+ // emptying the parent and putting the markers and that text back is several times cheaper than
585
+ // deleting a range's contents.
586
+ const parent = open.parentNode;
587
+ const before = [];
588
+ const after = [];
589
+ const blank = (node) => node.nodeType === TEXT && node.data.trim() === '';
590
+ let edge = open.previousSibling;
591
+ while (edge && blank(edge) && before.length < 4) { before.unshift(edge); edge = edge.previousSibling; }
592
+ if (parent && !edge) {
593
+ edge = close.nextSibling;
594
+ while (edge && blank(edge) && after.length < 4) { after.push(edge); edge = edge.nextSibling; }
595
+ if (!edge) {
596
+ nativeTextContent.call(parent, '');
597
+ for (const node of [...before, open, close, ...after]) Node.prototype.insertBefore.call(parent, node, null);
598
+ return;
599
+ }
600
+ }
561
601
  const range = document.createRange();
562
602
  range.setStartAfter(open);
563
603
  range.setEndBefore(close);
@@ -1393,6 +1433,7 @@ function bindEach(part, open, hydrating, context) {
1393
1433
  }
1394
1434
  // `animate`: the rows that stay glide from where they were (@zubra/runtime/motion).
1395
1435
  const reorder = () => {
1436
+ const was = new Map(rows.map((row, index) => [row, index]));
1396
1437
  const next = list.map((value, index) => {
1397
1438
  const row = byKey.get(keys[index]);
1398
1439
  if (!row) return createRow(value, index, keys[index]);
@@ -1402,17 +1443,14 @@ function bindEach(part, open, hydrating, context) {
1402
1443
  return row;
1403
1444
  });
1404
1445
  batch(() => { for (const row of byKey.values()) removeRow(row); });
1405
- // Text between rows, and rows on their way out, aren't rows to place against.
1406
- const skip = (node) => node && (node.nodeType === TEXT || part.animate?.isLeaving(node));
1407
- let at = open.nextSibling;
1408
- while (skip(at)) at = at.nextSibling;
1409
- for (const row of next) {
1410
- if (row.open === at) {
1411
- at = row.close.nextSibling;
1412
- while (skip(at)) at = at.nextSibling;
1413
- continue;
1414
- }
1415
- placeRow(row, at ?? close);
1446
+ // The longest run of rows still in their old order stays put; every other row (and every
1447
+ // new one) goes before the row after it, from the end. A swap moves two rows, not the
1448
+ // rows between them.
1449
+ const stay = inOrder(next.map((row) => was.get(row) ?? -1));
1450
+ let before = close;
1451
+ for (let index = next.length - 1; index >= 0; index--) {
1452
+ if (!stay.has(index)) placeRow(next[index], before);
1453
+ before = next[index].open;
1416
1454
  }
1417
1455
  rows = next;
1418
1456
  };
@@ -1422,6 +1460,31 @@ function bindEach(part, open, hydrating, context) {
1422
1460
  });
1423
1461
  }
1424
1462
 
1463
+ /**
1464
+ * The indexes of a longest strictly increasing run in `sequence` (negative entries never count):
1465
+ * for a keyed list, which rows are already in order and needn't move. O(n log n).
1466
+ */
1467
+ function inOrder(sequence) {
1468
+ const tails = []; // tails[length - 1]: the index ending the best run of that length so far
1469
+ const previous = new Array(sequence.length);
1470
+ for (let index = 0; index < sequence.length; index++) {
1471
+ const value = sequence[index];
1472
+ if (value < 0) continue;
1473
+ let low = 0;
1474
+ let high = tails.length;
1475
+ while (low < high) {
1476
+ const middle = (low + high) >> 1;
1477
+ if (sequence[tails[middle]] < value) low = middle + 1;
1478
+ else high = middle;
1479
+ }
1480
+ previous[index] = low > 0 ? tails[low - 1] : -1;
1481
+ tails[low] = index;
1482
+ }
1483
+ const kept = new Set();
1484
+ for (let index = tails.length ? tails[tails.length - 1] : -1; index >= 0; index = previous[index]) kept.add(index);
1485
+ return kept;
1486
+ }
1487
+
1425
1488
  const hasContent = (root) =>
1426
1489
  [...root.childNodes].some((node) => node.nodeType !== TEXT || node.data.trim() !== '');
1427
1490
 
@@ -1564,6 +1627,18 @@ function defineElement(tag, definition) {
1564
1627
  const live = new Set(); // WeakRefs to the elements, kept only for hot reloads
1565
1628
  // @hot-end
1566
1629
  const byAttribute = Object.fromEntries(Object.entries(props).map(([name, spec]) => [spec.attribute, name]));
1630
+ // HTML lowercases attribute names, so `maxValue` in a page arrives as `maxvalue`, which isn't
1631
+ // the prop's attribute (`max-value`): the element would ignore it without a word.
1632
+ const lowercased = Object.entries(props)
1633
+ .filter(([name, spec]) => name !== name.toLowerCase() && spec.attribute !== name.toLowerCase())
1634
+ .map(([name, spec]) => [name.toLowerCase(), name, spec.attribute]);
1635
+ const checkAttributeCase = (element) => {
1636
+ for (const [lower, name, attribute] of lowercased) {
1637
+ if (element.hasAttribute(lower) && !element.hasAttribute(attribute)) {
1638
+ console.warn(`zubra: <${tag}> ignores the attribute "${lower}": HTML lowercases attribute names, and the prop ${name} is the attribute "${attribute}"${see('writing-a-component')}`);
1639
+ }
1640
+ }
1641
+ };
1567
1642
 
1568
1643
  let internals; // (element) => its ElementInternals; set in the static block below
1569
1644
  class ZubraElement extends HTMLElement {
@@ -1658,7 +1733,10 @@ function defineElement(tag, definition) {
1658
1733
  // Server markup without its comments: start once the code that puts them back is here.
1659
1734
  loadRemark().then(() => { if (this.isConnected && !this.#scope) this.connectedCallback(); });
1660
1735
  return;
1661
- } else this.#boot();
1736
+ } else {
1737
+ if (lowercased.length) checkAttributeCase(this);
1738
+ this.#boot();
1739
+ }
1662
1740
  this.#cleanups = this.#hooks.mount.map((fn) => fn()).filter((cleanup) => typeof cleanup === 'function');
1663
1741
  this.#started();
1664
1742
  }
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
+ }