@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.
- package/LICENSE-eventTest.md +26 -0
- package/README.md +409 -42
- package/cssState.js +32 -1
- package/eventState.js +58 -49
- package/eventTest.js +196 -0
- package/examples/001-counter/README.md +44 -0
- package/examples/001-counter/eventState.js +86 -0
- package/examples/001-counter/index.html +30 -0
- package/examples/002-counter-improved/README.md +44 -0
- package/examples/002-counter-improved/eventState.js +86 -0
- package/examples/002-counter-improved/index.html +44 -0
- package/examples/003-input-reactive/README.md +44 -0
- package/examples/003-input-reactive/eventState.js +86 -0
- package/examples/003-input-reactive/index.html +30 -0
- package/examples/004-computed-state/README.md +45 -0
- package/examples/004-computed-state/eventState.js +86 -0
- package/examples/004-computed-state/index.html +62 -0
- package/examples/005-conditional-rendering/README.md +42 -0
- package/examples/005-conditional-rendering/eventState.js +86 -0
- package/examples/005-conditional-rendering/index.html +36 -0
- package/examples/006-list-rendering/README.md +49 -0
- package/examples/006-list-rendering/eventState.js +86 -0
- package/examples/006-list-rendering/index.html +60 -0
- package/examples/007-form-validation/README.md +52 -0
- package/examples/007-form-validation/eventState.js +86 -0
- package/examples/007-form-validation/index.html +99 -0
- package/examples/008-undo-redo/README.md +70 -0
- package/examples/008-undo-redo/eventState.js +86 -0
- package/examples/008-undo-redo/index.html +105 -0
- package/examples/009-localStorage-side-effects/README.md +72 -0
- package/examples/009-localStorage-side-effects/eventState.js +86 -0
- package/examples/009-localStorage-side-effects/index.html +54 -0
- package/examples/010-decoupled-components/README.md +74 -0
- package/examples/010-decoupled-components/eventState.js +86 -0
- package/examples/010-decoupled-components/index.html +90 -0
- package/examples/011-async-patterns/README.md +98 -0
- package/examples/011-async-patterns/eventState.js +86 -0
- package/examples/011-async-patterns/index.html +129 -0
- package/examples/028-counter-improved-eventTest/LICENSE +55 -0
- package/examples/028-counter-improved-eventTest/README.md +131 -0
- package/examples/028-counter-improved-eventTest/app/store.js +9 -0
- package/examples/028-counter-improved-eventTest/index.html +49 -0
- package/examples/028-counter-improved-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/examples/028-counter-improved-eventTest/runtime/core/eventState.js +100 -0
- package/examples/028-counter-improved-eventTest/runtime/core/helpers.js +212 -0
- package/examples/028-counter-improved-eventTest/runtime/core/router.js +271 -0
- package/examples/028-counter-improved-eventTest/store.d.ts +8 -0
- package/examples/028-counter-improved-eventTest/style.css +170 -0
- package/examples/028-counter-improved-eventTest/tests/README.md +208 -0
- package/examples/028-counter-improved-eventTest/tests/counter.test.js +116 -0
- package/examples/028-counter-improved-eventTest/tests/eventTest.js +176 -0
- package/examples/028-counter-improved-eventTest/tests/generateTypes.js +168 -0
- package/examples/028-counter-improved-eventTest/tests/run.js +20 -0
- package/examples/030-todo-app-with-eventTest/LICENSE +55 -0
- package/examples/030-todo-app-with-eventTest/README.md +121 -0
- package/examples/030-todo-app-with-eventTest/app/router.js +25 -0
- package/examples/030-todo-app-with-eventTest/app/store.js +16 -0
- package/examples/030-todo-app-with-eventTest/app/views/home.js +11 -0
- package/examples/030-todo-app-with-eventTest/app/views/todoDemo.js +88 -0
- package/examples/030-todo-app-with-eventTest/index.html +65 -0
- package/examples/030-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/examples/030-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
- package/examples/030-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
- package/examples/030-todo-app-with-eventTest/runtime/core/router.js +271 -0
- package/examples/030-todo-app-with-eventTest/store.d.ts +18 -0
- package/examples/030-todo-app-with-eventTest/style.css +170 -0
- package/examples/030-todo-app-with-eventTest/tests/README.md +208 -0
- package/examples/030-todo-app-with-eventTest/tests/eventTest.js +176 -0
- package/examples/030-todo-app-with-eventTest/tests/generateTypes.js +189 -0
- package/examples/030-todo-app-with-eventTest/tests/run.js +20 -0
- package/examples/030-todo-app-with-eventTest/tests/todos.test.js +167 -0
- package/examples/031-todo-app-with-eventTest/LICENSE +55 -0
- package/examples/031-todo-app-with-eventTest/README.md +54 -0
- package/examples/031-todo-app-with-eventTest/TUTORIAL.md +390 -0
- package/examples/031-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
- package/examples/031-todo-app-with-eventTest/app/bridges.js +113 -0
- package/examples/031-todo-app-with-eventTest/app/router.js +26 -0
- package/examples/031-todo-app-with-eventTest/app/store.js +15 -0
- package/examples/031-todo-app-with-eventTest/app/views/home.js +46 -0
- package/examples/031-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
- package/examples/031-todo-app-with-eventTest/devtools/dock.js +41 -0
- package/examples/031-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
- package/examples/031-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
- package/examples/031-todo-app-with-eventTest/devtools/telemetry.js +104 -0
- package/examples/031-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
- package/examples/031-todo-app-with-eventTest/index.html +103 -0
- package/examples/031-todo-app-with-eventTest/package-lock.json +2184 -0
- package/examples/031-todo-app-with-eventTest/package.json +24 -0
- package/examples/031-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/examples/031-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
- package/examples/031-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
- package/examples/031-todo-app-with-eventTest/runtime/core/router.js +271 -0
- package/examples/031-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
- package/examples/031-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
- package/examples/031-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
- package/examples/031-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
- package/examples/031-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
- package/examples/031-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
- package/examples/031-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
- package/examples/031-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
- package/examples/031-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
- package/examples/031-todo-app-with-eventTest/store.d.ts +23 -0
- package/examples/031-todo-app-with-eventTest/style.css +170 -0
- package/examples/031-todo-app-with-eventTest/tests/README.md +208 -0
- package/examples/031-todo-app-with-eventTest/tests/eventTest.js +176 -0
- package/examples/031-todo-app-with-eventTest/tests/generateTypes.js +191 -0
- package/examples/031-todo-app-with-eventTest/tests/run.js +20 -0
- package/examples/031-todo-app-with-eventTest/tests/todos.test.js +192 -0
- package/examples/032-todo-app-with-eventTest/LICENSE +55 -0
- package/examples/032-todo-app-with-eventTest/README.md +54 -0
- package/examples/032-todo-app-with-eventTest/TUTORIAL.md +390 -0
- package/examples/032-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
- package/examples/032-todo-app-with-eventTest/app/actions/index.js +153 -0
- package/examples/032-todo-app-with-eventTest/app/bridges.js +113 -0
- package/examples/032-todo-app-with-eventTest/app/router.js +26 -0
- package/examples/032-todo-app-with-eventTest/app/store.js +15 -0
- package/examples/032-todo-app-with-eventTest/app/views/home.js +46 -0
- package/examples/032-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
- package/examples/032-todo-app-with-eventTest/devtools/dock.js +41 -0
- package/examples/032-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
- package/examples/032-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
- package/examples/032-todo-app-with-eventTest/devtools/telemetry.js +104 -0
- package/examples/032-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
- package/examples/032-todo-app-with-eventTest/index.html +87 -0
- package/examples/032-todo-app-with-eventTest/package-lock.json +2184 -0
- package/examples/032-todo-app-with-eventTest/package.json +24 -0
- package/examples/032-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/examples/032-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
- package/examples/032-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
- package/examples/032-todo-app-with-eventTest/runtime/core/router.js +271 -0
- package/examples/032-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
- package/examples/032-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
- package/examples/032-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
- package/examples/032-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
- package/examples/032-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
- package/examples/032-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
- package/examples/032-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
- package/examples/032-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
- package/examples/032-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
- package/examples/032-todo-app-with-eventTest/store.d.ts +23 -0
- package/examples/032-todo-app-with-eventTest/style.css +170 -0
- package/examples/032-todo-app-with-eventTest/tests/README.md +208 -0
- package/examples/032-todo-app-with-eventTest/tests/eventTest.js +176 -0
- package/examples/032-todo-app-with-eventTest/tests/generateTypes.js +191 -0
- package/examples/032-todo-app-with-eventTest/tests/run.js +20 -0
- package/examples/032-todo-app-with-eventTest/tests/todos.test.js +192 -0
- package/index.js +14 -3
- package/package.json +16 -7
- package/stateSerializer.js +99 -4
- 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
|
+
}
|