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.
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Core - Reusable reactive primitives
3
+ */
4
+
5
+ export { default as Signal } from './Signal.js';
6
+ export { default as State } from './State.js';