@uistate/examples 1.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/README.md +40 -0
- package/cssState/.gitkeep +0 -0
- package/eventState/001-counter/README.md +44 -0
- package/eventState/001-counter/index.html +33 -0
- package/eventState/002-counter-improved/README.md +44 -0
- package/eventState/002-counter-improved/index.html +47 -0
- package/eventState/003-input-reactive/README.md +44 -0
- package/eventState/003-input-reactive/index.html +33 -0
- package/eventState/004-computed-state/README.md +45 -0
- package/eventState/004-computed-state/index.html +65 -0
- package/eventState/005-conditional-rendering/README.md +42 -0
- package/eventState/005-conditional-rendering/index.html +39 -0
- package/eventState/006-list-rendering/README.md +49 -0
- package/eventState/006-list-rendering/index.html +63 -0
- package/eventState/007-form-validation/README.md +52 -0
- package/eventState/007-form-validation/index.html +102 -0
- package/eventState/008-undo-redo/README.md +70 -0
- package/eventState/008-undo-redo/index.html +108 -0
- package/eventState/009-localStorage-side-effects/README.md +72 -0
- package/eventState/009-localStorage-side-effects/index.html +57 -0
- package/eventState/010-decoupled-components/README.md +74 -0
- package/eventState/010-decoupled-components/index.html +93 -0
- package/eventState/011-async-patterns/README.md +98 -0
- package/eventState/011-async-patterns/index.html +132 -0
- package/eventState/028-counter-improved-eventTest/LICENSE +55 -0
- package/eventState/028-counter-improved-eventTest/README.md +131 -0
- package/eventState/028-counter-improved-eventTest/app/store.js +9 -0
- package/eventState/028-counter-improved-eventTest/index.html +49 -0
- package/eventState/028-counter-improved-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/eventState/028-counter-improved-eventTest/runtime/core/eventState.js +100 -0
- package/eventState/028-counter-improved-eventTest/runtime/core/eventStateNew.js +149 -0
- package/eventState/028-counter-improved-eventTest/runtime/core/helpers.js +212 -0
- package/eventState/028-counter-improved-eventTest/runtime/core/router.js +271 -0
- package/eventState/028-counter-improved-eventTest/store.d.ts +8 -0
- package/eventState/028-counter-improved-eventTest/style.css +170 -0
- package/eventState/028-counter-improved-eventTest/tests/README.md +208 -0
- package/eventState/028-counter-improved-eventTest/tests/counter.test.js +116 -0
- package/eventState/028-counter-improved-eventTest/tests/eventTest.js +176 -0
- package/eventState/028-counter-improved-eventTest/tests/generateTypes.js +168 -0
- package/eventState/028-counter-improved-eventTest/tests/run.js +20 -0
- package/eventState/030-todo-app-with-eventTest/LICENSE +55 -0
- package/eventState/030-todo-app-with-eventTest/README.md +121 -0
- package/eventState/030-todo-app-with-eventTest/app/router.js +25 -0
- package/eventState/030-todo-app-with-eventTest/app/store.js +16 -0
- package/eventState/030-todo-app-with-eventTest/app/views/home.js +11 -0
- package/eventState/030-todo-app-with-eventTest/app/views/todoDemo.js +88 -0
- package/eventState/030-todo-app-with-eventTest/index.html +65 -0
- package/eventState/030-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/eventState/030-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
- package/eventState/030-todo-app-with-eventTest/runtime/core/eventStateNew.js +149 -0
- package/eventState/030-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
- package/eventState/030-todo-app-with-eventTest/runtime/core/router.js +271 -0
- package/eventState/030-todo-app-with-eventTest/store.d.ts +18 -0
- package/eventState/030-todo-app-with-eventTest/style.css +170 -0
- package/eventState/030-todo-app-with-eventTest/tests/README.md +208 -0
- package/eventState/030-todo-app-with-eventTest/tests/eventTest.js +176 -0
- package/eventState/030-todo-app-with-eventTest/tests/generateTypes.js +189 -0
- package/eventState/030-todo-app-with-eventTest/tests/run.js +20 -0
- package/eventState/030-todo-app-with-eventTest/tests/todos.test.js +167 -0
- package/eventState/031-todo-app-with-eventTest/LICENSE +55 -0
- package/eventState/031-todo-app-with-eventTest/README.md +54 -0
- package/eventState/031-todo-app-with-eventTest/TUTORIAL.md +390 -0
- package/eventState/031-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
- package/eventState/031-todo-app-with-eventTest/app/bridges.js +113 -0
- package/eventState/031-todo-app-with-eventTest/app/router.js +26 -0
- package/eventState/031-todo-app-with-eventTest/app/store.js +15 -0
- package/eventState/031-todo-app-with-eventTest/app/views/home.js +46 -0
- package/eventState/031-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
- package/eventState/031-todo-app-with-eventTest/devtools/dock.js +41 -0
- package/eventState/031-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
- package/eventState/031-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
- package/eventState/031-todo-app-with-eventTest/devtools/telemetry.js +104 -0
- package/eventState/031-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
- package/eventState/031-todo-app-with-eventTest/index.html +103 -0
- package/eventState/031-todo-app-with-eventTest/package-lock.json +2184 -0
- package/eventState/031-todo-app-with-eventTest/package.json +24 -0
- package/eventState/031-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/eventState/031-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
- package/eventState/031-todo-app-with-eventTest/runtime/core/eventStateNew.js +149 -0
- package/eventState/031-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
- package/eventState/031-todo-app-with-eventTest/runtime/core/router.js +271 -0
- package/eventState/031-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
- package/eventState/031-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
- package/eventState/031-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
- package/eventState/031-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
- package/eventState/031-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
- package/eventState/031-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
- package/eventState/031-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
- package/eventState/031-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
- package/eventState/031-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
- package/eventState/031-todo-app-with-eventTest/store.d.ts +23 -0
- package/eventState/031-todo-app-with-eventTest/style.css +170 -0
- package/eventState/031-todo-app-with-eventTest/tests/README.md +208 -0
- package/eventState/031-todo-app-with-eventTest/tests/eventTest.js +176 -0
- package/eventState/031-todo-app-with-eventTest/tests/generateTypes.js +191 -0
- package/eventState/031-todo-app-with-eventTest/tests/run.js +20 -0
- package/eventState/031-todo-app-with-eventTest/tests/todos.test.js +192 -0
- package/eventState/032-todo-app-with-eventTest/LICENSE +55 -0
- package/eventState/032-todo-app-with-eventTest/README.md +54 -0
- package/eventState/032-todo-app-with-eventTest/TUTORIAL.md +390 -0
- package/eventState/032-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
- package/eventState/032-todo-app-with-eventTest/app/actions/index.js +153 -0
- package/eventState/032-todo-app-with-eventTest/app/bridges.js +113 -0
- package/eventState/032-todo-app-with-eventTest/app/router.js +26 -0
- package/eventState/032-todo-app-with-eventTest/app/store.js +15 -0
- package/eventState/032-todo-app-with-eventTest/app/views/home.js +46 -0
- package/eventState/032-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
- package/eventState/032-todo-app-with-eventTest/devtools/dock.js +41 -0
- package/eventState/032-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
- package/eventState/032-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
- package/eventState/032-todo-app-with-eventTest/devtools/telemetry.js +104 -0
- package/eventState/032-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
- package/eventState/032-todo-app-with-eventTest/index.html +87 -0
- package/eventState/032-todo-app-with-eventTest/package-lock.json +2184 -0
- package/eventState/032-todo-app-with-eventTest/package.json +24 -0
- package/eventState/032-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/eventState/032-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
- package/eventState/032-todo-app-with-eventTest/runtime/core/eventStateNew.js +149 -0
- package/eventState/032-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
- package/eventState/032-todo-app-with-eventTest/runtime/core/router.js +271 -0
- package/eventState/032-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
- package/eventState/032-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
- package/eventState/032-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
- package/eventState/032-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
- package/eventState/032-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
- package/eventState/032-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
- package/eventState/032-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
- package/eventState/032-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
- package/eventState/032-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
- package/eventState/032-todo-app-with-eventTest/store.d.ts +23 -0
- package/eventState/032-todo-app-with-eventTest/style.css +170 -0
- package/eventState/032-todo-app-with-eventTest/tests/README.md +208 -0
- package/eventState/032-todo-app-with-eventTest/tests/eventTest.js +176 -0
- package/eventState/032-todo-app-with-eventTest/tests/generateTypes.js +191 -0
- package/eventState/032-todo-app-with-eventTest/tests/run.js +20 -0
- package/eventState/032-todo-app-with-eventTest/tests/todos.test.js +192 -0
- package/package.json +27 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventState v2 - Optimized Path-Based State Management
|
|
3
|
+
*
|
|
4
|
+
* A lightweight, performant state management library using path-based subscriptions.
|
|
5
|
+
* Optimized for selective notifications and granular updates.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Path-based get/set operations (e.g., 'user.profile.name')
|
|
9
|
+
* - Selective subscriptions (only relevant subscribers fire)
|
|
10
|
+
* - Wildcard subscriptions (e.g., 'user.*' catches all user changes)
|
|
11
|
+
* - Global subscriptions (e.g., '*' catches all changes)
|
|
12
|
+
* - Zero dependencies
|
|
13
|
+
* - ~2KB minified
|
|
14
|
+
*
|
|
15
|
+
* Performance characteristics:
|
|
16
|
+
* - 2-9x faster than Zustand for selective subscriptions
|
|
17
|
+
* - Competitive overall performance
|
|
18
|
+
* - Minimal rendering overhead (1.27x faster paint times)
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const store = createEventState({ count: 0, user: { name: 'Alice' } });
|
|
22
|
+
*
|
|
23
|
+
* // Subscribe to specific path (receives value directly)
|
|
24
|
+
* const unsub = store.subscribe('count', (value) => {
|
|
25
|
+
* console.log('Count changed:', value);
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* // Update state
|
|
29
|
+
* store.set('count', 1);
|
|
30
|
+
*
|
|
31
|
+
* // Get state
|
|
32
|
+
* const count = store.get('count');
|
|
33
|
+
*
|
|
34
|
+
* // Wildcard subscription
|
|
35
|
+
* store.subscribe('user.*', ({ path, value }) => {
|
|
36
|
+
* console.log(`User field ${path} changed to:`, value);
|
|
37
|
+
* });
|
|
38
|
+
*
|
|
39
|
+
* // Global subscription
|
|
40
|
+
* store.subscribe('*', ({ path, value }) => {
|
|
41
|
+
* console.log(`State changed at ${path}:`, value);
|
|
42
|
+
* });
|
|
43
|
+
*
|
|
44
|
+
* // Cleanup
|
|
45
|
+
* unsub();
|
|
46
|
+
* store.destroy();
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
export function createEventState(initial = {}) {
|
|
50
|
+
const state = JSON.parse(JSON.stringify(initial));
|
|
51
|
+
const listeners = new Map();
|
|
52
|
+
let destroyed = false;
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
/**
|
|
56
|
+
* Get value at path
|
|
57
|
+
* @param {string} path - Dot-separated path (e.g., 'user.profile.name')
|
|
58
|
+
* @returns {*} Value at path, or entire state if no path provided
|
|
59
|
+
*/
|
|
60
|
+
get(path) {
|
|
61
|
+
if (destroyed) throw new Error('Cannot get from destroyed store');
|
|
62
|
+
if (!path) return state;
|
|
63
|
+
return path.split(".").reduce((obj, key) => obj?.[key], state);
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Set value at path and notify subscribers
|
|
68
|
+
* @param {string} path - Dot-separated path (e.g., 'user.profile.name')
|
|
69
|
+
* @param {*} value - New value
|
|
70
|
+
* @returns {*} The value that was set
|
|
71
|
+
*/
|
|
72
|
+
set(path, value) {
|
|
73
|
+
if (destroyed) throw new Error('Cannot set on destroyed store');
|
|
74
|
+
if (!path) return value;
|
|
75
|
+
|
|
76
|
+
const parts = path.split(".");
|
|
77
|
+
const key = parts.pop();
|
|
78
|
+
let cur = state;
|
|
79
|
+
|
|
80
|
+
// Navigate to parent object, creating nested objects as needed
|
|
81
|
+
for (const p of parts) {
|
|
82
|
+
if (!cur[p]) cur[p] = {};
|
|
83
|
+
cur = cur[p];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const oldValue = cur[key];
|
|
87
|
+
cur[key] = value;
|
|
88
|
+
|
|
89
|
+
if (!destroyed) {
|
|
90
|
+
const detail = { path, value, oldValue };
|
|
91
|
+
|
|
92
|
+
// Notify exact path subscribers (pass value directly for backwards compatibility)
|
|
93
|
+
const exactListeners = listeners.get(path);
|
|
94
|
+
if (exactListeners) {
|
|
95
|
+
exactListeners.forEach(cb => cb(value));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Notify wildcard subscribers for all parent paths (pass detail object)
|
|
99
|
+
for (let i = 0; i < parts.length; i++) {
|
|
100
|
+
const parentPath = parts.slice(0, i + 1).join('.');
|
|
101
|
+
const wildcardListeners = listeners.get(`${parentPath}.*`);
|
|
102
|
+
if (wildcardListeners) {
|
|
103
|
+
wildcardListeners.forEach(cb => cb(detail));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Notify global subscribers (pass detail object)
|
|
108
|
+
const globalListeners = listeners.get('*');
|
|
109
|
+
if (globalListeners) {
|
|
110
|
+
globalListeners.forEach(cb => cb(detail));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return value;
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Subscribe to changes at path
|
|
119
|
+
* @param {string} path - Path to subscribe to (supports wildcards: 'user.*', '*')
|
|
120
|
+
* @param {Function} handler - Callback function receiving { path, value, oldValue }
|
|
121
|
+
* @returns {Function} Unsubscribe function
|
|
122
|
+
*/
|
|
123
|
+
subscribe(path, handler) {
|
|
124
|
+
if (destroyed) throw new Error('Cannot subscribe to destroyed store');
|
|
125
|
+
if (!path || typeof handler !== 'function') {
|
|
126
|
+
throw new TypeError('subscribe requires path and handler');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!listeners.has(path)) {
|
|
130
|
+
listeners.set(path, new Set());
|
|
131
|
+
}
|
|
132
|
+
listeners.get(path).add(handler);
|
|
133
|
+
|
|
134
|
+
return () => listeners.get(path)?.delete(handler);
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Destroy store and clear all subscriptions
|
|
139
|
+
*/
|
|
140
|
+
destroy() {
|
|
141
|
+
if (!destroyed) {
|
|
142
|
+
destroyed = true;
|
|
143
|
+
listeners.clear();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export default 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
|
+
};
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// runtime/core/router.js — Generic SPA router factory for eventState stores
|
|
2
|
+
// Usage:
|
|
3
|
+
// const router = createRouter({
|
|
4
|
+
// routes: [{ path: '/', view: 'home', component: HomeView }],
|
|
5
|
+
// store,
|
|
6
|
+
// rootSelector: '[data-route-root]',
|
|
7
|
+
// debug: true
|
|
8
|
+
// });
|
|
9
|
+
// router.start();
|
|
10
|
+
|
|
11
|
+
export function createRouter(config) {
|
|
12
|
+
const {
|
|
13
|
+
routes = [],
|
|
14
|
+
store,
|
|
15
|
+
rootSelector = '[data-route-root]',
|
|
16
|
+
fallback = null,
|
|
17
|
+
debug = false,
|
|
18
|
+
linkSelector = 'a[data-link]',
|
|
19
|
+
navSelector = 'nav a[data-link]',
|
|
20
|
+
} = config;
|
|
21
|
+
|
|
22
|
+
// Detect base path from <base href> if present
|
|
23
|
+
const BASE_PATH = (() => {
|
|
24
|
+
const b = document.querySelector('base[href]');
|
|
25
|
+
if (!b) return '';
|
|
26
|
+
try {
|
|
27
|
+
const u = new URL(b.getAttribute('href'), location.href);
|
|
28
|
+
let p = u.pathname;
|
|
29
|
+
if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
|
|
30
|
+
return p;
|
|
31
|
+
} catch { return ''; }
|
|
32
|
+
})();
|
|
33
|
+
|
|
34
|
+
function stripBase(pathname) {
|
|
35
|
+
if (BASE_PATH && pathname.startsWith(BASE_PATH)) {
|
|
36
|
+
const rest = pathname.slice(BASE_PATH.length) || '/';
|
|
37
|
+
return rest.startsWith('/') ? rest : ('/' + rest);
|
|
38
|
+
}
|
|
39
|
+
return pathname;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function withBase(pathname) {
|
|
43
|
+
if (!BASE_PATH) return pathname;
|
|
44
|
+
if (pathname === '/') return BASE_PATH || '/';
|
|
45
|
+
return (BASE_PATH + (pathname.startsWith('/') ? '' : '/') + pathname);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizePath(p) {
|
|
49
|
+
if (!p) return '/';
|
|
50
|
+
try {
|
|
51
|
+
if (p[0] !== '/') p = '/' + p;
|
|
52
|
+
if (p === '/index.html') return '/';
|
|
53
|
+
if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
|
|
54
|
+
return p;
|
|
55
|
+
} catch { return '/'; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolve(pathname) {
|
|
59
|
+
const p = normalizePath(pathname);
|
|
60
|
+
const r = routes.find(r => r.path === p);
|
|
61
|
+
if (r) return { ...r, params: {} };
|
|
62
|
+
if (fallback) return { ...fallback, params: {} };
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getRoot() {
|
|
67
|
+
const el = document.querySelector(rootSelector);
|
|
68
|
+
if (!el) throw new Error('Route root not found: ' + rootSelector);
|
|
69
|
+
return el;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function setActiveNav(pathname) {
|
|
73
|
+
document.querySelectorAll(navSelector).forEach(a => {
|
|
74
|
+
const url = new URL(a.getAttribute('href'), location.href);
|
|
75
|
+
const linkPath = normalizePath(stripBase(url.pathname));
|
|
76
|
+
const here = normalizePath(pathname);
|
|
77
|
+
const isExact = linkPath === here;
|
|
78
|
+
const isParent = !isExact && linkPath !== '/' && here.startsWith(linkPath);
|
|
79
|
+
const active = isExact || isParent;
|
|
80
|
+
a.classList.toggle('active', active);
|
|
81
|
+
if (isExact) a.setAttribute('aria-current', 'page');
|
|
82
|
+
else a.removeAttribute('aria-current');
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// State
|
|
87
|
+
let current = { viewKey: null, unboot: null, path: null, search: '' };
|
|
88
|
+
let navController = null;
|
|
89
|
+
const scrollPositions = new Map();
|
|
90
|
+
history.scrollRestoration = 'manual';
|
|
91
|
+
|
|
92
|
+
// Core navigate function
|
|
93
|
+
async function navigate(pathname, { replace = false, search = '', restoreScroll = false } = {}) {
|
|
94
|
+
const root = getRoot();
|
|
95
|
+
const appPath = normalizePath(stripBase(pathname));
|
|
96
|
+
const resolved = resolve(appPath);
|
|
97
|
+
|
|
98
|
+
if (!resolved) {
|
|
99
|
+
if (debug) console.warn('[router] No route found for:', appPath);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const viewKey = resolved.view;
|
|
104
|
+
const component = resolved.component;
|
|
105
|
+
const html = document.documentElement;
|
|
106
|
+
const prevViewKey = current.viewKey;
|
|
107
|
+
|
|
108
|
+
const searchStr = search && search.startsWith('?') ? search : (search ? ('?' + search) : '');
|
|
109
|
+
|
|
110
|
+
// Always log navigation for telemetry
|
|
111
|
+
console.log('[nav] navigate', { from: current.path, to: appPath, view: viewKey });
|
|
112
|
+
|
|
113
|
+
if (debug) {
|
|
114
|
+
console.debug('[router] navigate', { pathname, appPath, searchStr, view: viewKey, params: resolved.params });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Same-route no-op guard
|
|
118
|
+
if (current.path === appPath && current.search === searchStr) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Abort in-flight boot
|
|
123
|
+
if (navController) navController.abort();
|
|
124
|
+
navController = new AbortController();
|
|
125
|
+
const { signal } = navController;
|
|
126
|
+
|
|
127
|
+
// Transition start
|
|
128
|
+
if (store) {
|
|
129
|
+
try { store.set('ui.route.transitioning', true); } catch {}
|
|
130
|
+
}
|
|
131
|
+
html.setAttribute('data-transitioning', 'on');
|
|
132
|
+
|
|
133
|
+
// Save scroll position
|
|
134
|
+
if (current.path) {
|
|
135
|
+
scrollPositions.set(current.path, { x: scrollX, y: scrollY });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Unboot previous view
|
|
139
|
+
if (typeof current.unboot === 'function') {
|
|
140
|
+
try { await current.unboot(); } catch (_e) {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Clear DOM
|
|
144
|
+
root.replaceChildren();
|
|
145
|
+
|
|
146
|
+
// Boot new view
|
|
147
|
+
const unboot = await (component.boot?.({ store, el: root, signal }) || Promise.resolve(() => {}));
|
|
148
|
+
current = { viewKey, unboot, path: appPath, search: searchStr };
|
|
149
|
+
|
|
150
|
+
// Update nav active state
|
|
151
|
+
setActiveNav(appPath);
|
|
152
|
+
|
|
153
|
+
// Parse query params
|
|
154
|
+
const urlForQuery = new URL(location.origin + withBase(appPath) + searchStr);
|
|
155
|
+
const q = {};
|
|
156
|
+
urlForQuery.searchParams.forEach((v, k) => { q[k] = v; });
|
|
157
|
+
|
|
158
|
+
// Update store
|
|
159
|
+
if (store) {
|
|
160
|
+
try {
|
|
161
|
+
if (store.setMany) {
|
|
162
|
+
store.setMany({
|
|
163
|
+
'ui.route.view': viewKey,
|
|
164
|
+
'ui.route.path': appPath,
|
|
165
|
+
'ui.route.params': resolved.params || {},
|
|
166
|
+
'ui.route.query': q,
|
|
167
|
+
});
|
|
168
|
+
} else {
|
|
169
|
+
store.set('ui.route.view', viewKey);
|
|
170
|
+
store.set('ui.route.path', appPath);
|
|
171
|
+
store.set('ui.route.params', resolved.params || {});
|
|
172
|
+
store.set('ui.route.query', q);
|
|
173
|
+
}
|
|
174
|
+
} catch {}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Update browser history
|
|
178
|
+
const useReplace = replace || (prevViewKey === viewKey);
|
|
179
|
+
if (useReplace) history.replaceState({}, '', withBase(appPath) + searchStr);
|
|
180
|
+
else history.pushState({}, '', withBase(appPath) + searchStr);
|
|
181
|
+
|
|
182
|
+
// Set view attribute
|
|
183
|
+
html.setAttribute('data-view', viewKey);
|
|
184
|
+
|
|
185
|
+
if (debug) {
|
|
186
|
+
console.debug('[router] view', { viewKey, path: appPath, query: q });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Transition end
|
|
190
|
+
if (store) {
|
|
191
|
+
try { store.set('ui.route.transitioning', false); } catch {}
|
|
192
|
+
}
|
|
193
|
+
html.setAttribute('data-transitioning', 'off');
|
|
194
|
+
|
|
195
|
+
// Focus management
|
|
196
|
+
if (!root.hasAttribute('tabindex')) root.setAttribute('tabindex', '-1');
|
|
197
|
+
try { root.focus({ preventScroll: true }); } catch {}
|
|
198
|
+
|
|
199
|
+
// Scroll restoration
|
|
200
|
+
if (restoreScroll) {
|
|
201
|
+
const pos = scrollPositions.get(appPath);
|
|
202
|
+
if (pos) scrollTo(pos.x, pos.y);
|
|
203
|
+
} else {
|
|
204
|
+
scrollTo(0, 0);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function navigateQuery(patch = {}, { replace = true } = {}) {
|
|
209
|
+
const params = new URLSearchParams(current.search || '');
|
|
210
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
211
|
+
if (v === null || v === undefined || v === '') params.delete(k);
|
|
212
|
+
else params.set(k, String(v));
|
|
213
|
+
}
|
|
214
|
+
const searchStr = params.toString();
|
|
215
|
+
const prefixed = searchStr ? ('?' + searchStr) : '';
|
|
216
|
+
const path = current.path || normalizePath(stripBase(location.pathname));
|
|
217
|
+
return navigate(path, { search: prefixed, replace });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function navigatePath(path, { replace = true } = {}) {
|
|
221
|
+
const appPath = normalizePath(stripBase(path));
|
|
222
|
+
const searchStr = current.search || '';
|
|
223
|
+
return navigate(appPath, { search: searchStr, replace });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function onClick(e) {
|
|
227
|
+
const a = e.target.closest(linkSelector);
|
|
228
|
+
if (!a) return;
|
|
229
|
+
if (e.defaultPrevented) return;
|
|
230
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
|
231
|
+
const url = new URL(a.getAttribute('href'), location.href);
|
|
232
|
+
if (url.origin !== location.origin) return;
|
|
233
|
+
e.preventDefault();
|
|
234
|
+
|
|
235
|
+
// Log navigation click
|
|
236
|
+
console.log('[nav] click', { href: a.getAttribute('href'), text: a.textContent.trim() });
|
|
237
|
+
|
|
238
|
+
navigate(url.pathname, { search: url.search }).catch(() => {});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function onPop() {
|
|
242
|
+
navigate(location.pathname, { replace: true, search: location.search, restoreScroll: true }).catch(() => {});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Public API
|
|
246
|
+
return {
|
|
247
|
+
navigate,
|
|
248
|
+
navigateQuery,
|
|
249
|
+
navigatePath,
|
|
250
|
+
|
|
251
|
+
start() {
|
|
252
|
+
window.addEventListener('click', onClick);
|
|
253
|
+
window.addEventListener('popstate', onPop);
|
|
254
|
+
navigate(location.pathname, { replace: true, search: location.search, restoreScroll: true });
|
|
255
|
+
return this;
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
stop() {
|
|
259
|
+
window.removeEventListener('click', onClick);
|
|
260
|
+
window.removeEventListener('popstate', onPop);
|
|
261
|
+
if (typeof current.unboot === 'function') {
|
|
262
|
+
try { current.unboot(); } catch {}
|
|
263
|
+
}
|
|
264
|
+
return this;
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
getCurrent() {
|
|
268
|
+
return { ...current };
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Auto-generated from test assertions
|
|
2
|
+
// DO NOT EDIT - regenerate by running: node tests/generateTypes.js
|
|
3
|
+
|
|
4
|
+
export interface StoreState {
|
|
5
|
+
todos: {
|
|
6
|
+
items: Array<{ id: number; text: string; done: boolean }>;
|
|
7
|
+
filter: string;
|
|
8
|
+
};
|
|
9
|
+
ui: {
|
|
10
|
+
route: {
|
|
11
|
+
path: string;
|
|
12
|
+
view: string;
|
|
13
|
+
};
|
|
14
|
+
theme: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default StoreState;
|