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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# Flarp
|
|
2
|
+
|
|
3
|
+
**DOM-native XML State Management for Web Components**
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
XML is state. Signals are reactive. The DOM is the runtime.
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Why Flarp?
|
|
12
|
+
|
|
13
|
+
State management shouldn't require learning proprietary patterns. Flarp uses what the web already has:
|
|
14
|
+
|
|
15
|
+
- **XML for state** — Nested structure you can see and understand
|
|
16
|
+
- **Signals for reactivity** — No virtual DOM, no diffing, just updates
|
|
17
|
+
- **Web Components for UI** — Standard, portable, composable
|
|
18
|
+
- **DOM as runtime** — MutationObserver does the scheduling
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
```html
|
|
23
|
+
<script type="module">
|
|
24
|
+
import 'https://unpkg.com/flarp/src/index.js';
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<f-state key="myapp" autosave="500">
|
|
28
|
+
<user>
|
|
29
|
+
<name>Alice</name>
|
|
30
|
+
<role>Developer</role>
|
|
31
|
+
</user>
|
|
32
|
+
</f-state>
|
|
33
|
+
|
|
34
|
+
<main>
|
|
35
|
+
<h1><f-text path="user.name"></f-text></h1>
|
|
36
|
+
<f-field path="user.role"></f-field>
|
|
37
|
+
</main>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Components find `<f-state>` automatically — no nesting required!
|
|
41
|
+
|
|
42
|
+
> **Note:** The DOM lowercases all tag names. Use lowercase paths like `user.name`, not `User.Name`. Path matching is case-insensitive, so `User.Name` will still work, but your XML will appear lowercase in the DOM.
|
|
43
|
+
|
|
44
|
+
## Key Innovation: Signal-Based Readiness
|
|
45
|
+
|
|
46
|
+
Events fire once. If you miss them, you miss them. Signals don't have this problem:
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
// This works even if 'ready' already happened!
|
|
50
|
+
store.state.when('ready', () => {
|
|
51
|
+
const name = store.at('User.Name');
|
|
52
|
+
name.subscribe(v => console.log('Name:', v));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Or use async/await
|
|
56
|
+
await store.state.until('ready');
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npm install flarp
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Or use directly:
|
|
66
|
+
|
|
67
|
+
```html
|
|
68
|
+
<script type="module">
|
|
69
|
+
import 'https://unpkg.com/flarp/src/index.js';
|
|
70
|
+
</script>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Components
|
|
76
|
+
|
|
77
|
+
### `<f-state>` — The Store
|
|
78
|
+
|
|
79
|
+
```html
|
|
80
|
+
<f-state
|
|
81
|
+
key="myapp" <!-- Persistence key -->
|
|
82
|
+
autosave="500" <!-- Debounce ms -->
|
|
83
|
+
src="/api/data" <!-- Load from URL -->
|
|
84
|
+
>
|
|
85
|
+
<User>
|
|
86
|
+
<n>Alice</n>
|
|
87
|
+
</User>
|
|
88
|
+
</f-state>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**API:**
|
|
92
|
+
- `at(path)` — Get reactive node
|
|
93
|
+
- `query(path)` — Get all matching nodes
|
|
94
|
+
- `set(path, value)` — Set value
|
|
95
|
+
- `add(path, xml)` — Add child
|
|
96
|
+
- `remove(path)` — Remove node
|
|
97
|
+
- `serialize()` — Get XML string
|
|
98
|
+
- `on(action, fn)` — Register action
|
|
99
|
+
- `emit(action, payload)` — Dispatch action
|
|
100
|
+
- `state.when(name, fn)` — React to state
|
|
101
|
+
- `state.until(name)` — Promise for state
|
|
102
|
+
|
|
103
|
+
### `<f-text>` — Display
|
|
104
|
+
|
|
105
|
+
```html
|
|
106
|
+
<f-text path="User.Name"></f-text>
|
|
107
|
+
<f-text path="Price" format="currency"></f-text>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Formats:** `uppercase`, `lowercase`, `number`, `currency`, `percent`
|
|
111
|
+
|
|
112
|
+
### `<f-field>` — Two-Way Binding
|
|
113
|
+
|
|
114
|
+
```html
|
|
115
|
+
<f-field path="User.Name"></f-field>
|
|
116
|
+
<f-field path="User.Age" type="number"></f-field>
|
|
117
|
+
<f-field path="User.Active" type="checkbox"></f-field>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### `<f-each>` — Lists
|
|
121
|
+
|
|
122
|
+
```html
|
|
123
|
+
<f-each path="Users.User">
|
|
124
|
+
<template>
|
|
125
|
+
<div class="card">
|
|
126
|
+
<f-text path="Name"></f-text>
|
|
127
|
+
<f-field path="Email"></f-field>
|
|
128
|
+
</div>
|
|
129
|
+
</template>
|
|
130
|
+
</f-each>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Paths inside templates are relative to each item.
|
|
134
|
+
|
|
135
|
+
### `<f-when>` — Conditionals
|
|
136
|
+
|
|
137
|
+
```html
|
|
138
|
+
<f-when test="User.LoggedIn == 'true'">
|
|
139
|
+
<span>Welcome!</span>
|
|
140
|
+
</f-when>
|
|
141
|
+
|
|
142
|
+
<f-when test="Cart.Total > 100">
|
|
143
|
+
<span>Free shipping!</span>
|
|
144
|
+
</f-when>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### `<f-match>` — Switch
|
|
148
|
+
|
|
149
|
+
```html
|
|
150
|
+
<f-match test="User.Role">
|
|
151
|
+
<f-case value="admin">Admin Panel</f-case>
|
|
152
|
+
<f-case value="user">Dashboard</f-case>
|
|
153
|
+
<f-else>Please log in</f-else>
|
|
154
|
+
</f-match>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Store Discovery
|
|
160
|
+
|
|
161
|
+
Components don't need to be nested inside `<f-state>`. Flarp searches siblings and ancestors:
|
|
162
|
+
|
|
163
|
+
```html
|
|
164
|
+
<div class="app">
|
|
165
|
+
<f-state id="app">
|
|
166
|
+
<User><n>Alice</n></User>
|
|
167
|
+
</f-state>
|
|
168
|
+
|
|
169
|
+
<main>
|
|
170
|
+
<!-- Finds sibling f-state automatically -->
|
|
171
|
+
<f-text path="User.Name"></f-text>
|
|
172
|
+
</main>
|
|
173
|
+
</div>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Or reference explicitly:
|
|
177
|
+
|
|
178
|
+
```html
|
|
179
|
+
<f-text store="app" path="User.Name"></f-text>
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Event Sourcing
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
// Register actions
|
|
188
|
+
store.on('addUser', ({ name, role }) => {
|
|
189
|
+
store.add('Users', `<User><n>${name}</n><Role>${role}</Role></User>`);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Dispatch
|
|
193
|
+
store.emit('addUser', { name: 'Bob', role: 'Developer' });
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Per-Node Reactivity
|
|
199
|
+
|
|
200
|
+
Every XML node is independently:
|
|
201
|
+
- **Reactive** — Has its own Signal
|
|
202
|
+
- **Identifiable** — UUID attribute
|
|
203
|
+
- **Versioned** — `rev` for conflict resolution
|
|
204
|
+
- **Serializable** — `node.serialize()`
|
|
205
|
+
|
|
206
|
+
```js
|
|
207
|
+
const node = store.at('User.Name');
|
|
208
|
+
|
|
209
|
+
node.uuid; // "a1b2c3..."
|
|
210
|
+
node.rev; // 5
|
|
211
|
+
node.serialize(); // "<n uuid="..." rev="5">Alice</n>"
|
|
212
|
+
|
|
213
|
+
// Subscribe to just this node
|
|
214
|
+
node.subscribe(value => console.log(value));
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Persistence
|
|
220
|
+
|
|
221
|
+
State persists to localStorage automatically:
|
|
222
|
+
|
|
223
|
+
```html
|
|
224
|
+
<f-state key="myapp" autosave="500">
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Features:
|
|
228
|
+
- **Crash recovery** — Reload resumes state
|
|
229
|
+
- **Cross-tab sync** — BroadcastChannel
|
|
230
|
+
- **Conflict resolution** — Higher `rev` wins, `uuid` tiebreaker
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Modular Architecture
|
|
235
|
+
|
|
236
|
+
Flarp is built from composable pieces:
|
|
237
|
+
|
|
238
|
+
```js
|
|
239
|
+
// Just the signal primitive
|
|
240
|
+
import { Signal } from 'flarp/core';
|
|
241
|
+
|
|
242
|
+
// Just XML utilities
|
|
243
|
+
import { Node, Path } from 'flarp/xml';
|
|
244
|
+
|
|
245
|
+
// Just persistence
|
|
246
|
+
import { Persist } from 'flarp/sync';
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Custom Components
|
|
252
|
+
|
|
253
|
+
```js
|
|
254
|
+
import { findStore } from 'flarp';
|
|
255
|
+
|
|
256
|
+
class UserCard extends HTMLElement {
|
|
257
|
+
connectedCallback() {
|
|
258
|
+
const store = findStore(this);
|
|
259
|
+
|
|
260
|
+
store.state.when('ready', () => {
|
|
261
|
+
const name = store.at('User.Name');
|
|
262
|
+
|
|
263
|
+
name.subscribe(v => {
|
|
264
|
+
this.innerHTML = `<h2>${v}</h2>`;
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
customElements.define('user-card', UserCard);
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Philosophy
|
|
276
|
+
|
|
277
|
+
1. **XML is data** — Human-readable, self-describing, AI-friendly
|
|
278
|
+
2. **Signals are sync** — No scheduler, no batching, just updates
|
|
279
|
+
3. **DOM is truth** — MutationObserver, not virtual DOM
|
|
280
|
+
4. **State ≠ UI** — Keep them separate, connect with paths
|
|
281
|
+
5. **Persist by default** — State survives refresh
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Tag Naming
|
|
286
|
+
|
|
287
|
+
**Important:** The browser's HTML parser lowercases all tag names. When you write:
|
|
288
|
+
|
|
289
|
+
```html
|
|
290
|
+
<f-state>
|
|
291
|
+
<User><Name>Alice</Name></User>
|
|
292
|
+
</f-state>
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The DOM actually contains:
|
|
296
|
+
|
|
297
|
+
```html
|
|
298
|
+
<f-state>
|
|
299
|
+
<user><name>Alice</name></user>
|
|
300
|
+
</f-state>
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**Flarp handles this automatically** — path matching is case-insensitive. Both `user.name` and `User.Name` will work. However, we recommend using lowercase tags for clarity:
|
|
304
|
+
|
|
305
|
+
```html
|
|
306
|
+
<f-state>
|
|
307
|
+
<user>
|
|
308
|
+
<name>Alice</name>
|
|
309
|
+
<email>alice@example.com</email>
|
|
310
|
+
</user>
|
|
311
|
+
</f-state>
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## Browser Support
|
|
317
|
+
|
|
318
|
+
Modern browsers with ES modules:
|
|
319
|
+
- Chrome 61+
|
|
320
|
+
- Firefox 60+
|
|
321
|
+
- Safari 11+
|
|
322
|
+
- Edge 79+
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## License
|
|
327
|
+
|
|
328
|
+
MIT
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
*Built for developers who believe state management can be simple.*
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "flarp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "DOM-native XML state management for Web Components",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"module": "src/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.js",
|
|
10
|
+
"./core": "./src/core/index.js",
|
|
11
|
+
"./xml": "./src/xml/index.js",
|
|
12
|
+
"./sync": "./src/sync/index.js",
|
|
13
|
+
"./dom": "./src/dom/index.js",
|
|
14
|
+
"./components": "./src/components/index.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"dev": "http-server -c-1 -o",
|
|
21
|
+
"start": "http-server -c-1 -o"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"xml",
|
|
25
|
+
"state",
|
|
26
|
+
"signals",
|
|
27
|
+
"reactive",
|
|
28
|
+
"web-components",
|
|
29
|
+
"dom"
|
|
30
|
+
],
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
"author": "catpea (https://github.com/catpea)",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/catpea/flarp.git"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/catpea/flarp/issues"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://catpea.github.io/flarp",
|
|
43
|
+
"funding": {
|
|
44
|
+
"type": "github",
|
|
45
|
+
"url": "https://github.com/sponsors/catpea"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FBind - One-way binding to external DOM elements
|
|
3
|
+
*
|
|
4
|
+
* @element f-bind
|
|
5
|
+
*
|
|
6
|
+
* @attr {string} path - XML path to bind from
|
|
7
|
+
* @attr {string} target - CSS selector for target element(s)
|
|
8
|
+
* @attr {string} attr - Attribute to set (optional)
|
|
9
|
+
* @attr {string} prop - Property to set (optional)
|
|
10
|
+
* @attr {string} store - Optional store ID reference
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* <f-bind path="User.Name" target="#header h1"></f-bind>
|
|
14
|
+
* <f-bind path="App.Theme" target="body" attr="data-theme"></f-bind>
|
|
15
|
+
* <f-bind path="User.Avatar" target="#avatar" prop="src"></f-bind>
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { findStore } from '../dom/find.js';
|
|
19
|
+
|
|
20
|
+
export default class FBind extends HTMLElement {
|
|
21
|
+
#unsubscribe = null;
|
|
22
|
+
|
|
23
|
+
connectedCallback() {
|
|
24
|
+
this.style.display = 'none';
|
|
25
|
+
this.#connect();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
disconnectedCallback() {
|
|
29
|
+
this.#unsubscribe?.();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#connect() {
|
|
33
|
+
const path = this.getAttribute('path');
|
|
34
|
+
const target = this.getAttribute('target');
|
|
35
|
+
const attr = this.getAttribute('attr');
|
|
36
|
+
const prop = this.getAttribute('prop');
|
|
37
|
+
|
|
38
|
+
if (!path || !target) {
|
|
39
|
+
console.warn('<f-bind> requires path and target attributes');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const store = this._store || findStore(this);
|
|
44
|
+
if (!store) {
|
|
45
|
+
console.warn('<f-bind> could not find store');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
store.state.when('ready', () => {
|
|
50
|
+
const node = store.at(path);
|
|
51
|
+
if (!node) return;
|
|
52
|
+
|
|
53
|
+
this.#unsubscribe = node.subscribe(value => {
|
|
54
|
+
const targets = document.querySelectorAll(target);
|
|
55
|
+
|
|
56
|
+
for (const el of targets) {
|
|
57
|
+
if (attr) {
|
|
58
|
+
if (value != null) {
|
|
59
|
+
el.setAttribute(attr, value);
|
|
60
|
+
} else {
|
|
61
|
+
el.removeAttribute(attr);
|
|
62
|
+
}
|
|
63
|
+
} else if (prop) {
|
|
64
|
+
el[prop] = value ?? '';
|
|
65
|
+
} else if ('value' in el && (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA')) {
|
|
66
|
+
el.value = value ?? '';
|
|
67
|
+
} else {
|
|
68
|
+
el.textContent = value ?? '';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
customElements.define('f-bind', FBind);
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FEach - Reactive list iteration
|
|
3
|
+
*
|
|
4
|
+
* @element f-each
|
|
5
|
+
*
|
|
6
|
+
* @attr {string} path - XML path to iterate over
|
|
7
|
+
* @attr {string} store - Optional store ID reference
|
|
8
|
+
*
|
|
9
|
+
* Inside the template, paths are relative to each item.
|
|
10
|
+
* Use "." for the current node's text content.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* <f-each path="Users.User">
|
|
14
|
+
* <template>
|
|
15
|
+
* <div class="user">
|
|
16
|
+
* <f-text path="Name"></f-text>
|
|
17
|
+
* <f-field path="Email"></f-field>
|
|
18
|
+
* </div>
|
|
19
|
+
* </template>
|
|
20
|
+
* </f-each>
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { findStore, scopedStore } from '../dom/find.js';
|
|
24
|
+
import Node from '../xml/Node.js';
|
|
25
|
+
import * as Path from '../xml/Path.js';
|
|
26
|
+
|
|
27
|
+
export default class FEach extends HTMLElement {
|
|
28
|
+
#template = null;
|
|
29
|
+
#container = null;
|
|
30
|
+
#instances = new WeakMap();
|
|
31
|
+
#observer = null;
|
|
32
|
+
|
|
33
|
+
connectedCallback() {
|
|
34
|
+
this.#setup();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
disconnectedCallback() {
|
|
38
|
+
this.#observer?.disconnect();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#setup() {
|
|
42
|
+
const path = this.getAttribute('path');
|
|
43
|
+
this.#template = this.querySelector('template');
|
|
44
|
+
|
|
45
|
+
if (!this.#template) {
|
|
46
|
+
console.warn('<f-each> requires a <template> child');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const store = this._store || findStore(this);
|
|
51
|
+
if (!store) {
|
|
52
|
+
console.warn('<f-each> could not find store');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Create container for rendered items
|
|
57
|
+
this.#container = document.createElement('div');
|
|
58
|
+
this.#container.style.display = 'contents';
|
|
59
|
+
this.appendChild(this.#container);
|
|
60
|
+
|
|
61
|
+
store.state.when('ready', () => {
|
|
62
|
+
// Find parent element to observe
|
|
63
|
+
const parentPath = Path.parent(path);
|
|
64
|
+
const parentResolved = parentPath
|
|
65
|
+
? Path.resolve(store, parentPath)
|
|
66
|
+
: { element: store };
|
|
67
|
+
|
|
68
|
+
if (!parentResolved) {
|
|
69
|
+
console.warn(`<f-each> parent path not found: ${parentPath}`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const parent = parentResolved.element;
|
|
74
|
+
|
|
75
|
+
// Initial render
|
|
76
|
+
this.#render(store, path);
|
|
77
|
+
|
|
78
|
+
// Observe for structural changes
|
|
79
|
+
this.#observer = new MutationObserver(() => {
|
|
80
|
+
this.#render(store, path);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
this.#observer.observe(parent, { childList: true });
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#render(store, path) {
|
|
88
|
+
const elements = Path.resolveAll(store, path);
|
|
89
|
+
const seen = new Set(elements);
|
|
90
|
+
|
|
91
|
+
// Remove stale items
|
|
92
|
+
for (const child of Array.from(this.#container.children)) {
|
|
93
|
+
if (!seen.has(child.__element)) {
|
|
94
|
+
child.remove();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Add/update items
|
|
99
|
+
elements.forEach((element, index) => {
|
|
100
|
+
if (this.#instances.has(element)) {
|
|
101
|
+
// Ensure correct order
|
|
102
|
+
const existing = this.#instances.get(element);
|
|
103
|
+
if (this.#container.children[index] !== existing) {
|
|
104
|
+
this.#container.insertBefore(existing, this.#container.children[index]);
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Create new instance
|
|
110
|
+
const fragment = this.#template.content.cloneNode(true);
|
|
111
|
+
const wrapper = document.createElement('div');
|
|
112
|
+
wrapper.style.display = 'contents';
|
|
113
|
+
wrapper.appendChild(fragment);
|
|
114
|
+
wrapper.__element = element;
|
|
115
|
+
|
|
116
|
+
// Create scoped store for this item
|
|
117
|
+
const scoped = this.#createScopedStore(store, element);
|
|
118
|
+
|
|
119
|
+
// Set scoped store on all f-* children
|
|
120
|
+
this.#initializeScoped(wrapper, scoped);
|
|
121
|
+
|
|
122
|
+
this.#instances.set(element, wrapper);
|
|
123
|
+
|
|
124
|
+
// Insert at correct position
|
|
125
|
+
if (this.#container.children[index]) {
|
|
126
|
+
this.#container.insertBefore(wrapper, this.#container.children[index]);
|
|
127
|
+
} else {
|
|
128
|
+
this.#container.appendChild(wrapper);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#createScopedStore(parentStore, element) {
|
|
134
|
+
return {
|
|
135
|
+
at(path) {
|
|
136
|
+
if (path === '.' || path === '') {
|
|
137
|
+
return Node.wrap(element);
|
|
138
|
+
}
|
|
139
|
+
const resolved = Path.resolve(element, path);
|
|
140
|
+
if (!resolved) return null;
|
|
141
|
+
|
|
142
|
+
if (resolved.attr) {
|
|
143
|
+
const { AttrNode } = parentStore.constructor;
|
|
144
|
+
return AttrNode.wrap(resolved.element, resolved.attr);
|
|
145
|
+
}
|
|
146
|
+
return Node.wrap(resolved.element);
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
query(path) {
|
|
150
|
+
return Path.resolveAll(element, path).map(el => Node.wrap(el));
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
add: parentStore.add?.bind(parentStore),
|
|
154
|
+
remove: parentStore.remove?.bind(parentStore),
|
|
155
|
+
emit: parentStore.emit?.bind(parentStore),
|
|
156
|
+
on: parentStore.on?.bind(parentStore),
|
|
157
|
+
state: parentStore.state,
|
|
158
|
+
|
|
159
|
+
// For nested f-each
|
|
160
|
+
constructor: parentStore.constructor
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#initializeScoped(root, scoped) {
|
|
165
|
+
const scopedTags = ['f-text', 'f-field', 'f-each', 'f-when', 'f-bind'];
|
|
166
|
+
|
|
167
|
+
for (const tag of scopedTags) {
|
|
168
|
+
for (const el of root.querySelectorAll(tag)) {
|
|
169
|
+
el._store = scoped;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Handle elements added later
|
|
174
|
+
const observer = new MutationObserver(mutations => {
|
|
175
|
+
for (const m of mutations) {
|
|
176
|
+
for (const node of m.addedNodes) {
|
|
177
|
+
if (node.nodeType !== 1) continue;
|
|
178
|
+
if (scopedTags.includes(node.tagName.toLowerCase())) {
|
|
179
|
+
node._store = scoped;
|
|
180
|
+
}
|
|
181
|
+
for (const tag of scopedTags) {
|
|
182
|
+
for (const el of node.querySelectorAll?.(tag) || []) {
|
|
183
|
+
el._store = scoped;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
customElements.define('f-each', FEach);
|