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,153 @@
1
+ /**
2
+ * FField - Two-way form binding
3
+ *
4
+ * @element f-field
5
+ *
6
+ * @attr {string} path - XML path to bind
7
+ * @attr {string} store - Optional store ID reference
8
+ * @attr {string} type - Input type (text, number, checkbox, etc.)
9
+ * @attr {string} target - CSS selector for external input
10
+ * @attr {string} event - Event to listen for (default: input)
11
+ *
12
+ * @example
13
+ * <f-field path="User.Name"></f-field>
14
+ * <f-field path="User.Age" type="number"></f-field>
15
+ * <f-field path="User.Active" type="checkbox"></f-field>
16
+ * <f-field path="User.Email" target="#emailInput"></f-field>
17
+ */
18
+
19
+ import { findStore } from '../dom/find.js';
20
+
21
+ export default class FField extends HTMLElement {
22
+ #input = null;
23
+ #unsubscribe = null;
24
+ #updating = false;
25
+
26
+ connectedCallback() {
27
+ this.#connect();
28
+ }
29
+
30
+ disconnectedCallback() {
31
+ this.#unsubscribe?.();
32
+ }
33
+
34
+ #connect() {
35
+ const path = this.getAttribute('path');
36
+ const type = this.getAttribute('type') || 'text';
37
+ const target = this.getAttribute('target');
38
+ const eventType = this.getAttribute('event') || 'input';
39
+
40
+ if (!path) {
41
+ console.warn('<f-field> requires path attribute');
42
+ return;
43
+ }
44
+
45
+ const store = this._store || findStore(this);
46
+ if (!store) {
47
+ console.warn('<f-field> could not find store');
48
+ return;
49
+ }
50
+
51
+ store.state.when('ready', () => {
52
+ const node = store.at(path);
53
+ if (!node) {
54
+ console.warn(`<f-field> path not found: ${path}`);
55
+ return;
56
+ }
57
+
58
+ // Get or create input
59
+ this.#input = target
60
+ ? document.querySelector(target)
61
+ : this.#createInput(type);
62
+
63
+ if (!this.#input) return;
64
+
65
+ // Node → Input
66
+ this.#unsubscribe = node.subscribe(value => {
67
+ if (this.#updating) return;
68
+ this.#setInputValue(value, type);
69
+ });
70
+
71
+ // Input → Node
72
+ const handler = () => {
73
+ this.#updating = true;
74
+ node.value = this.#getInputValue(type);
75
+ this.#updating = false;
76
+ };
77
+
78
+ this.#input.addEventListener(eventType, handler);
79
+ if (eventType !== 'change') {
80
+ this.#input.addEventListener('change', handler);
81
+ }
82
+ });
83
+ }
84
+
85
+ #createInput(type) {
86
+ let input;
87
+
88
+ switch (type) {
89
+ case 'textarea':
90
+ input = document.createElement('textarea');
91
+ break;
92
+
93
+ case 'select':
94
+ input = document.createElement('select');
95
+ // Copy option children
96
+ for (const opt of this.querySelectorAll('option')) {
97
+ input.appendChild(opt.cloneNode(true));
98
+ }
99
+ break;
100
+
101
+ default:
102
+ input = document.createElement('input');
103
+ input.type = type;
104
+ }
105
+
106
+ // Copy attributes
107
+ for (const attr of ['placeholder', 'class', 'id', 'name', 'min', 'max', 'step', 'pattern', 'required', 'disabled', 'readonly']) {
108
+ const val = this.getAttribute(attr);
109
+ if (val !== null) input.setAttribute(attr, val);
110
+ }
111
+
112
+ this.replaceWith(input);
113
+ return input;
114
+ }
115
+
116
+ #setInputValue(value, type) {
117
+ if (!this.#input) return;
118
+
119
+ switch (type) {
120
+ case 'checkbox':
121
+ this.#input.checked = value === 'true' || value === '1';
122
+ break;
123
+ case 'radio':
124
+ this.#input.checked = this.#input.value === value;
125
+ break;
126
+ default:
127
+ this.#input.value = value ?? '';
128
+ }
129
+ }
130
+
131
+ #getInputValue(type) {
132
+ if (!this.#input) return '';
133
+
134
+ switch (type) {
135
+ case 'checkbox':
136
+ return this.#input.checked ? 'true' : 'false';
137
+ case 'radio':
138
+ return this.#input.checked ? this.#input.value : '';
139
+ default:
140
+ return this.#input.value;
141
+ }
142
+ }
143
+
144
+ get input() {
145
+ return this.#input;
146
+ }
147
+
148
+ focus() {
149
+ this.#input?.focus();
150
+ }
151
+ }
152
+
153
+ customElements.define('f-field', FField);
@@ -0,0 +1,332 @@
1
+ /**
2
+ * FStore - Main XML state container
3
+ *
4
+ * @element f-state
5
+ *
6
+ * @attr {string} key - Storage key for persistence
7
+ * @attr {string} src - URL to load XML from
8
+ * @attr {number} autosave - Autosave debounce in ms
9
+ *
10
+ * @example
11
+ * <f-state key="myapp" autosave="500">
12
+ * <User>
13
+ * <n>Alice</n>
14
+ * <Role>Developer</Role>
15
+ * </User>
16
+ * </f-state>
17
+ *
18
+ * <script>
19
+ * const store = document.querySelector('f-state');
20
+ *
21
+ * store.state.when('ready', () => {
22
+ * const name = store.at('User.Name');
23
+ * name.subscribe(v => console.log('Name:', v));
24
+ * });
25
+ * </script>
26
+ */
27
+
28
+ import Signal from '../core/Signal.js';
29
+ import State from '../core/State.js';
30
+ import Tree from '../xml/Tree.js';
31
+ import Node, { AttrNode } from '../xml/Node.js';
32
+ import * as Path from '../xml/Path.js';
33
+ import * as Persist from '../sync/Persist.js';
34
+ import Channel, { Protocol } from '../sync/Channel.js';
35
+
36
+ export default class FStore extends HTMLElement {
37
+ #tree;
38
+ #state = new State();
39
+ #actions = new Map();
40
+ #storage = null;
41
+ #channel = null;
42
+ #saveDebounced = null;
43
+ #parser = new DOMParser();
44
+
45
+ // Expose for scoped stores
46
+ static Node = Node;
47
+ static AttrNode = AttrNode;
48
+ static Path = Path;
49
+
50
+ constructor() {
51
+ super();
52
+ this.#tree = new Tree(this);
53
+ }
54
+
55
+ connectedCallback() {
56
+ this.#initialize();
57
+ }
58
+
59
+ disconnectedCallback() {
60
+ this.#channel?.close();
61
+ this.#tree.dispose();
62
+ this.#state.dispose();
63
+ }
64
+
65
+ async #initialize() {
66
+ const key = this.getAttribute('key');
67
+ const src = this.getAttribute('src');
68
+ const autosave = parseInt(this.getAttribute('autosave') || '0', 10);
69
+
70
+ // Setup persistence
71
+ if (key) {
72
+ this.#storage = Persist.auto();
73
+
74
+ // Try to restore from storage
75
+ const stored = await this.#storage.get(key);
76
+ if (stored) {
77
+ this.#applyXML(stored, false);
78
+ }
79
+
80
+ // Setup cross-tab sync
81
+ this.#channel = new Channel(key);
82
+ this.#channel.onMessage(msg => {
83
+ if (msg.type === Protocol.UPDATE) {
84
+ this.#applyXML(msg.xml, false);
85
+ }
86
+ });
87
+ }
88
+
89
+ // Load from URL if no stored state
90
+ if (src && this.#tree.empty) {
91
+ await this.load(src);
92
+ }
93
+
94
+ // Parse inline XML if present
95
+ if (this.#tree.empty && this.innerHTML.trim()) {
96
+ this.#parseInline();
97
+ }
98
+
99
+ // Setup autosave
100
+ if (autosave > 0 && key) {
101
+ this.#saveDebounced = Persist.debounce(() => this.#save(), autosave);
102
+ this.#tree.onChange(() => this.#saveDebounced());
103
+ }
104
+
105
+ // Mark ready
106
+ this.#state.set('ready');
107
+ }
108
+
109
+ #parseInline() {
110
+ const xml = this.innerHTML;
111
+ const doc = this.#parser.parseFromString(
112
+ `<root>${xml}</root>`,
113
+ 'application/xml'
114
+ );
115
+
116
+ const error = doc.querySelector('parsererror');
117
+ if (error) {
118
+ console.error('Flarp parse error:', error.textContent);
119
+ this.#state.set('error');
120
+ return;
121
+ }
122
+
123
+ this.innerHTML = '';
124
+ for (const child of Array.from(doc.documentElement.children)) {
125
+ this.appendChild(document.importNode(child, true));
126
+ }
127
+ }
128
+
129
+ #applyXML(xml, broadcast = true) {
130
+ const doc = this.#parser.parseFromString(
131
+ `<root>${xml}</root>`,
132
+ 'application/xml'
133
+ );
134
+
135
+ const error = doc.querySelector('parsererror');
136
+ if (error) {
137
+ this.#state.set('error');
138
+ return false;
139
+ }
140
+
141
+ this.innerHTML = '';
142
+ for (const child of Array.from(doc.documentElement.children)) {
143
+ this.appendChild(document.importNode(child, true));
144
+ }
145
+
146
+ if (broadcast && this.#channel) {
147
+ this.#channel.send(Protocol.update(xml));
148
+ }
149
+
150
+ return true;
151
+ }
152
+
153
+ async #save() {
154
+ const key = this.getAttribute('key');
155
+ if (!key || !this.#storage) return;
156
+
157
+ const xml = this.serialize();
158
+ await this.#storage.set(key, xml);
159
+
160
+ if (this.#channel) {
161
+ this.#channel.send(Protocol.update(xml));
162
+ }
163
+
164
+ this.#state.set('saved');
165
+ }
166
+
167
+ // ========== Public API ==========
168
+
169
+ /**
170
+ * Get state machine
171
+ */
172
+ get state() {
173
+ return this.#state;
174
+ }
175
+
176
+ /**
177
+ * Get reactive node at path
178
+ * @param {string} path
179
+ * @returns {Node|AttrNode|null}
180
+ */
181
+ at(path) {
182
+ return this.#tree.at(path);
183
+ }
184
+
185
+ /**
186
+ * Get all nodes matching path
187
+ * @param {string} path
188
+ * @returns {Node[]}
189
+ */
190
+ query(path) {
191
+ return this.#tree.query(path);
192
+ }
193
+
194
+ /**
195
+ * Set value at path
196
+ * @param {string} path
197
+ * @param {string} value
198
+ */
199
+ set(path, value) {
200
+ this.#tree.set(path, value);
201
+ }
202
+
203
+ /**
204
+ * Add XML fragment
205
+ * @param {string} parentPath
206
+ * @param {string} xml
207
+ * @returns {Node|Node[]}
208
+ */
209
+ add(parentPath, xml) {
210
+ return this.#tree.add(parentPath, xml);
211
+ }
212
+
213
+ /**
214
+ * Remove node at path
215
+ * @param {string} path
216
+ * @returns {boolean}
217
+ */
218
+ remove(path) {
219
+ return this.#tree.remove(path);
220
+ }
221
+
222
+ /**
223
+ * Serialize entire state
224
+ * @returns {string}
225
+ */
226
+ serialize() {
227
+ return this.innerHTML;
228
+ }
229
+
230
+ /**
231
+ * Get/set raw XML
232
+ */
233
+ get xml() {
234
+ return this.serialize();
235
+ }
236
+
237
+ set xml(value) {
238
+ this.#applyXML(value);
239
+ }
240
+
241
+ /**
242
+ * Load from URL
243
+ * @param {string} url
244
+ */
245
+ async load(url) {
246
+ this.#state.set('loading');
247
+
248
+ try {
249
+ const response = await fetch(url || this.getAttribute('src'));
250
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
251
+
252
+ const text = await response.text();
253
+ this.#applyXML(text);
254
+ this.#state.clear('loading');
255
+ this.#state.set('loaded');
256
+ } catch (error) {
257
+ console.error('Flarp load error:', error);
258
+ this.#state.clear('loading');
259
+ this.#state.set('error');
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Force save to storage
265
+ */
266
+ async save() {
267
+ await this.#save();
268
+ }
269
+
270
+ /**
271
+ * Clear stored state
272
+ */
273
+ async clear() {
274
+ const key = this.getAttribute('key');
275
+ if (key && this.#storage) {
276
+ await this.#storage.remove(key);
277
+ }
278
+ }
279
+
280
+ // ========== Event Sourcing ==========
281
+
282
+ /**
283
+ * Register action handler
284
+ * @param {string} action
285
+ * @param {Function} handler
286
+ * @returns {Function} Unsubscribe
287
+ */
288
+ on(action, handler) {
289
+ if (!this.#actions.has(action)) {
290
+ this.#actions.set(action, new Set());
291
+ }
292
+ this.#actions.get(action).add(handler);
293
+ return () => this.#actions.get(action)?.delete(handler);
294
+ }
295
+
296
+ /**
297
+ * Emit action
298
+ * @param {string} action
299
+ * @param {*} payload
300
+ */
301
+ emit(action, payload) {
302
+ const handlers = this.#actions.get(action);
303
+ if (handlers) {
304
+ for (const handler of handlers) {
305
+ try {
306
+ handler.call(this, payload, this);
307
+ } catch (e) {
308
+ console.error(`Action error (${action}):`, e);
309
+ }
310
+ }
311
+ }
312
+
313
+ // Also dispatch as DOM event
314
+ this.dispatchEvent(new CustomEvent(`action:${action}`, {
315
+ detail: payload,
316
+ bubbles: true
317
+ }));
318
+ }
319
+
320
+ // ========== Tree subscription ==========
321
+
322
+ /**
323
+ * Subscribe to any tree change
324
+ * @param {Function} fn
325
+ * @returns {Function} Unsubscribe
326
+ */
327
+ onChange(fn) {
328
+ return this.#tree.onChange(fn);
329
+ }
330
+ }
331
+
332
+ customElements.define('f-state', FStore);
@@ -0,0 +1,73 @@
1
+ /**
2
+ * FText - Reactive text display
3
+ *
4
+ * @element f-text
5
+ *
6
+ * @attr {string} path - XML path to display
7
+ * @attr {string} store - Optional store ID reference
8
+ * @attr {string} format - Optional format (uppercase, lowercase, number, etc.)
9
+ * @attr {string} default - Default value if path not found
10
+ *
11
+ * @example
12
+ * <f-text path="User.Name"></f-text>
13
+ * <f-text path="Product.Price" format="currency"></f-text>
14
+ */
15
+
16
+ import { findStore } from '../dom/find.js';
17
+
18
+ const FORMATS = {
19
+ uppercase: v => String(v).toUpperCase(),
20
+ lowercase: v => String(v).toLowerCase(),
21
+ capitalize: v => String(v).charAt(0).toUpperCase() + String(v).slice(1),
22
+ number: v => Number(v).toLocaleString(),
23
+ currency: v => Number(v).toLocaleString(undefined, { style: 'currency', currency: 'USD' }),
24
+ percent: v => Number(v).toLocaleString(undefined, { style: 'percent' }),
25
+ trim: v => String(v).trim(),
26
+ };
27
+
28
+ export default class FText extends HTMLElement {
29
+ #unsubscribe = null;
30
+
31
+ connectedCallback() {
32
+ this.#connect();
33
+ }
34
+
35
+ disconnectedCallback() {
36
+ this.#unsubscribe?.();
37
+ }
38
+
39
+ #connect() {
40
+ const path = this.getAttribute('path');
41
+ const format = this.getAttribute('format');
42
+ const defaultValue = this.getAttribute('default') || '';
43
+
44
+ const store = this._store || findStore(this);
45
+ if (!store) {
46
+ console.warn('<f-text> could not find store');
47
+ return;
48
+ }
49
+
50
+ // Wait for store to be ready
51
+ store.state.when('ready', () => {
52
+ const node = store.at(path);
53
+
54
+ if (!node) {
55
+ this.textContent = defaultValue;
56
+ return;
57
+ }
58
+
59
+ this.#unsubscribe = node.subscribe(value => {
60
+ const formatted = this.#format(value ?? defaultValue, format);
61
+ this.textContent = formatted;
62
+ });
63
+ });
64
+ }
65
+
66
+ #format(value, format) {
67
+ if (!format) return value;
68
+ const formatter = FORMATS[format];
69
+ return formatter ? formatter(value) : value;
70
+ }
71
+ }
72
+
73
+ customElements.define('f-text', FText);