flarp 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/LICENSE +21 -0
- package/README.md +332 -0
- package/package.json +48 -0
- package/src/components/FBind.js +76 -0
- package/src/components/FEach.js +194 -0
- package/src/components/FField.js +153 -0
- package/src/components/FStore.js +332 -0
- package/src/components/FText.js +73 -0
- package/src/components/FWhen.js +253 -0
- package/src/components/index.js +10 -0
- package/src/core/Signal.js +220 -0
- package/src/core/State.js +168 -0
- package/src/core/index.js +6 -0
- package/src/dom/find.js +128 -0
- package/src/dom/index.js +5 -0
- package/src/index.js +63 -0
- package/src/sync/Channel.js +113 -0
- package/src/sync/Persist.js +216 -0
- package/src/sync/index.js +6 -0
- package/src/xml/Node.js +396 -0
- package/src/xml/Path.js +176 -0
- package/src/xml/Tree.js +279 -0
- package/src/xml/index.js +7 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FWhen - Conditional rendering
|
|
3
|
+
*
|
|
4
|
+
* @element f-when
|
|
5
|
+
*
|
|
6
|
+
* @attr {string} test - Condition to evaluate
|
|
7
|
+
* @attr {string} store - Optional store ID reference
|
|
8
|
+
*
|
|
9
|
+
* Supports:
|
|
10
|
+
* - Truthy check: test="user.loggedin"
|
|
11
|
+
* - Equality: test="user.role == 'admin'"
|
|
12
|
+
* - Comparison: test="cart.total > 100"
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* <f-when test="user.loggedin == 'true'">
|
|
16
|
+
* <span>Welcome back!</span>
|
|
17
|
+
* </f-when>
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { findStore } from '../dom/find.js';
|
|
21
|
+
|
|
22
|
+
const OPERATORS = ['>=', '<=', '!=', '==', '>', '<'];
|
|
23
|
+
|
|
24
|
+
function evaluate(test, store) {
|
|
25
|
+
const op = OPERATORS.find(o => test.includes(o));
|
|
26
|
+
|
|
27
|
+
if (!op) {
|
|
28
|
+
// Simple truthy check
|
|
29
|
+
const node = store.at(test.trim());
|
|
30
|
+
const value = node?.peek?.() ?? node?.value;
|
|
31
|
+
return Boolean(value) && value !== 'false' && value !== '0' && value !== '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Parse comparison
|
|
35
|
+
const [left, right] = test.split(op).map(s => s.trim());
|
|
36
|
+
const node = store.at(left);
|
|
37
|
+
const leftVal = node?.peek?.() ?? node?.value;
|
|
38
|
+
|
|
39
|
+
// Parse right side literal
|
|
40
|
+
let rightVal;
|
|
41
|
+
if ((right.startsWith("'") && right.endsWith("'")) ||
|
|
42
|
+
(right.startsWith('"') && right.endsWith('"'))) {
|
|
43
|
+
rightVal = right.slice(1, -1);
|
|
44
|
+
} else if (right === 'true') {
|
|
45
|
+
rightVal = true;
|
|
46
|
+
} else if (right === 'false') {
|
|
47
|
+
rightVal = false;
|
|
48
|
+
} else if (right === 'null') {
|
|
49
|
+
rightVal = null;
|
|
50
|
+
} else {
|
|
51
|
+
rightVal = Number(right);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
switch (op) {
|
|
55
|
+
case '==': return leftVal == rightVal;
|
|
56
|
+
case '!=': return leftVal != rightVal;
|
|
57
|
+
case '>': return Number(leftVal) > rightVal;
|
|
58
|
+
case '<': return Number(leftVal) < rightVal;
|
|
59
|
+
case '>=': return Number(leftVal) >= rightVal;
|
|
60
|
+
case '<=': return Number(leftVal) <= rightVal;
|
|
61
|
+
default: return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export default class FWhen extends HTMLElement {
|
|
66
|
+
#container = null;
|
|
67
|
+
#templateHtml = '';
|
|
68
|
+
#showing = false;
|
|
69
|
+
#observer = null;
|
|
70
|
+
#store = null;
|
|
71
|
+
#test = null;
|
|
72
|
+
#lastResult = null;
|
|
73
|
+
|
|
74
|
+
connectedCallback() {
|
|
75
|
+
this.#setup();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
disconnectedCallback() {
|
|
79
|
+
this.#observer?.disconnect();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#setup() {
|
|
83
|
+
this.#test = this.getAttribute('test');
|
|
84
|
+
if (!this.#test) {
|
|
85
|
+
console.warn('<f-when> requires test attribute');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.#store = this._store || findStore(this);
|
|
90
|
+
if (!this.#store) {
|
|
91
|
+
console.warn('<f-when> could not find store');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Save content as HTML string (simpler than DocumentFragment)
|
|
96
|
+
this.#templateHtml = this.innerHTML;
|
|
97
|
+
this.innerHTML = '';
|
|
98
|
+
|
|
99
|
+
// Create container
|
|
100
|
+
this.#container = document.createElement('span');
|
|
101
|
+
this.#container.style.display = 'contents';
|
|
102
|
+
this.appendChild(this.#container);
|
|
103
|
+
|
|
104
|
+
this.#store.state.when('ready', () => {
|
|
105
|
+
this.#update();
|
|
106
|
+
|
|
107
|
+
// Observe for changes - debounced
|
|
108
|
+
let pending = false;
|
|
109
|
+
this.#observer = new MutationObserver(() => {
|
|
110
|
+
if (pending) return;
|
|
111
|
+
pending = true;
|
|
112
|
+
requestAnimationFrame(() => {
|
|
113
|
+
pending = false;
|
|
114
|
+
this.#update();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
this.#observer.observe(this.#store, {
|
|
119
|
+
subtree: true,
|
|
120
|
+
childList: true,
|
|
121
|
+
characterData: true,
|
|
122
|
+
attributes: true
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#update() {
|
|
128
|
+
const shouldShow = evaluate(this.#test, this.#store);
|
|
129
|
+
|
|
130
|
+
// Skip if result unchanged
|
|
131
|
+
if (shouldShow === this.#lastResult) return;
|
|
132
|
+
this.#lastResult = shouldShow;
|
|
133
|
+
|
|
134
|
+
if (shouldShow) {
|
|
135
|
+
this.#container.innerHTML = this.#templateHtml;
|
|
136
|
+
this.#showing = true;
|
|
137
|
+
} else {
|
|
138
|
+
this.#container.innerHTML = '';
|
|
139
|
+
this.#showing = false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
customElements.define('f-when', FWhen);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* FMatch - Switch-style conditional
|
|
148
|
+
*
|
|
149
|
+
* @element f-match
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* <f-match test="user.role">
|
|
153
|
+
* <f-case value="admin">Admin Panel</f-case>
|
|
154
|
+
* <f-case value="user">Dashboard</f-case>
|
|
155
|
+
* <f-else>Please log in</f-else>
|
|
156
|
+
* </f-match>
|
|
157
|
+
*/
|
|
158
|
+
|
|
159
|
+
export class FMatch extends HTMLElement {
|
|
160
|
+
#container = null;
|
|
161
|
+
#cases = [];
|
|
162
|
+
#activeValue = null;
|
|
163
|
+
#observer = null;
|
|
164
|
+
#store = null;
|
|
165
|
+
#test = null;
|
|
166
|
+
|
|
167
|
+
connectedCallback() {
|
|
168
|
+
this.#setup();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
disconnectedCallback() {
|
|
172
|
+
this.#observer?.disconnect();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#setup() {
|
|
176
|
+
this.#test = this.getAttribute('test');
|
|
177
|
+
if (!this.#test) {
|
|
178
|
+
console.warn('<f-match> requires test attribute');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.#store = this._store || findStore(this);
|
|
183
|
+
if (!this.#store) {
|
|
184
|
+
console.warn('<f-match> could not find store');
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Collect cases before modifying DOM
|
|
189
|
+
this.#cases = Array.from(this.children).map(child => ({
|
|
190
|
+
value: child.getAttribute('value'),
|
|
191
|
+
isElse: child.tagName.toLowerCase() === 'f-else',
|
|
192
|
+
template: child.innerHTML
|
|
193
|
+
}));
|
|
194
|
+
|
|
195
|
+
// Clear and create container
|
|
196
|
+
this.innerHTML = '';
|
|
197
|
+
this.#container = document.createElement('span');
|
|
198
|
+
this.#container.style.display = 'contents';
|
|
199
|
+
this.appendChild(this.#container);
|
|
200
|
+
|
|
201
|
+
this.#store.state.when('ready', () => {
|
|
202
|
+
this.#update();
|
|
203
|
+
|
|
204
|
+
// Observe for changes - debounced
|
|
205
|
+
let pending = false;
|
|
206
|
+
this.#observer = new MutationObserver(() => {
|
|
207
|
+
if (pending) return;
|
|
208
|
+
pending = true;
|
|
209
|
+
requestAnimationFrame(() => {
|
|
210
|
+
pending = false;
|
|
211
|
+
this.#update();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
this.#observer.observe(this.#store, {
|
|
216
|
+
subtree: true,
|
|
217
|
+
childList: true,
|
|
218
|
+
characterData: true,
|
|
219
|
+
attributes: true
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#update() {
|
|
225
|
+
const node = this.#store.at(this.#test);
|
|
226
|
+
const value = node?.peek?.() ?? node?.value ?? '';
|
|
227
|
+
|
|
228
|
+
// Skip if same value
|
|
229
|
+
if (value === this.#activeValue) return;
|
|
230
|
+
this.#activeValue = value;
|
|
231
|
+
|
|
232
|
+
// Find matching case
|
|
233
|
+
let matched = this.#cases.find(c => !c.isElse && c.value == value);
|
|
234
|
+
if (!matched) {
|
|
235
|
+
matched = this.#cases.find(c => c.isElse);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Render
|
|
239
|
+
this.#container.innerHTML = matched ? matched.template : '';
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
customElements.define('f-match', FMatch);
|
|
244
|
+
|
|
245
|
+
// Marker elements
|
|
246
|
+
class FCase extends HTMLElement {}
|
|
247
|
+
class FElse extends HTMLElement {}
|
|
248
|
+
|
|
249
|
+
customElements.define('f-case', FCase);
|
|
250
|
+
customElements.define('f-else', FElse);
|
|
251
|
+
|
|
252
|
+
export { FCase, FElse };
|
|
253
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flarp Web Components
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { default as FStore } from './FStore.js';
|
|
6
|
+
export { default as FText } from './FText.js';
|
|
7
|
+
export { default as FField } from './FField.js';
|
|
8
|
+
export { default as FBind } from './FBind.js';
|
|
9
|
+
export { default as FEach } from './FEach.js';
|
|
10
|
+
export { default as FWhen, FMatch, FCase, FElse } from './FWhen.js';
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal - Minimal synchronous reactive primitive
|
|
3
|
+
*
|
|
4
|
+
* The foundation of Flarp's reactivity system.
|
|
5
|
+
* Designed for explicit ownership, deterministic disposal,
|
|
6
|
+
* and embedding in larger systems.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export default class Signal {
|
|
10
|
+
|
|
11
|
+
static #currentAccess = null;
|
|
12
|
+
|
|
13
|
+
#value;
|
|
14
|
+
#subscribers = new Set();
|
|
15
|
+
#disposables = new Set();
|
|
16
|
+
#disposed = false;
|
|
17
|
+
|
|
18
|
+
constructor(initialValue) {
|
|
19
|
+
this.#value = initialValue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get value() {
|
|
23
|
+
this.#assertAlive();
|
|
24
|
+
if (Signal.#currentAccess) Signal.#currentAccess(this);
|
|
25
|
+
return this.#value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
set value(next) {
|
|
29
|
+
this.#assertAlive();
|
|
30
|
+
if (Object.is(next, this.#value)) return;
|
|
31
|
+
this.#value = next;
|
|
32
|
+
this.notify();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read without triggering dependency tracking
|
|
37
|
+
*/
|
|
38
|
+
peek() {
|
|
39
|
+
this.#assertAlive();
|
|
40
|
+
return this.#value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Subscribe to changes
|
|
45
|
+
* @param {Function} fn - Callback receiving new value
|
|
46
|
+
* @param {boolean} autorun - Call immediately with current value
|
|
47
|
+
* @returns {Function} Unsubscribe function
|
|
48
|
+
*/
|
|
49
|
+
subscribe(fn, autorun = true) {
|
|
50
|
+
this.#assertAlive();
|
|
51
|
+
|
|
52
|
+
if (typeof fn !== "function") {
|
|
53
|
+
throw new TypeError("Signal.subscribe: expected function");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.#subscribers.add(fn);
|
|
57
|
+
|
|
58
|
+
if (autorun) {
|
|
59
|
+
this.#safeCall(fn, this.#value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return () => this.unsubscribe(fn);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
unsubscribe(fn) {
|
|
66
|
+
this.#subscribers.delete(fn);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Manually notify subscribers (for mutable data)
|
|
71
|
+
*/
|
|
72
|
+
notify() {
|
|
73
|
+
this.#assertAlive();
|
|
74
|
+
for (const fn of [...this.#subscribers]) {
|
|
75
|
+
this.#safeCall(fn, this.#value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create derived signal from single parent
|
|
81
|
+
* @param {Function} fn - Transform function
|
|
82
|
+
* @returns {Signal} Child signal owned by this parent
|
|
83
|
+
*/
|
|
84
|
+
map(fn) {
|
|
85
|
+
this.#assertAlive();
|
|
86
|
+
|
|
87
|
+
if (typeof fn !== "function") {
|
|
88
|
+
throw new TypeError("Signal.map: expected function");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const child = new Signal(fn(this.peek()));
|
|
92
|
+
|
|
93
|
+
const unsubscribe = this.subscribe((value) => {
|
|
94
|
+
child.value = fn(value);
|
|
95
|
+
}, false);
|
|
96
|
+
|
|
97
|
+
child.collect(unsubscribe);
|
|
98
|
+
this.collect(child);
|
|
99
|
+
|
|
100
|
+
return child;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Register disposables owned by this signal
|
|
105
|
+
*/
|
|
106
|
+
collect(...items) {
|
|
107
|
+
this.#assertAlive();
|
|
108
|
+
for (const d of items.flat(Infinity)) {
|
|
109
|
+
if (d != null) this.#disposables.add(d);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Dispose this signal and all owned resources
|
|
115
|
+
*/
|
|
116
|
+
dispose() {
|
|
117
|
+
if (this.#disposed) return;
|
|
118
|
+
this.#disposed = true;
|
|
119
|
+
|
|
120
|
+
this.#subscribers.clear();
|
|
121
|
+
|
|
122
|
+
for (const d of this.#disposables) {
|
|
123
|
+
try {
|
|
124
|
+
if (typeof d === "function") d();
|
|
125
|
+
else if (typeof d?.dispose === "function") d.dispose();
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error("Signal disposal error:", err);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.#disposables.clear();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get disposed() {
|
|
135
|
+
return this.#disposed;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#safeCall(fn, value) {
|
|
139
|
+
try {
|
|
140
|
+
fn(value);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error("Signal subscriber error:", err);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#assertAlive() {
|
|
147
|
+
if (this.#disposed) {
|
|
148
|
+
throw new Error("Signal has been disposed");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Create derived signal with automatic dependency tracking
|
|
154
|
+
* @param {Function} fn - Computation function
|
|
155
|
+
* @returns {Signal} Derived signal
|
|
156
|
+
*/
|
|
157
|
+
static derive(fn) {
|
|
158
|
+
if (typeof fn !== "function") {
|
|
159
|
+
throw new TypeError("Signal.derive: expected function");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const deps = new Set();
|
|
163
|
+
const derived = new Signal(undefined);
|
|
164
|
+
let computing = false;
|
|
165
|
+
|
|
166
|
+
const recompute = () => {
|
|
167
|
+
if (computing) return;
|
|
168
|
+
computing = true;
|
|
169
|
+
|
|
170
|
+
deps.clear();
|
|
171
|
+
|
|
172
|
+
const prev = Signal.#currentAccess;
|
|
173
|
+
Signal.#currentAccess = (signal) => deps.add(signal);
|
|
174
|
+
|
|
175
|
+
let next;
|
|
176
|
+
try {
|
|
177
|
+
next = fn();
|
|
178
|
+
} finally {
|
|
179
|
+
Signal.#currentAccess = prev;
|
|
180
|
+
computing = false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
derived.value = next;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
recompute();
|
|
187
|
+
|
|
188
|
+
const subscriptions = new Set();
|
|
189
|
+
|
|
190
|
+
const resubscribe = () => {
|
|
191
|
+
for (const unsub of subscriptions) unsub();
|
|
192
|
+
subscriptions.clear();
|
|
193
|
+
|
|
194
|
+
for (const dep of deps) {
|
|
195
|
+
const unsub = dep.subscribe(recompute, false);
|
|
196
|
+
subscriptions.add(unsub);
|
|
197
|
+
dep.collect(derived);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
resubscribe();
|
|
202
|
+
|
|
203
|
+
derived.collect(() => {
|
|
204
|
+
for (const unsub of subscriptions) unsub();
|
|
205
|
+
subscriptions.clear();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return derived;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Create a signal that combines multiple signals
|
|
213
|
+
* @param {Signal[]} signals - Source signals
|
|
214
|
+
* @param {Function} fn - Combiner function
|
|
215
|
+
* @returns {Signal} Combined signal
|
|
216
|
+
*/
|
|
217
|
+
static combine(signals, fn) {
|
|
218
|
+
return Signal.derive(() => fn(...signals.map(s => s.value)));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State - Named state machine built on Signals
|
|
3
|
+
*
|
|
4
|
+
* Solves the "ready event fires before listener attached" problem.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const state = new State();
|
|
8
|
+
*
|
|
9
|
+
* // This works even if 'ready' is already true!
|
|
10
|
+
* state.when('ready', () => console.log('Ready!'));
|
|
11
|
+
*
|
|
12
|
+
* // Later...
|
|
13
|
+
* state.set('ready');
|
|
14
|
+
*
|
|
15
|
+
* // Check synchronously
|
|
16
|
+
* if (state.is('ready')) { ... }
|
|
17
|
+
*
|
|
18
|
+
* // Await async
|
|
19
|
+
* await state.until('ready');
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import Signal from './Signal.js';
|
|
23
|
+
|
|
24
|
+
export default class State {
|
|
25
|
+
#signals = new Map();
|
|
26
|
+
#disposed = false;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get or create a signal for a named state
|
|
30
|
+
* @param {string} name - State name
|
|
31
|
+
* @returns {Signal<boolean>}
|
|
32
|
+
*/
|
|
33
|
+
signal(name) {
|
|
34
|
+
this.#assertAlive();
|
|
35
|
+
|
|
36
|
+
if (!this.#signals.has(name)) {
|
|
37
|
+
this.#signals.set(name, new Signal(false));
|
|
38
|
+
}
|
|
39
|
+
return this.#signals.get(name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Set a state to true (or specific value)
|
|
44
|
+
* @param {string} name - State name
|
|
45
|
+
* @param {boolean} value - State value (default: true)
|
|
46
|
+
*/
|
|
47
|
+
set(name, value = true) {
|
|
48
|
+
this.signal(name).value = Boolean(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Clear a state (set to false)
|
|
53
|
+
* @param {string} name - State name
|
|
54
|
+
*/
|
|
55
|
+
clear(name) {
|
|
56
|
+
this.set(name, false);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Toggle a state
|
|
61
|
+
* @param {string} name - State name
|
|
62
|
+
* @returns {boolean} New value
|
|
63
|
+
*/
|
|
64
|
+
toggle(name) {
|
|
65
|
+
const sig = this.signal(name);
|
|
66
|
+
sig.value = !sig.peek();
|
|
67
|
+
return sig.peek();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check if a state is true
|
|
72
|
+
* @param {string} name - State name
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
is(name) {
|
|
76
|
+
return this.signal(name).peek();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* React when state becomes true
|
|
81
|
+
* Fires immediately if already true, otherwise subscribes
|
|
82
|
+
*
|
|
83
|
+
* @param {string} name - State name
|
|
84
|
+
* @param {Function} fn - Callback (no arguments)
|
|
85
|
+
* @returns {Function} Unsubscribe function
|
|
86
|
+
*/
|
|
87
|
+
when(name, fn) {
|
|
88
|
+
this.#assertAlive();
|
|
89
|
+
|
|
90
|
+
const sig = this.signal(name);
|
|
91
|
+
|
|
92
|
+
// Already true? Fire immediately
|
|
93
|
+
if (sig.peek()) {
|
|
94
|
+
try { fn(); } catch (e) { console.error(e); }
|
|
95
|
+
return () => {}; // No-op unsubscribe
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Subscribe for future
|
|
99
|
+
let fired = false;
|
|
100
|
+
return sig.subscribe(value => {
|
|
101
|
+
if (value && !fired) {
|
|
102
|
+
fired = true;
|
|
103
|
+
fn();
|
|
104
|
+
}
|
|
105
|
+
}, false);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* React to any state change
|
|
110
|
+
* @param {string} name - State name
|
|
111
|
+
* @param {Function} fn - Callback receiving boolean value
|
|
112
|
+
* @returns {Function} Unsubscribe function
|
|
113
|
+
*/
|
|
114
|
+
watch(name, fn) {
|
|
115
|
+
return this.signal(name).subscribe(fn);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Wait for state to become true
|
|
120
|
+
* Resolves immediately if already true
|
|
121
|
+
*
|
|
122
|
+
* @param {string} name - State name
|
|
123
|
+
* @returns {Promise<void>}
|
|
124
|
+
*/
|
|
125
|
+
until(name) {
|
|
126
|
+
if (this.is(name)) return Promise.resolve();
|
|
127
|
+
|
|
128
|
+
return new Promise(resolve => {
|
|
129
|
+
const unsub = this.signal(name).subscribe(value => {
|
|
130
|
+
if (value) {
|
|
131
|
+
unsub();
|
|
132
|
+
resolve();
|
|
133
|
+
}
|
|
134
|
+
}, false);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get all current state names and values
|
|
140
|
+
* @returns {Object}
|
|
141
|
+
*/
|
|
142
|
+
snapshot() {
|
|
143
|
+
const result = {};
|
|
144
|
+
for (const [name, sig] of this.#signals) {
|
|
145
|
+
result[name] = sig.peek();
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Dispose all state signals
|
|
152
|
+
*/
|
|
153
|
+
dispose() {
|
|
154
|
+
if (this.#disposed) return;
|
|
155
|
+
this.#disposed = true;
|
|
156
|
+
|
|
157
|
+
for (const sig of this.#signals.values()) {
|
|
158
|
+
sig.dispose();
|
|
159
|
+
}
|
|
160
|
+
this.#signals.clear();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#assertAlive() {
|
|
164
|
+
if (this.#disposed) {
|
|
165
|
+
throw new Error("State has been disposed");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|