@uistate/core 4.1.2 → 5.0.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.
Files changed (150) hide show
  1. package/LICENSE-eventTest.md +26 -0
  2. package/README.md +409 -42
  3. package/cssState.js +32 -1
  4. package/eventState.js +58 -49
  5. package/eventTest.js +196 -0
  6. package/examples/001-counter/README.md +44 -0
  7. package/examples/001-counter/eventState.js +86 -0
  8. package/examples/001-counter/index.html +30 -0
  9. package/examples/002-counter-improved/README.md +44 -0
  10. package/examples/002-counter-improved/eventState.js +86 -0
  11. package/examples/002-counter-improved/index.html +44 -0
  12. package/examples/003-input-reactive/README.md +44 -0
  13. package/examples/003-input-reactive/eventState.js +86 -0
  14. package/examples/003-input-reactive/index.html +30 -0
  15. package/examples/004-computed-state/README.md +45 -0
  16. package/examples/004-computed-state/eventState.js +86 -0
  17. package/examples/004-computed-state/index.html +62 -0
  18. package/examples/005-conditional-rendering/README.md +42 -0
  19. package/examples/005-conditional-rendering/eventState.js +86 -0
  20. package/examples/005-conditional-rendering/index.html +36 -0
  21. package/examples/006-list-rendering/README.md +49 -0
  22. package/examples/006-list-rendering/eventState.js +86 -0
  23. package/examples/006-list-rendering/index.html +60 -0
  24. package/examples/007-form-validation/README.md +52 -0
  25. package/examples/007-form-validation/eventState.js +86 -0
  26. package/examples/007-form-validation/index.html +99 -0
  27. package/examples/008-undo-redo/README.md +70 -0
  28. package/examples/008-undo-redo/eventState.js +86 -0
  29. package/examples/008-undo-redo/index.html +105 -0
  30. package/examples/009-localStorage-side-effects/README.md +72 -0
  31. package/examples/009-localStorage-side-effects/eventState.js +86 -0
  32. package/examples/009-localStorage-side-effects/index.html +54 -0
  33. package/examples/010-decoupled-components/README.md +74 -0
  34. package/examples/010-decoupled-components/eventState.js +86 -0
  35. package/examples/010-decoupled-components/index.html +90 -0
  36. package/examples/011-async-patterns/README.md +98 -0
  37. package/examples/011-async-patterns/eventState.js +86 -0
  38. package/examples/011-async-patterns/index.html +129 -0
  39. package/examples/028-counter-improved-eventTest/LICENSE +55 -0
  40. package/examples/028-counter-improved-eventTest/README.md +131 -0
  41. package/examples/028-counter-improved-eventTest/app/store.js +9 -0
  42. package/examples/028-counter-improved-eventTest/index.html +49 -0
  43. package/examples/028-counter-improved-eventTest/runtime/core/behaviors.runtime.js +282 -0
  44. package/examples/028-counter-improved-eventTest/runtime/core/eventState.js +100 -0
  45. package/examples/028-counter-improved-eventTest/runtime/core/helpers.js +212 -0
  46. package/examples/028-counter-improved-eventTest/runtime/core/router.js +271 -0
  47. package/examples/028-counter-improved-eventTest/store.d.ts +8 -0
  48. package/examples/028-counter-improved-eventTest/style.css +170 -0
  49. package/examples/028-counter-improved-eventTest/tests/README.md +208 -0
  50. package/examples/028-counter-improved-eventTest/tests/counter.test.js +116 -0
  51. package/examples/028-counter-improved-eventTest/tests/eventTest.js +176 -0
  52. package/examples/028-counter-improved-eventTest/tests/generateTypes.js +168 -0
  53. package/examples/028-counter-improved-eventTest/tests/run.js +20 -0
  54. package/examples/030-todo-app-with-eventTest/LICENSE +55 -0
  55. package/examples/030-todo-app-with-eventTest/README.md +121 -0
  56. package/examples/030-todo-app-with-eventTest/app/router.js +25 -0
  57. package/examples/030-todo-app-with-eventTest/app/store.js +16 -0
  58. package/examples/030-todo-app-with-eventTest/app/views/home.js +11 -0
  59. package/examples/030-todo-app-with-eventTest/app/views/todoDemo.js +88 -0
  60. package/examples/030-todo-app-with-eventTest/index.html +65 -0
  61. package/examples/030-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
  62. package/examples/030-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
  63. package/examples/030-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
  64. package/examples/030-todo-app-with-eventTest/runtime/core/router.js +271 -0
  65. package/examples/030-todo-app-with-eventTest/store.d.ts +18 -0
  66. package/examples/030-todo-app-with-eventTest/style.css +170 -0
  67. package/examples/030-todo-app-with-eventTest/tests/README.md +208 -0
  68. package/examples/030-todo-app-with-eventTest/tests/eventTest.js +176 -0
  69. package/examples/030-todo-app-with-eventTest/tests/generateTypes.js +189 -0
  70. package/examples/030-todo-app-with-eventTest/tests/run.js +20 -0
  71. package/examples/030-todo-app-with-eventTest/tests/todos.test.js +167 -0
  72. package/examples/031-todo-app-with-eventTest/LICENSE +55 -0
  73. package/examples/031-todo-app-with-eventTest/README.md +54 -0
  74. package/examples/031-todo-app-with-eventTest/TUTORIAL.md +390 -0
  75. package/examples/031-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
  76. package/examples/031-todo-app-with-eventTest/app/bridges.js +113 -0
  77. package/examples/031-todo-app-with-eventTest/app/router.js +26 -0
  78. package/examples/031-todo-app-with-eventTest/app/store.js +15 -0
  79. package/examples/031-todo-app-with-eventTest/app/views/home.js +46 -0
  80. package/examples/031-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
  81. package/examples/031-todo-app-with-eventTest/devtools/dock.js +41 -0
  82. package/examples/031-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
  83. package/examples/031-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
  84. package/examples/031-todo-app-with-eventTest/devtools/telemetry.js +104 -0
  85. package/examples/031-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
  86. package/examples/031-todo-app-with-eventTest/index.html +103 -0
  87. package/examples/031-todo-app-with-eventTest/package-lock.json +2184 -0
  88. package/examples/031-todo-app-with-eventTest/package.json +24 -0
  89. package/examples/031-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
  90. package/examples/031-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
  91. package/examples/031-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
  92. package/examples/031-todo-app-with-eventTest/runtime/core/router.js +271 -0
  93. package/examples/031-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
  94. package/examples/031-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
  95. package/examples/031-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
  96. package/examples/031-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
  97. package/examples/031-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
  98. package/examples/031-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
  99. package/examples/031-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
  100. package/examples/031-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
  101. package/examples/031-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
  102. package/examples/031-todo-app-with-eventTest/store.d.ts +23 -0
  103. package/examples/031-todo-app-with-eventTest/style.css +170 -0
  104. package/examples/031-todo-app-with-eventTest/tests/README.md +208 -0
  105. package/examples/031-todo-app-with-eventTest/tests/eventTest.js +176 -0
  106. package/examples/031-todo-app-with-eventTest/tests/generateTypes.js +191 -0
  107. package/examples/031-todo-app-with-eventTest/tests/run.js +20 -0
  108. package/examples/031-todo-app-with-eventTest/tests/todos.test.js +192 -0
  109. package/examples/032-todo-app-with-eventTest/LICENSE +55 -0
  110. package/examples/032-todo-app-with-eventTest/README.md +54 -0
  111. package/examples/032-todo-app-with-eventTest/TUTORIAL.md +390 -0
  112. package/examples/032-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
  113. package/examples/032-todo-app-with-eventTest/app/actions/index.js +153 -0
  114. package/examples/032-todo-app-with-eventTest/app/bridges.js +113 -0
  115. package/examples/032-todo-app-with-eventTest/app/router.js +26 -0
  116. package/examples/032-todo-app-with-eventTest/app/store.js +15 -0
  117. package/examples/032-todo-app-with-eventTest/app/views/home.js +46 -0
  118. package/examples/032-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
  119. package/examples/032-todo-app-with-eventTest/devtools/dock.js +41 -0
  120. package/examples/032-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
  121. package/examples/032-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
  122. package/examples/032-todo-app-with-eventTest/devtools/telemetry.js +104 -0
  123. package/examples/032-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
  124. package/examples/032-todo-app-with-eventTest/index.html +87 -0
  125. package/examples/032-todo-app-with-eventTest/package-lock.json +2184 -0
  126. package/examples/032-todo-app-with-eventTest/package.json +24 -0
  127. package/examples/032-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
  128. package/examples/032-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
  129. package/examples/032-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
  130. package/examples/032-todo-app-with-eventTest/runtime/core/router.js +271 -0
  131. package/examples/032-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
  132. package/examples/032-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
  133. package/examples/032-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
  134. package/examples/032-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
  135. package/examples/032-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
  136. package/examples/032-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
  137. package/examples/032-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
  138. package/examples/032-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
  139. package/examples/032-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
  140. package/examples/032-todo-app-with-eventTest/store.d.ts +23 -0
  141. package/examples/032-todo-app-with-eventTest/style.css +170 -0
  142. package/examples/032-todo-app-with-eventTest/tests/README.md +208 -0
  143. package/examples/032-todo-app-with-eventTest/tests/eventTest.js +176 -0
  144. package/examples/032-todo-app-with-eventTest/tests/generateTypes.js +191 -0
  145. package/examples/032-todo-app-with-eventTest/tests/run.js +20 -0
  146. package/examples/032-todo-app-with-eventTest/tests/todos.test.js +192 -0
  147. package/index.js +14 -3
  148. package/package.json +16 -7
  149. package/stateSerializer.js +99 -4
  150. package/templateManager.js +50 -2
@@ -0,0 +1,157 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // hydrate.js — general JSON→store utilities
3
+ // Minimal, framework-agnostic helpers that operate on a simple store interface:
4
+ // - store.get(path: string): any
5
+ // - store.set(path: string, value: any): void
6
+ // Options are defensive and production-oriented but remain tiny.
7
+
8
+ /**
9
+ * @typedef {Object} Store
10
+ * @property {(path:string)=>any} get
11
+ * @property {(path:string, value:any)=>void} set
12
+ * @property {(path:string, fn:(v:any)=>void)=>(()=>void)=} subscribe
13
+ */
14
+
15
+ /**
16
+ * Execute a function intended to perform multiple set() calls.
17
+ * Note: Without native store batching, this is a semantic wrapper.
18
+ * Consumers may swap this with a true batch if available.
19
+ *
20
+ * @template T
21
+ * @param {Store} store
22
+ * @param {() => T} fn
23
+ * @returns {T}
24
+ */
25
+ export function withBatch(store, fn) {
26
+ return fn();
27
+ }
28
+
29
+ function inWhitelist(path, whitelistPaths) {
30
+ if (!whitelistPaths || whitelistPaths.length === 0) return true;
31
+ return whitelistPaths.some((p) => path === p || path.startsWith(p + '.'));
32
+ }
33
+
34
+ /**
35
+ * Replace top-level subtrees.
36
+ *
37
+ * @param {Store} store
38
+ * @param {Record<string, any>} payload - e.g. { demo: {...}, ui: {...} }
39
+ * @param {Object} [opts]
40
+ * @param {string[]} [opts.whitelistPaths] - Allowed path prefixes (e.g., ['form'])
41
+ * @param {boolean} [opts.batch] - If true, wraps changes in {@link withBatch}
42
+ * @returns {void}
43
+ */
44
+ export function hydrateReplace(store, payload, opts = {}) {
45
+ const { whitelistPaths, batch } = opts;
46
+ const apply = () => {
47
+ for (const [k, v] of Object.entries(payload || {})) {
48
+ if (!inWhitelist(k, whitelistPaths)) continue;
49
+ store.set(k, v);
50
+ }
51
+ };
52
+ return batch ? withBatch(store, apply) : apply();
53
+ }
54
+
55
+ /**
56
+ * Deep merge a payload into the store, starting from a root path.
57
+ *
58
+ * Arrays: `replace` (default) or `keyedMerge` by a `keyField` (default: `id`).
59
+ * Conflicts: optional `onConflict(path, prev, next)` on leaf writes.
60
+ * Version: optional guard to avoid stale apply via `version` + `getVersion()`.
61
+ *
62
+ * @param {Store} store
63
+ * @param {string} root - Path prefix to merge under (e.g., 'demo')
64
+ * @param {any} payload - Arbitrary JSON-compatible structure
65
+ * @param {Object} [opts]
66
+ * @param {(path:string, prev:any, next:any)=>any} [opts.onConflict]
67
+ * @param {('replace'|'keyedMerge')} [opts.arrayStrategy]
68
+ * @param {string} [opts.keyField]
69
+ * @param {string[]} [opts.whitelistPaths]
70
+ * @param {number} [opts.version]
71
+ * @param {()=>number} [opts.getVersion]
72
+ * @param {boolean} [opts.batch]
73
+ * @returns {void}
74
+ */
75
+ export function hydrateMerge(store, root, payload, opts = {}) {
76
+ const {
77
+ onConflict,
78
+ arrayStrategy = 'replace',
79
+ keyField = 'id',
80
+ whitelistPaths,
81
+ version,
82
+ getVersion,
83
+ batch,
84
+ } = opts;
85
+
86
+ if (version != null && typeof getVersion === 'function') {
87
+ const current = getVersion();
88
+ if (current != null && version <= current) {
89
+ // Incoming is stale or equal; skip.
90
+ return;
91
+ }
92
+ }
93
+
94
+ const setLeaf = (path, nextVal) => {
95
+ if (!inWhitelist(path, whitelistPaths)) return;
96
+ const prev = store.get(path);
97
+ if (onConflict && typeof nextVal !== 'object') {
98
+ if (typeof prev !== 'undefined') {
99
+ const resolved = onConflict(path, prev, nextVal);
100
+ store.set(path, resolved);
101
+ return;
102
+ }
103
+ }
104
+ store.set(path, nextVal);
105
+ };
106
+
107
+ const mergeArray = (path, incomingArr) => {
108
+ if (arrayStrategy === 'replace') {
109
+ setLeaf(path, incomingArr);
110
+ return;
111
+ }
112
+ // keyedMerge
113
+ const local = store.get(path) || [];
114
+ const byKey = new Map();
115
+ for (const item of local) {
116
+ const k = item && typeof item === 'object' ? item[keyField] : undefined;
117
+ if (k != null) byKey.set(String(k), item);
118
+ }
119
+ const result = [...local];
120
+ for (const inc of incomingArr) {
121
+ const k = inc && typeof inc === 'object' ? inc[keyField] : undefined;
122
+ if (k == null) { result.push(inc); continue; }
123
+ const sk = String(k);
124
+ const existingIdx = result.findIndex((x) => x && x[keyField] != null && String(x[keyField]) === sk);
125
+ if (existingIdx >= 0) {
126
+ const old = result[existingIdx];
127
+ // shallow merge; objects only
128
+ if (old && typeof old === 'object' && inc && typeof inc === 'object') {
129
+ result[existingIdx] = { ...old, ...inc };
130
+ } else {
131
+ result[existingIdx] = inc;
132
+ }
133
+ } else {
134
+ result.push(inc);
135
+ }
136
+ }
137
+ setLeaf(path, result);
138
+ };
139
+
140
+ const recurse = (prefix, value) => {
141
+ if (Array.isArray(value)) {
142
+ mergeArray(prefix, value);
143
+ return;
144
+ }
145
+ if (value && typeof value === 'object') {
146
+ for (const [k, v] of Object.entries(value)) {
147
+ const path = prefix ? `${prefix}.${k}` : k;
148
+ recurse(path, v);
149
+ }
150
+ } else {
151
+ setLeaf(prefix, value);
152
+ }
153
+ };
154
+
155
+ const apply = () => recurse(root || '', payload);
156
+ return batch ? withBatch(store, apply) : apply();
157
+ }
@@ -0,0 +1,69 @@
1
+ // queryBinding.js — bind a URL query param to a store path (two-way, minimal loops)
2
+ // Usage:
3
+ // import { bindQueryParam } from './queryBinding.js';
4
+ // const unbind = bindQueryParam(store, { param: 'tab', path: 'ui.nav.tab', coerce: (v)=>v });
5
+ // Notes:
6
+ // - Reads current ui.route.query on navigation and applies to `path`.
7
+ // - When the store path changes, updates the URL query via history.replaceState.
8
+ // - Avoids re-entrancy loops with a tiny guard.
9
+
10
+ export function bindQueryParam(store, { param, path, coerce, defaultValue, omitDefault, mode = 'replace' } = {}){
11
+ if (!param || !path) throw new Error('bindQueryParam: `param` and `path` are required');
12
+ let internalUpdate = false;
13
+ let lastValue; // track last store value to drive smart push/replace
14
+
15
+ // Apply query -> store on any route query change
16
+ const applyFromQuery = () => {
17
+ const q = store.get('ui.route.query') || {};
18
+ const raw = q[param];
19
+ let val = typeof coerce === 'function' ? coerce(raw) : raw;
20
+ if (typeof raw === 'undefined' && typeof defaultValue !== 'undefined') {
21
+ val = defaultValue;
22
+ }
23
+ internalUpdate = true;
24
+ try { if (typeof val !== 'undefined') store.set(path, val); }
25
+ finally { internalUpdate = false; }
26
+ lastValue = store.get(path);
27
+ };
28
+
29
+ // Apply store -> URL on path change
30
+ const applyToUrl = () => {
31
+ if (internalUpdate) return;
32
+ const current = store.get(path);
33
+ // Build next query object from ui.route.query, then set/clear our param
34
+ const q = Object.assign({}, store.get('ui.route.query') || {});
35
+ const isDefault = omitDefault && typeof defaultValue !== 'undefined' && current === defaultValue;
36
+ if (typeof current === 'undefined' || current === null || current === '' || isDefault) delete q[param];
37
+ else q[param] = String(current);
38
+ // Write new query into URL (replaceState), and reflect into store.ui.route.query
39
+ const p = store.get('ui.route.path') || location.pathname;
40
+ const sp = new URLSearchParams(q);
41
+ const next = p + (sp.toString() ? ('?' + sp.toString()) : '') + location.hash;
42
+ // Decide push vs replace per mode
43
+ let doPush = false;
44
+ if (mode === 'push') doPush = true;
45
+ else if (mode === 'replace') doPush = false;
46
+ else if (mode === 'smart') {
47
+ const prev = lastValue;
48
+ if (isDefault) doPush = false; // returning to default cleans URL
49
+ else if (typeof prev === 'undefined') doPush = true; // first set
50
+ else if (prev !== current) doPush = true; // switching between non-defaults
51
+ else doPush = false;
52
+ }
53
+ if (doPush) history.pushState({}, '', next); else history.replaceState({}, '', next);
54
+ // Reflect new query map into store without recursion back to `path`
55
+ internalUpdate = true;
56
+ try { store.set('ui.route.query', Object.fromEntries(sp.entries())); }
57
+ finally { internalUpdate = false; }
58
+ lastValue = current;
59
+ };
60
+
61
+ // Subscriptions
62
+ const unsubA = store.subscribe('ui.route.query', applyFromQuery);
63
+ const unsubB = store.subscribe(path, applyToUrl);
64
+
65
+ // Initial sync from query
66
+ applyFromQuery();
67
+
68
+ return () => { unsubA(); unsubB(); };
69
+ }
@@ -0,0 +1,78 @@
1
+ // forms/computed.js — explicit-deps computed helper for eventState-like stores
2
+ // Requirements for store: get(path), set(path, value), subscribe(path, handler)
3
+ // Features: explicit deps, loop-avoidance, optional debounce, optional memo by dep tuple, optional gatePath, immediate compute
4
+
5
+ import { withBatch } from '../extensions/hydrate.js';
6
+
7
+ function tupleKey(vals) {
8
+ // Simple, deterministic key for small tuples
9
+ try { return JSON.stringify(vals); } catch { return String(vals); }
10
+ }
11
+
12
+ /**
13
+ * Register a computed field derived from explicit dependencies.
14
+ * @param {any} store
15
+ * @param {string} targetPath
16
+ * @param {string[]} deps
17
+ * @param {(get:(p:string)=>any)=>any} fn
18
+ * @param {{ debounce?: number, memo?: boolean, immediate?: boolean, gatePath?: string }} options
19
+ * @returns {() => void} unsubscribe function
20
+ */
21
+ export function computed(store, targetPath, deps, fn, options = {}) {
22
+ const { debounce = 0, memo = false, immediate = true, gatePath } = options;
23
+ let timer = null;
24
+ let lastKey = undefined;
25
+ let destroyed = false;
26
+
27
+ const get = (p) => store.get(p);
28
+
29
+ const doCompute = () => {
30
+ if (destroyed) return;
31
+ if (gatePath) {
32
+ const gateVal = store.get(gatePath);
33
+ if (!gateVal) return; // gate closed
34
+ }
35
+ const depVals = deps.map((d) => store.get(d));
36
+ if (memo) {
37
+ const k = tupleKey(depVals);
38
+ if (k === lastKey) return;
39
+ lastKey = k;
40
+ }
41
+ const nextVal = fn(get);
42
+ // Loop avoidance: only write if value actually changed (best-effort)
43
+ const prev = store.get(targetPath);
44
+ if (prev !== nextVal) {
45
+ withBatch(store, () => {
46
+ store.set(targetPath, nextVal);
47
+ });
48
+ }
49
+ };
50
+
51
+ const schedule = () => {
52
+ if (debounce > 0) {
53
+ if (timer) clearTimeout(timer);
54
+ timer = setTimeout(doCompute, debounce);
55
+ } else {
56
+ doCompute();
57
+ }
58
+ };
59
+
60
+ const unsubs = deps.map((d) => store.subscribe(d, () => schedule()));
61
+ // Ignore self-updates if someone else sets targetPath explicitly
62
+ const unsubSelf = store.subscribe(targetPath, () => {
63
+ // no-op; presence prevents some stores from GC-ing path observers
64
+ });
65
+ // If a gate is used, subscribe to it so opening/closing the gate retriggers compute
66
+ if (gatePath) {
67
+ unsubs.push(store.subscribe(gatePath, () => schedule()));
68
+ }
69
+
70
+ if (immediate) schedule();
71
+
72
+ return () => {
73
+ destroyed = true;
74
+ if (timer) { try { clearTimeout(timer); } catch {} }
75
+ for (const u of unsubs) { try { u && u(); } catch {} }
76
+ try { unsubSelf && unsubSelf(); } catch {}
77
+ };
78
+ }
@@ -0,0 +1,51 @@
1
+ // forms/meta.js — touched/dirty helpers and simple a11y reflection
2
+ // Generic, path-agnostic helpers. Caller passes concrete value/meta paths.
3
+
4
+ /**
5
+ * Initialize field meta with an initial value.
6
+ * @param {any} store
7
+ * @param {{ valuePath: string, metaPath: string, initialValue?: any }} opts
8
+ */
9
+ export function initFieldMeta(store, { valuePath, metaPath, initialValue }) {
10
+ const init = (typeof initialValue !== 'undefined') ? initialValue : store.get(valuePath);
11
+ store.set(`${metaPath}.initial`, init);
12
+ store.set(`${metaPath}.touched`, false);
13
+ store.set(`${metaPath}.dirty`, false);
14
+ store.set(`${metaPath}.invalid`, false);
15
+ }
16
+
17
+ /** Mark as touched (e.g., on blur) */
18
+ export function markTouched(store, metaPath) {
19
+ store.set(`${metaPath}.touched`, true);
20
+ }
21
+
22
+ /** Update dirty flag by comparing current value to initial */
23
+ export function updateDirty(store, { valuePath, metaPath }) {
24
+ const current = store.get(valuePath);
25
+ const initial = store.get(`${metaPath}.initial`);
26
+ store.set(`${metaPath}.dirty`, current !== initial);
27
+ }
28
+
29
+ /** Reset meta flags (preserves initial) */
30
+ export function resetFieldMeta(store, metaPath) {
31
+ store.set(`${metaPath}.touched`, false);
32
+ store.set(`${metaPath}.dirty`, false);
33
+ store.set(`${metaPath}.invalid`, false);
34
+ }
35
+
36
+ /** Reset value to initial and clear flags */
37
+ export function resetToInitial(store, { valuePath, metaPath }) {
38
+ const initial = store.get(`${metaPath}.initial`);
39
+ store.set(valuePath, initial);
40
+ resetFieldMeta(store, metaPath);
41
+ }
42
+
43
+ /**
44
+ * Reflect invalid based on errorsByField[fieldKey].length
45
+ * @param {any} store
46
+ * @param {{ metaPath: string, errorsByField: Record<string, string[]>, fieldKey: string }} opts
47
+ */
48
+ export function reflectInvalid(store, { metaPath, errorsByField, fieldKey }) {
49
+ const invalid = Array.isArray(errorsByField?.[fieldKey]) && errorsByField[fieldKey].length > 0;
50
+ store.set(`${metaPath}.invalid`, invalid);
51
+ }
@@ -0,0 +1,28 @@
1
+ // forms/submitWithBoundary.js — thin wrapper over runWithBoundary for forms
2
+ // Usage: submitWithBoundary(store, asyncFn, { submittingPath, errorPath, successPath })
3
+ // Ensures submitting flag toggles, errors are mapped to a path, and optional success payload is written.
4
+
5
+ import { runWithBoundary } from '../extensions/boundary.js';
6
+
7
+ /**
8
+ * @template T
9
+ * @param {any} store - eventState-like store with get/set
10
+ * @param {() => Promise<T>} fn - async submit function
11
+ * @param {{ submittingPath: string, errorPath?: string, successPath?: string, mapError?: (e:any)=>any }} opts
12
+ * @returns {Promise<T|any|undefined>}
13
+ */
14
+ export function submitWithBoundary(store, fn, opts) {
15
+ const { submittingPath, errorPath, successPath, mapError } = opts || {};
16
+ if (submittingPath) store.set(submittingPath, true);
17
+ if (errorPath) store.set(errorPath, null);
18
+
19
+ return runWithBoundary(fn, {
20
+ setLoading: (b) => { if (submittingPath) store.set(submittingPath, b); },
21
+ onError: (err) => { if (errorPath) store.set(errorPath, err); },
22
+ mapError,
23
+ finally: () => {}
24
+ }).then((res) => {
25
+ if (typeof successPath === 'string') store.set(successPath, res ?? null);
26
+ return res;
27
+ });
28
+ }
@@ -0,0 +1,55 @@
1
+ // forms/validators.js — minimal sync/async validation helper
2
+ // Shape:
3
+ // validate(model, rules) -> Promise<{ valid: boolean, errorsByField: Record<string,string[]>, errorsGlobal: string[] }>
4
+ // Rules shape:
5
+ // {
6
+ // fieldName: [ ruleFn, ... ],
7
+ // _global?: [ ruleFn, ... ]
8
+ // }
9
+ // ruleFn signature:
10
+ // (value, model) => string|undefined|Promise<string|undefined>
11
+ // Returns a message when invalid; undefined when valid.
12
+
13
+ /**
14
+ * @param {any} model
15
+ * @param {Record<string, Array<Function>>} rules
16
+ */
17
+ export async function validate(model, rules = {}) {
18
+ const errorsByField = {};
19
+ const errorsGlobal = [];
20
+
21
+ const entries = Object.entries(rules).filter(([k]) => k !== '_global');
22
+
23
+ for (const [field, fns] of entries) {
24
+ const val = field.split('.').reduce((acc, k) => (acc ? acc[k] : undefined), model);
25
+ for (const fn of fns || []) {
26
+ const res = await fn(val, model);
27
+ if (typeof res === 'string' && res) {
28
+ (errorsByField[field] ||= []).push(res);
29
+ }
30
+ }
31
+ }
32
+
33
+ const globals = rules._global || [];
34
+ for (const fn of globals) {
35
+ const res = await fn(model);
36
+ if (typeof res === 'string' && res) errorsGlobal.push(res);
37
+ }
38
+
39
+ const valid = Object.keys(errorsByField).length === 0 && errorsGlobal.length === 0;
40
+ return { valid, errorsByField, errorsGlobal };
41
+ }
42
+
43
+ // Some tiny reusable rules
44
+ export const Rules = {
45
+ required: (msg = 'This field is required.') => (v) => (v == null || v === '' ? msg : undefined),
46
+ minLen: (n, msg) => (v) => (typeof v === 'string' && v.length < n ? (msg || `Must be at least ${n} characters.`) : undefined),
47
+ pattern: (re, msg) => (v) => (v && !re.test(String(v)) ? (msg || 'Invalid format.') : undefined),
48
+ email: (msg = 'Enter a valid email.') => (v) => (!v ? undefined : /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v)) ? undefined : msg),
49
+ // Async example: simulate uniqueness check
50
+ asyncUnique: (checkFn, msg = 'Already taken.') => async (v) => {
51
+ if (!v) return undefined;
52
+ const ok = await checkFn(v);
53
+ return ok ? undefined : msg;
54
+ },
55
+ };
@@ -0,0 +1,23 @@
1
+ // Auto-generated from test assertions
2
+ // DO NOT EDIT - regenerate by running: node tests/generateTypes.js
3
+
4
+ export interface StoreState {
5
+ domain: {
6
+ todos: {
7
+ items: Array<{ id: number; text: string; done: boolean }>;
8
+ };
9
+ };
10
+ ui: {
11
+ todos: {
12
+ filter: string;
13
+ };
14
+ };
15
+ intent: {
16
+ todo: {
17
+ add: { text: string };
18
+ toggle: { id: number };
19
+ };
20
+ };
21
+ }
22
+
23
+ export default StoreState;
@@ -0,0 +1,170 @@
1
+ body {
2
+ font: 14px/1.4 system-ui, sans-serif;
3
+ margin: 0;
4
+ }
5
+
6
+ /* Hide focus outline on route container (set programmatically for a11y) */
7
+ [data-route-root]:focus {
8
+ outline: none;
9
+ }
10
+
11
+ nav {
12
+ padding: 10px 14px;
13
+ border-bottom: 1px solid #eee;
14
+ position: sticky;
15
+ top: 0;
16
+ background: #fff;
17
+ z-index: 10;
18
+ }
19
+
20
+ nav a {
21
+ margin-right: 12px;
22
+ text-decoration: none;
23
+ color: #0366d6;
24
+ }
25
+
26
+ nav a.active {
27
+ font-weight: 600;
28
+ }
29
+
30
+ main {
31
+ padding: 16px;
32
+ }
33
+
34
+ .loading-badge {
35
+ margin-left: 8px;
36
+ font-size: 12px;
37
+ color: #666;
38
+ display: none;
39
+ }
40
+
41
+ html[data-transitioning="on"] .loading-badge {
42
+ display: inline;
43
+ }
44
+
45
+ /* 404 view demo: tint the top bar only on notfound */
46
+ html[data-view="notfound"] nav {
47
+ background: #fff7f7;
48
+ border-bottom-color: #f5c2c2;
49
+ }
50
+
51
+ html[data-view="notfound"] nav a {
52
+ color: #b91c1c;
53
+ }
54
+
55
+ html[data-view="notfound"] .loading-badge {
56
+ color: #b91c1c;
57
+ }
58
+
59
+ /* Tabs styling (used by views/demo.js) */
60
+ .tabs {
61
+ margin-top: 12px;
62
+ }
63
+
64
+ .tab-bar {
65
+ display: flex;
66
+ gap: 8px;
67
+ border-bottom: 1px solid #eee;
68
+ padding-bottom: 6px;
69
+ }
70
+
71
+ .tab {
72
+ appearance: none;
73
+ border: 1px solid #ddd;
74
+ background: #fafafa;
75
+ border-radius: 6px;
76
+ padding: 6px 10px;
77
+ cursor: pointer;
78
+ }
79
+
80
+ .tab:hover {
81
+ background: #f3f4f6;
82
+ }
83
+
84
+ .tab.active {
85
+ background: #e5efff;
86
+ border-color: #bcd1ff;
87
+ }
88
+
89
+ .tab-panels {
90
+ padding-top: 10px;
91
+ }
92
+
93
+ .tab-panel {
94
+ display: none;
95
+ }
96
+
97
+ .tab-panel[data-active] {
98
+ display: block;
99
+ }
100
+
101
+ :root {
102
+ --bg: #ffffff;
103
+ --fg: #111111;
104
+ --muted: #555;
105
+ --card: #f6f6f6;
106
+ --border: #e5e5e5;
107
+ --btn-bg: #f8f8f8;
108
+ --btn-fg: #111;
109
+ --btn-border: #ddd;
110
+ --input-bg: #fff;
111
+ --input-fg: #222;
112
+ --input-border: #d6d6d6;
113
+ }
114
+
115
+ body[data-theme='dark'] {
116
+ --bg: #0f1115;
117
+ --fg: #cfd3dc;
118
+ --muted: #9aa3b2;
119
+ --card: #151923;
120
+ --border: #242a36;
121
+ --btn-bg: #1a2030;
122
+ --btn-fg: #cfd3dc;
123
+ --btn-border: #2b3242;
124
+ --input-bg: #1b2130;
125
+ --input-fg: #cfd3dc;
126
+ --input-border: #2b3242;
127
+ }
128
+
129
+ body {
130
+ background: var(--bg);
131
+ color: var(--fg);
132
+ }
133
+
134
+ a {
135
+ color: inherit;
136
+ }
137
+
138
+ nav {
139
+ background: var(--card);
140
+ border-bottom: 1px solid var(--border);
141
+ }
142
+
143
+ .btn {
144
+ appearance: none;
145
+ border: 1px solid var(--btn-border);
146
+ background: var(--btn-bg);
147
+ color: var(--btn-fg);
148
+ border-radius: 6px;
149
+ padding: 6px 10px;
150
+ cursor: pointer;
151
+ }
152
+
153
+ input,
154
+ select,
155
+ textarea {
156
+ background: var(--input-bg);
157
+ color: var(--input-fg);
158
+ border: 1px solid var(--input-border);
159
+ border-radius: 6px;
160
+ padding: 6px 8px;
161
+ }
162
+
163
+ input::placeholder,
164
+ textarea::placeholder {
165
+ color: color-mix(in srgb, var(--input-fg) 55%, transparent);
166
+ }
167
+
168
+ .btn:hover {
169
+ filter: brightness(1.05);
170
+ }