@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,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "uistate-010-001-true-spa",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"test": "vitest run",
|
|
9
|
+
"test:watch": "vitest",
|
|
10
|
+
"test:unit": "vitest run vitest/todoDemo.spec.js",
|
|
11
|
+
"test:events": "vitest run vitest/todoDemo.eventtest.js",
|
|
12
|
+
"test:e2e": "playwright test",
|
|
13
|
+
"test:all": "npm run test:unit && npm run test:events && npm run test:e2e",
|
|
14
|
+
"build": "vite build",
|
|
15
|
+
"preview": "vite preview"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@playwright/test": "^1.48.0",
|
|
19
|
+
"@vitest/ui": "^2.1.9",
|
|
20
|
+
"jsdom": "^25.0.0",
|
|
21
|
+
"vite": "^5.4.0",
|
|
22
|
+
"vitest": "^2.0.5"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// behaviors.runtime.js — minimal parser + dispatcher for data-on/data-bind
|
|
2
|
+
// Install with: installBehaviors(store, { registry, root, writablePrefixes })
|
|
3
|
+
|
|
4
|
+
export function installBehaviors(store, { registry = {}, root = document, writablePrefixes = ['ui.'], writableWhitelist = [], debug = false, onStep = null } = {}){
|
|
5
|
+
const subsByPath = new Map();
|
|
6
|
+
const repeaters = [];
|
|
7
|
+
const trackOff = (el, off) => { const arr = subsByPath.get(el) || []; arr.push(off); subsByPath.set(el, arr); };
|
|
8
|
+
const unbindNode = (node) => {
|
|
9
|
+
// Unsubscribe any subs registered for this node and its descendants
|
|
10
|
+
const cleanup = (n) => {
|
|
11
|
+
const offs = subsByPath.get(n);
|
|
12
|
+
if (offs) { offs.forEach(off => { try { off(); } catch{} }); subsByPath.delete(n); }
|
|
13
|
+
n.childNodes && n.childNodes.forEach && n.childNodes.forEach(cleanup);
|
|
14
|
+
};
|
|
15
|
+
try { cleanup(node); } catch {}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const bindEl = (el) => {
|
|
19
|
+
// data-bind: "text: ui.counter; value: ui.name"
|
|
20
|
+
const bindAttr = el.getAttribute?.('data-bind');
|
|
21
|
+
if (bindAttr){
|
|
22
|
+
for (const part of bindAttr.split(';')){
|
|
23
|
+
const seg = part.trim(); if (!seg) continue;
|
|
24
|
+
const [propRaw, pathRaw] = seg.split(':');
|
|
25
|
+
const prop = (propRaw||'').trim();
|
|
26
|
+
const path = (pathRaw||'').trim();
|
|
27
|
+
const render = () => {
|
|
28
|
+
const v = store.get(path);
|
|
29
|
+
if (prop === 'text') el.textContent = v ?? '';
|
|
30
|
+
else if (prop === 'value') el.value = v ?? '';
|
|
31
|
+
else el.setAttribute(prop, v ?? '');
|
|
32
|
+
};
|
|
33
|
+
render();
|
|
34
|
+
const off = store.subscribe(path, render);
|
|
35
|
+
trackOff(el, off);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// data-on: "click: inc(ui.counter,1) | set(ui.last,'clicked'); input: setFrom(ui.name,target.value)"
|
|
40
|
+
const onAttr = el.getAttribute?.('data-on');
|
|
41
|
+
if (onAttr){
|
|
42
|
+
for (const rule of onAttr.split(';')){
|
|
43
|
+
const seg = rule.trim(); if (!seg) continue;
|
|
44
|
+
// Split only at the first ':' to avoid breaking on URLs like https://
|
|
45
|
+
const colonAt = seg.indexOf(':');
|
|
46
|
+
if (colonAt === -1) continue;
|
|
47
|
+
const evt = seg.slice(0, colonAt).trim();
|
|
48
|
+
const pipeRaw = seg.slice(colonAt + 1);
|
|
49
|
+
const steps = String(pipeRaw||'').split('|').map(s=>s.trim()).filter(Boolean);
|
|
50
|
+
el.addEventListener(evt, (event) => {
|
|
51
|
+
for (const step of steps){
|
|
52
|
+
const m = step.match(/^(\w+)\((.*)\)$/);
|
|
53
|
+
const name = m ? m[1] : step;
|
|
54
|
+
const argsStr = m ? m[2] : '';
|
|
55
|
+
const args = parseArgs(argsStr);
|
|
56
|
+
const fn = registry[name]; if (!fn) { if (console && console.warn) console.warn('[behaviors] missing action', name); continue; }
|
|
57
|
+
const ctx = {
|
|
58
|
+
store,
|
|
59
|
+
el,
|
|
60
|
+
event,
|
|
61
|
+
get: (p) => store.get(p),
|
|
62
|
+
set: (p, v) => {
|
|
63
|
+
// writable guards: prefixes AND optional whitelist patterns
|
|
64
|
+
const path = String(p);
|
|
65
|
+
const okPrefix = writablePrefixes.some(pref => path === pref.slice(0, -1) || path.startsWith(pref));
|
|
66
|
+
const okWhitelist = !writableWhitelist.length || writableWhitelist.some(pat => matchPattern(pat, path));
|
|
67
|
+
if (!(okPrefix && okWhitelist)) {
|
|
68
|
+
if (debug) console.warn('[behaviors] blocked write', { path, reason: 'guard' });
|
|
69
|
+
try { onStep && onStep({ el, event, name, args, phase: 'blocked', blocked: true, write: path, reason: 'guard' }); } catch{}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
store.set(p, v);
|
|
73
|
+
// annotate successful write
|
|
74
|
+
try { onStep && onStep({ el, event, name, args, phase: 'applied', blocked: false, write: path }); } catch{}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
try {
|
|
78
|
+
try { onStep && onStep({ el, event, name, args, phase: 'started', blocked: false }); } catch{}
|
|
79
|
+
fn(ctx, ...args);
|
|
80
|
+
} catch (e) { console.warn('[behaviors] action error', name, e); }
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// data-repeat: very small, read-only repeater with simple scope for item.*
|
|
87
|
+
// Example:
|
|
88
|
+
// <li data-repeat="item in ui.items" data-key="item.id"><span class="txt" data-bind="text: item.name"></span></li>
|
|
89
|
+
// <li data-repeat-empty>Empty</li>
|
|
90
|
+
const repExpr = el.getAttribute?.('data-repeat');
|
|
91
|
+
if (repExpr) {
|
|
92
|
+
const parsed = parseRepeat(repExpr);
|
|
93
|
+
if (!parsed) return;
|
|
94
|
+
const { itemName, idxName, listPath, keyExpr } = parsed;
|
|
95
|
+
// Detach template and install renderer anchored by a comment
|
|
96
|
+
const parent = el.parentNode;
|
|
97
|
+
if (!parent) return;
|
|
98
|
+
const anchor = document.createComment('repeat-anchor');
|
|
99
|
+
parent.insertBefore(anchor, el);
|
|
100
|
+
parent.removeChild(el);
|
|
101
|
+
|
|
102
|
+
const emptyEl = parent.querySelector?.('[data-repeat-empty]') || null;
|
|
103
|
+
|
|
104
|
+
// State for keyed reconciliation
|
|
105
|
+
const nodesByKey = new Map();
|
|
106
|
+
|
|
107
|
+
const renderList = () => {
|
|
108
|
+
const list = store.get(listPath) || [];
|
|
109
|
+
// Toggle empty placeholder
|
|
110
|
+
if (emptyEl) {
|
|
111
|
+
try {
|
|
112
|
+
const isEmpty = !Array.isArray(list) || list.length === 0;
|
|
113
|
+
if (isEmpty) { emptyEl.removeAttribute('hidden'); emptyEl.style && (emptyEl.style.display = ''); }
|
|
114
|
+
else { emptyEl.setAttribute('hidden', ''); emptyEl.style && (emptyEl.style.display = 'none'); }
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!Array.isArray(list)) return;
|
|
119
|
+
|
|
120
|
+
// Build a fragment in order with keyed clones
|
|
121
|
+
const frag = document.createDocumentFragment();
|
|
122
|
+
const nextNodesByKey = new Map();
|
|
123
|
+
|
|
124
|
+
for (let idx = 0; idx < list.length; idx++) {
|
|
125
|
+
const item = list[idx];
|
|
126
|
+
const key = resolveKey(item, idx, keyExpr);
|
|
127
|
+
let node = nodesByKey.get(key);
|
|
128
|
+
if (!node) {
|
|
129
|
+
node = el.cloneNode(true);
|
|
130
|
+
// Simple scoped binding for common cases used in tests:
|
|
131
|
+
// - text: item.name
|
|
132
|
+
// - attributes with {{item.id}}
|
|
133
|
+
// Apply text bindings manually
|
|
134
|
+
node.querySelectorAll?.('[data-bind]').forEach((n) => {
|
|
135
|
+
const bindAttr = n.getAttribute('data-bind') || '';
|
|
136
|
+
bindAttr.split(';').map(s => s.trim()).filter(Boolean).forEach((seg) => {
|
|
137
|
+
const [propRaw, pathRaw] = seg.split(':');
|
|
138
|
+
const prop = (propRaw||'').trim();
|
|
139
|
+
const pth = (pathRaw||'').trim();
|
|
140
|
+
let val;
|
|
141
|
+
if (pth === idxName && idxName) {
|
|
142
|
+
val = idx;
|
|
143
|
+
} else if (pth.startsWith(itemName + '.')) {
|
|
144
|
+
val = getByPath(item, pth.slice(itemName.length + 1));
|
|
145
|
+
}
|
|
146
|
+
if (pth === itemName) val = item; // not deeply rendered, but allow truthy check
|
|
147
|
+
if (val !== undefined) {
|
|
148
|
+
if (prop === 'text') n.textContent = val ?? '';
|
|
149
|
+
else if (prop === 'value') n.value = val ?? '';
|
|
150
|
+
else n.setAttribute(prop, val ?? '');
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
// Prevent behaviors runtime from also binding this node to store paths like "item.*"
|
|
154
|
+
n.removeAttribute('data-bind');
|
|
155
|
+
});
|
|
156
|
+
// Interpolate {{item.*}} in attributes used by tests (e.g., data-id)
|
|
157
|
+
node.querySelectorAll('*').forEach((n) => {
|
|
158
|
+
for (const attr of Array.from(n.attributes || [])){
|
|
159
|
+
const m = /\{\{\s*(?:item\.(.+?)|(idx))\s*\}\}/g;
|
|
160
|
+
if (m.test(attr.value)){
|
|
161
|
+
const replaced = attr.value.replace(/\{\{\s*(?:item\.(.+?)|(idx))\s*\}\}/g, (_, pItem, pIdx) => {
|
|
162
|
+
if (pIdx && idxName) return String(idx);
|
|
163
|
+
const v = getByPath(item, pItem);
|
|
164
|
+
return v == null ? '' : String(v);
|
|
165
|
+
});
|
|
166
|
+
n.setAttribute(attr.name, replaced);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
nextNodesByKey.set(key, node);
|
|
172
|
+
frag.appendChild(node);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Replace current range (between anchor and next non-render node) with frag
|
|
176
|
+
// Simple strategy: remove all nodes after anchor until a repeat-empty (which we keep)
|
|
177
|
+
let cursor = anchor.nextSibling;
|
|
178
|
+
while (cursor && cursor !== emptyEl) {
|
|
179
|
+
const next = cursor.nextSibling;
|
|
180
|
+
parent.removeChild(cursor);
|
|
181
|
+
cursor = next;
|
|
182
|
+
}
|
|
183
|
+
parent.insertBefore(frag, emptyEl || null);
|
|
184
|
+
nodesByKey.clear();
|
|
185
|
+
nextNodesByKey.forEach((v,k)=>nodesByKey.set(k,v));
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
renderList();
|
|
189
|
+
const off = store.subscribe(listPath, renderList);
|
|
190
|
+
repeaters.push({ off });
|
|
191
|
+
trackOff(el, off);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// initial scan
|
|
196
|
+
const all = root.querySelectorAll?.('[data-bind], [data-on], [data-repeat]') || [];
|
|
197
|
+
all.forEach(bindEl);
|
|
198
|
+
|
|
199
|
+
// observe new nodes
|
|
200
|
+
const mo = new MutationObserver((mutList) => {
|
|
201
|
+
for (const m of mutList){
|
|
202
|
+
m.addedNodes && m.addedNodes.forEach(node => {
|
|
203
|
+
if (node.nodeType !== 1) return;
|
|
204
|
+
if (node.matches?.('[data-bind], [data-on], [data-repeat]')) bindEl(node);
|
|
205
|
+
node.querySelectorAll?.('[data-bind], [data-on], [data-repeat]').forEach(bindEl);
|
|
206
|
+
});
|
|
207
|
+
// cleanup removed
|
|
208
|
+
m.removedNodes && m.removedNodes.forEach(node => {
|
|
209
|
+
if (node.nodeType !== 1) return;
|
|
210
|
+
unbindNode(node);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
if (root && root instanceof Document) mo.observe(document.body, { childList: true, subtree: true });
|
|
215
|
+
|
|
216
|
+
return () => {
|
|
217
|
+
mo.disconnect();
|
|
218
|
+
subsByPath.forEach(offs => offs.forEach(off => { try { off(); } catch{} }));
|
|
219
|
+
subsByPath.clear();
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseArgs(s){
|
|
224
|
+
if (!s.trim()) return [];
|
|
225
|
+
// split by commas not inside quotes
|
|
226
|
+
const parts = s.split(',').map(p=>p.trim());
|
|
227
|
+
return parts.map(coerceArg);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function coerceArg(x){
|
|
231
|
+
if (x === '') return '';
|
|
232
|
+
if ((x.startsWith('"') && x.endsWith('"')) || (x.startsWith("'") && x.endsWith("'"))) return x.slice(1,-1);
|
|
233
|
+
if (x === 'true') return true;
|
|
234
|
+
if (x === 'false') return false;
|
|
235
|
+
if (x === 'null') return null;
|
|
236
|
+
if (!Number.isNaN(Number(x))) return Number(x);
|
|
237
|
+
return x; // pass path strings etc.
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function matchPattern(pattern, path){
|
|
241
|
+
// Very small wildcard: '*' matches a single segment, '**' matches the rest
|
|
242
|
+
if (pattern === path) return true;
|
|
243
|
+
const pSegs = String(pattern).split('.');
|
|
244
|
+
const sSegs = String(path).split('.');
|
|
245
|
+
let i = 0, j = 0;
|
|
246
|
+
while (i < pSegs.length && j < sSegs.length){
|
|
247
|
+
const part = pSegs[i];
|
|
248
|
+
if (part === '**'){ return true; }
|
|
249
|
+
if (part === '*' || part === sSegs[j]){ i++; j++; continue; }
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
// Allow trailing '**'
|
|
253
|
+
while (i < pSegs.length && pSegs[i] === '**') i++;
|
|
254
|
+
return i === pSegs.length && j === sSegs.length;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Dev-only explicit export to support unit tests in 010-005
|
|
258
|
+
export { matchPattern };
|
|
259
|
+
|
|
260
|
+
function parseRepeat(expr){
|
|
261
|
+
// "item in ui.items" or "(item, idx) in ui.items"
|
|
262
|
+
const m = String(expr).match(/^\s*\(?\s*([a-zA-Z_$][\w$]*)\s*(?:,\s*([a-zA-Z_$][\w$]*))?\s*\)?\s+in\s+([\w$.]+)\s*$/);
|
|
263
|
+
if (!m) return null;
|
|
264
|
+
const itemName = m[1];
|
|
265
|
+
const idxName = m[2] || null;
|
|
266
|
+
const listPath = m[3];
|
|
267
|
+
// Optional data-key attribute is read from element attribute when rendering
|
|
268
|
+
return { itemName, idxName, listPath, keyExpr: `${itemName}.id` };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function resolveKey(item, idx, keyExpr){
|
|
272
|
+
// For now only support item.id; fallback to index
|
|
273
|
+
const v = (item && (item.id != null)) ? item.id : idx;
|
|
274
|
+
return String(v);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function getByPath(obj, path){
|
|
278
|
+
const segs = String(path).split('.');
|
|
279
|
+
let cur = obj;
|
|
280
|
+
for (const s of segs){ if (cur == null) return undefined; cur = cur[s]; }
|
|
281
|
+
return cur;
|
|
282
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UIstate - Event-based hierarchical state management module
|
|
3
|
+
* Part of the UIstate declarative state management system
|
|
4
|
+
* Uses DOM events for pub/sub with hierarchical path support
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const createEventState = (initial = {}) => {
|
|
8
|
+
// // Clone the initial state to avoid direct mutations to the passed object
|
|
9
|
+
const store = JSON.parse(JSON.stringify(initial));
|
|
10
|
+
|
|
11
|
+
// // Create a dedicated DOM element to use as an event bus
|
|
12
|
+
// const bus = document.createElement("x-store");
|
|
13
|
+
|
|
14
|
+
// // Optional: Keep the bus element off the actual DOM for better encapsulation
|
|
15
|
+
// // but this isn't strictly necessary for functionality
|
|
16
|
+
// bus.style.display = "none";
|
|
17
|
+
// document.documentElement.appendChild(bus);
|
|
18
|
+
|
|
19
|
+
const bus = new EventTarget();
|
|
20
|
+
return {
|
|
21
|
+
// get a value from the store by path
|
|
22
|
+
get: (path) => {
|
|
23
|
+
if (!path) return store;
|
|
24
|
+
return path
|
|
25
|
+
.split(".")
|
|
26
|
+
.reduce(
|
|
27
|
+
(obj, prop) =>
|
|
28
|
+
obj && obj[prop] !== undefined ? obj[prop] : undefined,
|
|
29
|
+
store
|
|
30
|
+
);
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// set a value in the store by path
|
|
34
|
+
set: (path, value) => {
|
|
35
|
+
if(!path) return;
|
|
36
|
+
|
|
37
|
+
// Update the store
|
|
38
|
+
let target = store;
|
|
39
|
+
const parts = path.split(".");
|
|
40
|
+
const last = parts.pop();
|
|
41
|
+
|
|
42
|
+
// Create the path if it doesn't exist
|
|
43
|
+
parts.forEach((part) => {
|
|
44
|
+
if (!target[part] || typeof target[part] !== "object") {
|
|
45
|
+
target[part] = {};
|
|
46
|
+
}
|
|
47
|
+
target = target[part];
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Set the value
|
|
51
|
+
target[last] = value;
|
|
52
|
+
|
|
53
|
+
// Notify subscribers with a DOM event
|
|
54
|
+
bus.dispatchEvent(new CustomEvent(path, { detail: value }));
|
|
55
|
+
|
|
56
|
+
// Also dispatch events for parent paths to support wildcards
|
|
57
|
+
if (parts.length > 0) {
|
|
58
|
+
let parentPath = "";
|
|
59
|
+
for (const part of parts) {
|
|
60
|
+
parentPath = parentPath ? `${parentPath}.${part}` : part;
|
|
61
|
+
bus.dispatchEvent(
|
|
62
|
+
new CustomEvent(`${parentPath}.*`, {
|
|
63
|
+
detail: { path, value },
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Dispatch root wildcard for any state change
|
|
69
|
+
bus.dispatchEvent(
|
|
70
|
+
new CustomEvent("*", {
|
|
71
|
+
detail: { path, value},
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return value;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
// Subscribe to changes on a path
|
|
80
|
+
subscribe: (path, callback) => {
|
|
81
|
+
if (!path || typeof callback !== "function") return () => {};
|
|
82
|
+
|
|
83
|
+
const handler = (e) => callback(e.detail, path);
|
|
84
|
+
bus.addEventListener(path, handler);
|
|
85
|
+
|
|
86
|
+
return () => bus.removeEventListener(path, handler);
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// Optional method to clean up resources
|
|
90
|
+
destroy: () => {
|
|
91
|
+
if (bus.parentNode) {
|
|
92
|
+
bus.parentNode.removeChild(bus);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export default createEventState;
|
|
100
|
+
export { createEventState };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// helpers.js
|
|
2
|
+
export const intent = (store, name, payload = true) => store.set(`intent.${name}`, payload);
|
|
3
|
+
|
|
4
|
+
export const bindIntentClicks = (root, store, payloadFromEvent) => {
|
|
5
|
+
root.addEventListener('click', (e) => {
|
|
6
|
+
const t = e.target.closest('[data-intent]');
|
|
7
|
+
if (!t) return;
|
|
8
|
+
const name = t.dataset.intent;
|
|
9
|
+
const payload = payloadFromEvent ? payloadFromEvent(e, t) : true;
|
|
10
|
+
store.set(`intent.${name}`, payload);
|
|
11
|
+
});
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const mount = (a, b) => {
|
|
15
|
+
// Overloads:
|
|
16
|
+
// - mount(selectorsMap) -> root defaults to document
|
|
17
|
+
// - mount(root, selectorsMap)
|
|
18
|
+
let root, selectors;
|
|
19
|
+
if (typeof b === 'undefined') {
|
|
20
|
+
selectors = a;
|
|
21
|
+
root = document;
|
|
22
|
+
} else {
|
|
23
|
+
root = a;
|
|
24
|
+
selectors = b;
|
|
25
|
+
}
|
|
26
|
+
const entries = Object.entries(selectors).map(([k, sel]) => {
|
|
27
|
+
const el = typeof sel === 'string' ? root.querySelector(sel) : sel;
|
|
28
|
+
return [k, el];
|
|
29
|
+
});
|
|
30
|
+
return Object.fromEntries(entries);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const renderJson = (el, getSnapshot) => {
|
|
34
|
+
try { el.textContent = JSON.stringify(getSnapshot(), null, 2); }
|
|
35
|
+
catch { el.textContent = String(getSnapshot()); }
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Simple plug-and-play state panel. Attempts to use a provided target (selector or element),
|
|
39
|
+
// then '#state' if present, otherwise creates a floating <pre> panel in the bottom-right.
|
|
40
|
+
export const showStatePanel = (store, target) => {
|
|
41
|
+
let el = null;
|
|
42
|
+
if (typeof target === 'string') {
|
|
43
|
+
el = document.querySelector(target);
|
|
44
|
+
} else if (target && target.nodeType === 1) {
|
|
45
|
+
el = target; // DOM Element
|
|
46
|
+
}
|
|
47
|
+
if (!el) {
|
|
48
|
+
el = document.querySelector('#state');
|
|
49
|
+
}
|
|
50
|
+
if (!el) {
|
|
51
|
+
el = document.createElement('pre');
|
|
52
|
+
el.setAttribute('id', 'state');
|
|
53
|
+
Object.assign(el.style, {
|
|
54
|
+
position: 'fixed', right: '8px', bottom: '8px',
|
|
55
|
+
minWidth: '240px', maxWidth: '40vw', maxHeight: '40vh', overflow: 'auto',
|
|
56
|
+
padding: '8px', background: 'rgba(0,0,0,0.7)', color: '#0f0',
|
|
57
|
+
font: '12px/1.4 monospace', borderRadius: '6px', zIndex: 99999,
|
|
58
|
+
boxShadow: '0 2px 12px rgba(0,0,0,0.35)'
|
|
59
|
+
});
|
|
60
|
+
document.body.appendChild(el);
|
|
61
|
+
}
|
|
62
|
+
const render = () => {
|
|
63
|
+
try { el.textContent = JSON.stringify(store.get(), null, 2); }
|
|
64
|
+
catch { el.textContent = String(store.get()); }
|
|
65
|
+
};
|
|
66
|
+
store.subscribe('*', render);
|
|
67
|
+
render();
|
|
68
|
+
return el;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Subscribe multiple path/handler pairs at once. Returns a SubGroup with:
|
|
72
|
+
// - dispose(): unsubscribe all
|
|
73
|
+
// - unsubs: individual unsubscribe functions (in pair order)
|
|
74
|
+
// - byPath: Record<string, Function[]> to selectively dispose by path
|
|
75
|
+
// - size: number of subscriptions created
|
|
76
|
+
export const groupSubs = (store, ...args) => {
|
|
77
|
+
if (args.length % 2 !== 0) {
|
|
78
|
+
throw new Error('groupSubs expects alternating path/handler pairs');
|
|
79
|
+
}
|
|
80
|
+
const unsubs = [];
|
|
81
|
+
const byPath = Object.create(null);
|
|
82
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
83
|
+
const path = args[i];
|
|
84
|
+
const handler = args[i + 1];
|
|
85
|
+
const unsub = store.subscribe(path, handler);
|
|
86
|
+
unsubs.push(unsub);
|
|
87
|
+
(byPath[path] || (byPath[path] = [])).push(unsub);
|
|
88
|
+
}
|
|
89
|
+
const dispose = () => {
|
|
90
|
+
for (let i = unsubs.length - 1; i >= 0; i--) {
|
|
91
|
+
try { unsubs[i](); } catch (_) { /* noop */ }
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
return { dispose, unsubs, byPath, size: unsubs.length };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Console logger for state changes; subscribes to '*' wildcard and logs path/value.
|
|
98
|
+
// Optional custom formatter receives the event object `{ path, value }`.
|
|
99
|
+
export const consoleLogState = (store, formatter) => {
|
|
100
|
+
const handler = (evt) => {
|
|
101
|
+
const payload = evt && typeof evt === 'object' && 'path' in evt ? evt : { path: '*', value: store.get() };
|
|
102
|
+
if (formatter) return formatter(payload);
|
|
103
|
+
console.log('[state]', payload.path, payload.value);
|
|
104
|
+
};
|
|
105
|
+
return store.subscribe('*', handler);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// JS-only wiring helpers (no DOM data-* exposure)
|
|
109
|
+
export const onClick = (el, handler) => {
|
|
110
|
+
el.addEventListener('click', handler);
|
|
111
|
+
return () => el.removeEventListener('click', handler);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const bump = (store, path, delta = 1) => {
|
|
115
|
+
const n = (store.get(path) || 0) + delta;
|
|
116
|
+
store.set(path, n);
|
|
117
|
+
};
|
|
118
|
+
export const inc = (store, path) => bump(store, path, 1);
|
|
119
|
+
export const dec = (store, path) => bump(store, path, -1);
|
|
120
|
+
|
|
121
|
+
// Intent wiring without using data-* attributes
|
|
122
|
+
export const bindIntent = (store, el, name, payloadFromEvent) => {
|
|
123
|
+
const listener = (e) => {
|
|
124
|
+
const payload = payloadFromEvent ? payloadFromEvent(e, el) : true;
|
|
125
|
+
store.set(`intent.${name}`, payload);
|
|
126
|
+
};
|
|
127
|
+
el.addEventListener('click', listener);
|
|
128
|
+
return () => el.removeEventListener('click', listener);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const bindIntents = (store, tuples) => {
|
|
132
|
+
const unsubs = tuples.map(([el, name, payloadFromEvent]) => bindIntent(store, el, name, payloadFromEvent));
|
|
133
|
+
return () => { for (let i = unsubs.length - 1; i >= 0; i--) { try { unsubs[i](); } catch (_) {} } };
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Orchestrator: initialize a view by mounting elements, subscribing handlers, wiring events, and optional dev tools.
|
|
137
|
+
// API:
|
|
138
|
+
// initView({
|
|
139
|
+
// store, // required
|
|
140
|
+
// mount: selectors | [root, selectors],
|
|
141
|
+
// paths: { ALIAS: 'a.b.c' }, // optional path aliases
|
|
142
|
+
// view: [ ['path', (value, els, store, p) => { /* render */ }], ... ],
|
|
143
|
+
// dev: { log: true, panel: true },
|
|
144
|
+
// events: (els, store, p) => [ /* array of unsubs */ ],
|
|
145
|
+
// })
|
|
146
|
+
export const initView = (cfg) => {
|
|
147
|
+
if (!cfg || !cfg.store) throw new Error('initView: cfg.store is required');
|
|
148
|
+
const store = cfg.store;
|
|
149
|
+
const mountCfg = cfg.mount;
|
|
150
|
+
const p = Object.assign({}, cfg.paths || {});
|
|
151
|
+
const dev = cfg.dev || {};
|
|
152
|
+
|
|
153
|
+
// Resolve elements once
|
|
154
|
+
const els = Array.isArray(mountCfg) ? mount(mountCfg[0], mountCfg[1]) : mount(mountCfg || {});
|
|
155
|
+
|
|
156
|
+
// Build grouped subscriptions from view tuples, wrapping to inject (els, store, p)
|
|
157
|
+
const viewTuples = (cfg.view || []).flatMap(([path, handler]) => {
|
|
158
|
+
let wrapped;
|
|
159
|
+
if (typeof handler === 'string') {
|
|
160
|
+
const key = handler;
|
|
161
|
+
wrapped = (value) => {
|
|
162
|
+
const el = els[key];
|
|
163
|
+
if (el) el.textContent = String(value);
|
|
164
|
+
};
|
|
165
|
+
} else {
|
|
166
|
+
wrapped = (value) => handler && handler(value, els, store, p);
|
|
167
|
+
}
|
|
168
|
+
return [path, wrapped];
|
|
169
|
+
});
|
|
170
|
+
const subs = viewTuples.length ? groupSubs(store, ...viewTuples) : { dispose(){} };
|
|
171
|
+
|
|
172
|
+
// Dev tools
|
|
173
|
+
const devUnsubs = [];
|
|
174
|
+
if (dev.log) {
|
|
175
|
+
try { devUnsubs.push(consoleLogState(store)); } catch (_) {}
|
|
176
|
+
}
|
|
177
|
+
let panelEl = null;
|
|
178
|
+
if (dev.panel) {
|
|
179
|
+
try { panelEl = showStatePanel(store); } catch (_) {}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Events wiring
|
|
183
|
+
let eventUnsubs = [];
|
|
184
|
+
if (typeof cfg.events === 'function') {
|
|
185
|
+
try { eventUnsubs = cfg.events(els, store, p) || []; } catch (_) { eventUnsubs = []; }
|
|
186
|
+
} else if (cfg.events && typeof cfg.events === 'object') {
|
|
187
|
+
// Sugar: events map { elKey: (store, paths, el) => void | unsub }
|
|
188
|
+
const map = cfg.events;
|
|
189
|
+
eventUnsubs = Object.entries(map).map(([key, fn]) => {
|
|
190
|
+
const el = els[key];
|
|
191
|
+
if (!el || typeof fn !== 'function') return () => {};
|
|
192
|
+
// Default to click wiring; allow handler to return its own unsub.
|
|
193
|
+
const handler = () => fn(store, p, el);
|
|
194
|
+
el.addEventListener('click', handler);
|
|
195
|
+
return () => el.removeEventListener('click', handler);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Unified disposer
|
|
200
|
+
const dispose = () => {
|
|
201
|
+
try { subs && subs.dispose && subs.dispose(); } catch (_) {}
|
|
202
|
+
for (let i = eventUnsubs.length - 1; i >= 0; i--) {
|
|
203
|
+
try { eventUnsubs[i] && eventUnsubs[i](); } catch (_) {}
|
|
204
|
+
}
|
|
205
|
+
for (let i = devUnsubs.length - 1; i >= 0; i--) {
|
|
206
|
+
try { devUnsubs[i] && devUnsubs[i](); } catch (_) {}
|
|
207
|
+
}
|
|
208
|
+
// Note: showStatePanel returns an element; we do not auto-remove it by default.
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
return { els, paths: p, subs, eventUnsubs, devUnsubs, dispose };
|
|
212
|
+
};
|