@wcstack/state 1.7.1 → 1.8.4
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.ja.md +1350 -1350
- package/README.md +1350 -1350
- package/dist/auto.js +3 -3
- package/dist/auto.min.js +3 -3
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/index.esm.min.js +1 -1
- package/dist/index.esm.min.js.map +1 -1
- package/package.json +72 -72
package/README.md
CHANGED
|
@@ -1,1350 +1,1350 @@
|
|
|
1
|
-
# @wcstack/state
|
|
2
|
-
|
|
3
|
-
**What if HTML had reactive data binding?**
|
|
4
|
-
|
|
5
|
-
Imagine a future where the browser natively understands state — you declare data inline, bind it to the DOM with attributes, and everything stays in sync. No virtual DOM, no compilation, no framework. Just HTML that reacts.
|
|
6
|
-
|
|
7
|
-
That's what `<wcs-state>` and `data-wcs` explore. One CDN import, zero dependencies, pure HTML syntax.
|
|
8
|
-
|
|
9
|
-
The CDN script only registers the custom element definition — nothing else happens at load time. When a `<wcs-state>` element connects to the DOM, it reads its state source, scans all `data-wcs` bindings within the same root node (`document` or `ShadowRoot`), and wires up reactivity. All initialization is driven by the element's lifecycle, not by your code.
|
|
10
|
-
|
|
11
|
-
## Design Philosophy
|
|
12
|
-
|
|
13
|
-
### Path as the Universal Contract
|
|
14
|
-
|
|
15
|
-
In every existing framework, the **component** is the coupling point between UI and state. Components import state hooks, selectors, or reactive primitives, and the binding happens inside JavaScript. No matter how cleanly you separate your state store, there is always glue code in the component that pulls state in.
|
|
16
|
-
|
|
17
|
-
`@wcstack/state` eliminates that coupling entirely. The **only** thing connecting UI and state is a **path string** — a dot-separated address like `user.name` or `cart.items.*.subtotal`. This is the sole contract between the two layers:
|
|
18
|
-
|
|
19
|
-
| Layer | What it knows | What it doesn't know |
|
|
20
|
-
|-------|---------------|----------------------|
|
|
21
|
-
| **State** (`<wcs-state>`) | Data structure and business logic | Which DOM nodes are bound |
|
|
22
|
-
| **UI** (`data-wcs`) | Path strings and display intent | How state is stored or computed |
|
|
23
|
-
| **Components** (`@name`) | The path they need from a named state | The other component's internals |
|
|
24
|
-
|
|
25
|
-
Three levels of path contracts keep everything loosely coupled:
|
|
26
|
-
|
|
27
|
-
1. **UI ↔ State** — A `data-wcs="textContent: user.name"` attribute is the entire binding. No hooks, no selectors, no reactive primitives. The component's JavaScript doesn't contain a single line that references state.
|
|
28
|
-
|
|
29
|
-
2. **Component ↔ Component** — Cross-component communication happens through named state references (`@stateName`). Components never import or depend on each other; they share a naming convention, nothing more.
|
|
30
|
-
|
|
31
|
-
3. **Loop context** — Inside a `for` loop, `*` acts as an abstract index. Bindings like `items.*.price` resolve to the current element automatically. The template doesn't know its concrete position — the wildcard is the contract.
|
|
32
|
-
|
|
33
|
-
### Why This Matters
|
|
34
|
-
|
|
35
|
-
This is complete separation of UI and state with **no JavaScript intermediary**. You can:
|
|
36
|
-
|
|
37
|
-
- Redesign the entire UI without touching state logic
|
|
38
|
-
- Refactor state structure and only update path strings
|
|
39
|
-
- Read the HTML alone and understand every data dependency
|
|
40
|
-
|
|
41
|
-
The path contract works like a URL in a REST API — a simple string that both sides agree on, with no shared code between them. It's the natural result of building on HTML's declarative nature rather than inventing a template language on top of JavaScript.
|
|
42
|
-
|
|
43
|
-
## 4 Steps to Reactive HTML
|
|
44
|
-
|
|
45
|
-
```html
|
|
46
|
-
<!-- 1. Load the CDN -->
|
|
47
|
-
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
|
|
48
|
-
|
|
49
|
-
<!-- 2. Write a <wcs-state> tag -->
|
|
50
|
-
<wcs-state>
|
|
51
|
-
<!-- 3. Define your state object -->
|
|
52
|
-
<script type="module">
|
|
53
|
-
export default {
|
|
54
|
-
message: "Hello, World!"
|
|
55
|
-
};
|
|
56
|
-
</script>
|
|
57
|
-
</wcs-state>
|
|
58
|
-
|
|
59
|
-
<!-- 4. Bind with data-wcs attributes -->
|
|
60
|
-
<div data-wcs="textContent: message"></div>
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
That's it. No build, no bootstrap code, no framework.
|
|
64
|
-
|
|
65
|
-
## Features
|
|
66
|
-
|
|
67
|
-
- **Declarative data binding** — `data-wcs` attribute for property / text / event / structural binding
|
|
68
|
-
- **Reactive Proxy** — ES Proxy-based automatic DOM updates with dependency tracking
|
|
69
|
-
- **Structural directives** — `for`, `if` / `elseif` / `else` via `<template>` elements
|
|
70
|
-
- **Built-in filters** — 40 filters for formatting, comparison, arithmetic, date, and more
|
|
71
|
-
- **Two-way binding** — automatic for `<input>`, `<select>`, `<textarea>`
|
|
72
|
-
- **Web Component binding** — bidirectional state binding with Shadow DOM components
|
|
73
|
-
- **Path getters** — dot-path key getters (`get "users.*.fullName"()`) for virtual properties at any depth in a data tree, all defined flat in one place with automatic dependency tracking and caching
|
|
74
|
-
- **Mustache syntax** — `{{ path|filter }}` in text nodes
|
|
75
|
-
- **Multiple state sources** — JSON, JS module, inline script, API, attribute
|
|
76
|
-
- **SVG support** — full binding support inside `<svg>` elements
|
|
77
|
-
- **Lifecycle hooks** — `$connectedCallback` / `$disconnectedCallback` / `$updatedCallback`, plus `$stateReadyCallback` for Web Components
|
|
78
|
-
- **TypeScript support** — `defineState()` for typed state definitions with dot-path autocompletion ([details](docs/define-state.md))
|
|
79
|
-
- **Server-Side Rendering** — `enable-ssr` attribute + `@wcstack/server` for full SSR with automatic hydration
|
|
80
|
-
- **Zero dependencies** — no runtime dependencies
|
|
81
|
-
|
|
82
|
-
## Installation
|
|
83
|
-
|
|
84
|
-
### CDN (recommended)
|
|
85
|
-
|
|
86
|
-
```html
|
|
87
|
-
<!-- Auto-initialization — this is all you need -->
|
|
88
|
-
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
### CDN (manual initialization)
|
|
92
|
-
|
|
93
|
-
```html
|
|
94
|
-
<script type="module">
|
|
95
|
-
import { bootstrapState } from 'https://esm.run/@wcstack/state';
|
|
96
|
-
bootstrapState();
|
|
97
|
-
</script>
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
## Basic Usage
|
|
101
|
-
|
|
102
|
-
```html
|
|
103
|
-
<wcs-state>
|
|
104
|
-
<script type="module">
|
|
105
|
-
export default {
|
|
106
|
-
count: 0,
|
|
107
|
-
user: { id: 1, name: "Alice" },
|
|
108
|
-
users: [
|
|
109
|
-
{ id: 1, name: "Alice" },
|
|
110
|
-
{ id: 2, name: "Bob" },
|
|
111
|
-
{ id: 3, name: "Charlie" }
|
|
112
|
-
],
|
|
113
|
-
countUp() { this.count += 1; },
|
|
114
|
-
clearCount() { this.count = 0; },
|
|
115
|
-
get "users.*.displayName"() {
|
|
116
|
-
return this["users.*.name"] + " (ID: " + this["users.*.id"] + ")";
|
|
117
|
-
}
|
|
118
|
-
};
|
|
119
|
-
</script>
|
|
120
|
-
</wcs-state>
|
|
121
|
-
|
|
122
|
-
<!-- Text binding -->
|
|
123
|
-
<div data-wcs="textContent: count"></div>
|
|
124
|
-
{{ count }}
|
|
125
|
-
|
|
126
|
-
<!-- Two-way input binding -->
|
|
127
|
-
<input type="text" data-wcs="value: user.name">
|
|
128
|
-
|
|
129
|
-
<!-- Event binding -->
|
|
130
|
-
<button data-wcs="onclick: countUp">Increment</button>
|
|
131
|
-
|
|
132
|
-
<!-- Conditional class -->
|
|
133
|
-
<div data-wcs="textContent: count; class.over: count|gt(10)"></div>
|
|
134
|
-
|
|
135
|
-
<!-- Loop -->
|
|
136
|
-
<template data-wcs="for: users">
|
|
137
|
-
<div>
|
|
138
|
-
<span data-wcs="textContent: .id"></span>:
|
|
139
|
-
<span data-wcs="textContent: .displayName"></span>
|
|
140
|
-
</div>
|
|
141
|
-
</template>
|
|
142
|
-
|
|
143
|
-
<!-- Conditional rendering -->
|
|
144
|
-
<template data-wcs="if: count|gt(0)">
|
|
145
|
-
<p>The count is positive.</p>
|
|
146
|
-
</template>
|
|
147
|
-
<template data-wcs="elseif: count|lt(0)">
|
|
148
|
-
<p>The count is negative.</p>
|
|
149
|
-
</template>
|
|
150
|
-
<template data-wcs="else:">
|
|
151
|
-
<p>The count is zero.</p>
|
|
152
|
-
</template>
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
## State Initialization
|
|
156
|
-
|
|
157
|
-
`<wcs-state>` supports multiple ways to load initial state:
|
|
158
|
-
|
|
159
|
-
```html
|
|
160
|
-
<!-- 1. Reference a <script type="application/json"> by id -->
|
|
161
|
-
<script type="application/json" id="state">
|
|
162
|
-
{ "count": 0 }
|
|
163
|
-
</script>
|
|
164
|
-
<wcs-state state="state"></wcs-state>
|
|
165
|
-
|
|
166
|
-
<!-- 2. Inline JSON attribute -->
|
|
167
|
-
<wcs-state json='{ "count": 0 }'></wcs-state>
|
|
168
|
-
|
|
169
|
-
<!-- 3. External JSON file -->
|
|
170
|
-
<wcs-state src="./data.json"></wcs-state>
|
|
171
|
-
|
|
172
|
-
<!-- 4. External JS module (export default { ... }) -->
|
|
173
|
-
<wcs-state src="./state.js"></wcs-state>
|
|
174
|
-
|
|
175
|
-
<!-- 5. Inline script module -->
|
|
176
|
-
<wcs-state>
|
|
177
|
-
<script type="module">
|
|
178
|
-
export default { count: 0 };
|
|
179
|
-
</script>
|
|
180
|
-
</wcs-state>
|
|
181
|
-
|
|
182
|
-
<!-- 6. Programmatic API -->
|
|
183
|
-
<script>
|
|
184
|
-
const el = document.createElement('wcs-state');
|
|
185
|
-
el.setInitialState({ count: 0 });
|
|
186
|
-
document.body.appendChild(el);
|
|
187
|
-
</script>
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
Resolution order: `state` → `src` (.json / .js) → `json` → inner `<script>` → wait for `setInitialState()`.
|
|
191
|
-
|
|
192
|
-
### Named State
|
|
193
|
-
|
|
194
|
-
Multiple state elements can coexist with the `name` attribute. Bindings reference them with `@name`:
|
|
195
|
-
|
|
196
|
-
```html
|
|
197
|
-
<wcs-state name="cart">...</wcs-state>
|
|
198
|
-
<wcs-state name="user">...</wcs-state>
|
|
199
|
-
|
|
200
|
-
<div data-wcs="textContent: total@cart"></div>
|
|
201
|
-
<div data-wcs="textContent: name@user"></div>
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
Default name is `"default"` (no `@` needed).
|
|
205
|
-
|
|
206
|
-
## Updating State
|
|
207
|
-
|
|
208
|
-
In `@wcstack/state`, every piece of state has a **path** — like `count`, `user.name`, or `items`. To update state reactively, **assign to the path**:
|
|
209
|
-
|
|
210
|
-
```javascript
|
|
211
|
-
this.count = 10; // path "count"
|
|
212
|
-
this["user.name"] = "Bob"; // path "user.name"
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
That's the one rule: **assign to the path, and the DOM updates automatically.**
|
|
216
|
-
|
|
217
|
-
### Why `this.user.name = "Bob"` Doesn't Work
|
|
218
|
-
|
|
219
|
-
`this.user.name` first reads the `user` object via `this.user` (a path read), then sets `.name` on that plain object — this is not a path assignment, so the change is not detected:
|
|
220
|
-
|
|
221
|
-
```javascript
|
|
222
|
-
// ✅ Path assignment — change detected
|
|
223
|
-
this["user.name"] = "Bob";
|
|
224
|
-
|
|
225
|
-
// ❌ Not a path assignment — change NOT detected
|
|
226
|
-
this.user.name = "Bob";
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
### Arrays
|
|
230
|
-
|
|
231
|
-
The same rule applies: assign a new array to the path. Mutating methods (`push`, `splice`, `sort`, ...) modify the array in place without path assignment, so use non-destructive alternatives:
|
|
232
|
-
|
|
233
|
-
```javascript
|
|
234
|
-
// ✅ New array assigned to path — change detected
|
|
235
|
-
this.items = this.items.concat({ id: 4, text: "New" });
|
|
236
|
-
this.items = this.items.toSpliced(index, 1);
|
|
237
|
-
this.items = this.items.filter(item => !item.done);
|
|
238
|
-
this.items = this.items.toSorted((a, b) => a.id - b.id);
|
|
239
|
-
this.items = this.items.toReversed();
|
|
240
|
-
this.items = this.items.with(index, newValue);
|
|
241
|
-
|
|
242
|
-
// ❌ In-place mutation — no path assignment, change NOT detected
|
|
243
|
-
this.items.push({ id: 4, text: "New" });
|
|
244
|
-
this.items.splice(index, 1);
|
|
245
|
-
this.items.sort((a, b) => a.id - b.id);
|
|
246
|
-
```
|
|
247
|
-
|
|
248
|
-
## Binding Syntax
|
|
249
|
-
|
|
250
|
-
### `data-wcs` Attribute
|
|
251
|
-
|
|
252
|
-
```
|
|
253
|
-
property[#modifier]: path[@state][|filter[|filter(args)...]]
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
Multiple bindings separated by `;`:
|
|
257
|
-
|
|
258
|
-
```html
|
|
259
|
-
<div data-wcs="textContent: count; class.over: count|gt(10)"></div>
|
|
260
|
-
```
|
|
261
|
-
|
|
262
|
-
| Part | Description | Example |
|
|
263
|
-
|---|---|---|
|
|
264
|
-
| `property` | DOM property to bind | `value`, `textContent`, `checked` |
|
|
265
|
-
| `#modifier` | Binding modifier | `#ro`, `#prevent`, `#stop`, `#onchange` |
|
|
266
|
-
| `path` | State property path | `count`, `user.name`, `users.*.name` |
|
|
267
|
-
| `@state` | Named state reference | `@cart`, `@user` |
|
|
268
|
-
| `\|filter` | Transform filter chain | `\|gt(0)`, `\|round\|locale` |
|
|
269
|
-
|
|
270
|
-
### Property Types
|
|
271
|
-
|
|
272
|
-
| Property | Description |
|
|
273
|
-
|---|---|
|
|
274
|
-
| `value` | Element value (two-way for inputs) |
|
|
275
|
-
| `checked` | Checkbox / radio checked state (two-way) |
|
|
276
|
-
| `textContent` | Text content |
|
|
277
|
-
| `text` | Alias for textContent |
|
|
278
|
-
| `html` | innerHTML |
|
|
279
|
-
| `class.NAME` | Toggle a CSS class |
|
|
280
|
-
| `style.PROP` | Set a CSS style property |
|
|
281
|
-
| `attr.NAME` | Set an attribute (supports SVG namespace) |
|
|
282
|
-
| `radio` | Radio button group binding (two-way) |
|
|
283
|
-
| `checkbox` | Checkbox group binding to array (two-way) |
|
|
284
|
-
| `onclick`, `on*` | Event handler binding |
|
|
285
|
-
|
|
286
|
-
### Modifiers
|
|
287
|
-
|
|
288
|
-
| Modifier | Description |
|
|
289
|
-
|---|---|
|
|
290
|
-
| `#ro` | Read-only — disables two-way binding |
|
|
291
|
-
| `#prevent` | Calls `event.preventDefault()` on event handlers |
|
|
292
|
-
| `#stop` | Calls `event.stopPropagation()` on event handlers |
|
|
293
|
-
| `#onchange` | Uses `change` event instead of `input` for two-way binding |
|
|
294
|
-
|
|
295
|
-
### Two-Way Binding
|
|
296
|
-
|
|
297
|
-
Automatically enabled for:
|
|
298
|
-
|
|
299
|
-
| Element | Property | Event |
|
|
300
|
-
|---|---|---|
|
|
301
|
-
| `<input type="checkbox/radio">` | `checked` | `input` |
|
|
302
|
-
| `<input>` (other types) | `value`, `valueAsNumber`, `valueAsDate` | `input` |
|
|
303
|
-
| `<select>` | `value` | `change` |
|
|
304
|
-
| `<textarea>` | `value` | `input` |
|
|
305
|
-
|
|
306
|
-
`<input type="button">` is excluded. Use `#ro` to disable, `#onchange` to change the event.
|
|
307
|
-
|
|
308
|
-
### Radio Binding
|
|
309
|
-
|
|
310
|
-
Bind a radio button group to a single state value with `radio`:
|
|
311
|
-
|
|
312
|
-
```html
|
|
313
|
-
<input type="radio" value="red" data-wcs="radio: selectedColor">
|
|
314
|
-
<input type="radio" value="blue" data-wcs="radio: selectedColor">
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
The radio button whose `value` matches the state value is automatically checked. When the user selects a different radio button, the state is updated. Use `#ro` for read-only.
|
|
318
|
-
|
|
319
|
-
Inside a `for` loop:
|
|
320
|
-
|
|
321
|
-
```html
|
|
322
|
-
<template data-wcs="for: branches">
|
|
323
|
-
<label>
|
|
324
|
-
<input type="radio" data-wcs="value: .; radio: currentBranch">
|
|
325
|
-
{{ . }}
|
|
326
|
-
</label>
|
|
327
|
-
</template>
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
### Checkbox Binding
|
|
331
|
-
|
|
332
|
-
Bind a checkbox group to a state array with `checkbox`:
|
|
333
|
-
|
|
334
|
-
```html
|
|
335
|
-
<input type="checkbox" value="apple" data-wcs="checkbox: selectedFruits">
|
|
336
|
-
<input type="checkbox" value="banana" data-wcs="checkbox: selectedFruits">
|
|
337
|
-
<input type="checkbox" value="orange" data-wcs="checkbox: selectedFruits">
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
A checkbox is checked when its `value` is included in the state array. Toggling a checkbox adds or removes the value from the array. Use `|int` to convert string values to numbers, and `#ro` for read-only.
|
|
341
|
-
|
|
342
|
-
### Mustache Syntax
|
|
343
|
-
|
|
344
|
-
When `enableMustache` is `true` (default), `{{ expression }}` in text nodes is supported:
|
|
345
|
-
|
|
346
|
-
```html
|
|
347
|
-
<p>Hello, {{ user.name }}!</p>
|
|
348
|
-
<p>Count: {{ count|locale }}</p>
|
|
349
|
-
```
|
|
350
|
-
|
|
351
|
-
Internally converted to comment-based bindings (`<!--@@:expression-->`).
|
|
352
|
-
|
|
353
|
-
## Structural Directives
|
|
354
|
-
|
|
355
|
-
Structural directives use `<template>` elements:
|
|
356
|
-
|
|
357
|
-
### Loop (`for`)
|
|
358
|
-
|
|
359
|
-
```html
|
|
360
|
-
<template data-wcs="for: users">
|
|
361
|
-
<div>
|
|
362
|
-
<!-- Full path -->
|
|
363
|
-
<span data-wcs="textContent: users.*.name"></span>
|
|
364
|
-
<!-- Shorthand (relative to loop context) -->
|
|
365
|
-
<span data-wcs="textContent: .name"></span>
|
|
366
|
-
</div>
|
|
367
|
-
</template>
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
The `for:` directive uses a **value-based diff algorithm** — each array element's value itself serves as the identity key. There is no need for an explicit `key` attribute (like React's `key` or Vue's `:key`). When the array is reassigned, the differ matches old and new elements by value, reusing existing DOM nodes for unchanged items and efficiently adding, removing, or reordering the rest.
|
|
371
|
-
|
|
372
|
-
#### Dot Shorthand
|
|
373
|
-
|
|
374
|
-
Inside a `for` loop, paths starting with `.` are expanded relative to the loop's array path:
|
|
375
|
-
|
|
376
|
-
| Shorthand | Expanded to | Description |
|
|
377
|
-
|---|---|---|
|
|
378
|
-
| `.name` | `users.*.name` | Property of the current element |
|
|
379
|
-
| `.` | `users.*` | The current element itself |
|
|
380
|
-
| `.name\|uc` | `users.*.name\|uc` | Filters are preserved |
|
|
381
|
-
| `.name@state` | `users.*.name@state` | State name is preserved |
|
|
382
|
-
|
|
383
|
-
For primitive arrays, `.` refers to the element value directly:
|
|
384
|
-
|
|
385
|
-
```html
|
|
386
|
-
<template data-wcs="for: branches">
|
|
387
|
-
<label>
|
|
388
|
-
<input type="radio" data-wcs="value: .; radio: currentBranch">
|
|
389
|
-
{{ . }}
|
|
390
|
-
</label>
|
|
391
|
-
</template>
|
|
392
|
-
```
|
|
393
|
-
|
|
394
|
-
Nested loops are supported with multi-level wildcards. The `.` shorthand in nested `for` directives also expands relative to the parent loop path:
|
|
395
|
-
|
|
396
|
-
```html
|
|
397
|
-
<template data-wcs="for: regions">
|
|
398
|
-
<!-- .states → regions.*.states -->
|
|
399
|
-
<template data-wcs="for: .states">
|
|
400
|
-
<!-- .name → regions.*.states.*.name -->
|
|
401
|
-
<span data-wcs="textContent: .name"></span>
|
|
402
|
-
</template>
|
|
403
|
-
</template>
|
|
404
|
-
```
|
|
405
|
-
|
|
406
|
-
### Conditional (`if` / `elseif` / `else`)
|
|
407
|
-
|
|
408
|
-
```html
|
|
409
|
-
<template data-wcs="if: count|gt(0)">
|
|
410
|
-
<p>Positive</p>
|
|
411
|
-
</template>
|
|
412
|
-
<template data-wcs="elseif: count|lt(0)">
|
|
413
|
-
<p>Negative</p>
|
|
414
|
-
</template>
|
|
415
|
-
<template data-wcs="else:">
|
|
416
|
-
<p>Zero</p>
|
|
417
|
-
</template>
|
|
418
|
-
```
|
|
419
|
-
|
|
420
|
-
Conditions can be chained. `elseif` automatically inverts the previous condition.
|
|
421
|
-
|
|
422
|
-
## Path Getters (Computed Properties)
|
|
423
|
-
|
|
424
|
-
**Path getters** are the core feature of `@wcstack/state`. Define computed properties using JavaScript getters with **dot-path string keys** containing wildcards (`*`). They act as **virtual properties that can be attached at any depth in a data tree — all defined flat in one place**. No matter how deeply data is nested, path getters keep definitions at the same level with automatic dependency tracking per loop element.
|
|
425
|
-
|
|
426
|
-
### Basic Path Getter
|
|
427
|
-
|
|
428
|
-
```html
|
|
429
|
-
<wcs-state>
|
|
430
|
-
<script type="module">
|
|
431
|
-
export default {
|
|
432
|
-
users: [
|
|
433
|
-
{ id: 1, firstName: "Alice", lastName: "Smith" },
|
|
434
|
-
{ id: 2, firstName: "Bob", lastName: "Jones" }
|
|
435
|
-
],
|
|
436
|
-
// Path getter — runs per-element inside a loop
|
|
437
|
-
get "users.*.fullName"() {
|
|
438
|
-
return this["users.*.firstName"] + " " + this["users.*.lastName"];
|
|
439
|
-
},
|
|
440
|
-
get "users.*.displayName"() {
|
|
441
|
-
return this["users.*.fullName"] + " (ID: " + this["users.*.id"] + ")";
|
|
442
|
-
}
|
|
443
|
-
};
|
|
444
|
-
</script>
|
|
445
|
-
</wcs-state>
|
|
446
|
-
|
|
447
|
-
<template data-wcs="for: users">
|
|
448
|
-
<div data-wcs="textContent: .displayName"></div>
|
|
449
|
-
</template>
|
|
450
|
-
<!-- Output:
|
|
451
|
-
Alice Smith (ID: 1)
|
|
452
|
-
Bob Jones (ID: 2)
|
|
453
|
-
-->
|
|
454
|
-
```
|
|
455
|
-
|
|
456
|
-
Inside a path getter, `this["users.*.firstName"]` automatically resolves to the current loop element — no manual indexing needed.
|
|
457
|
-
|
|
458
|
-
### Top-Level Computed Properties
|
|
459
|
-
|
|
460
|
-
Getters without wildcards work as standard computed properties:
|
|
461
|
-
|
|
462
|
-
```javascript
|
|
463
|
-
export default {
|
|
464
|
-
price: 100,
|
|
465
|
-
tax: 0.1,
|
|
466
|
-
get total() {
|
|
467
|
-
return this.price * (1 + this.tax);
|
|
468
|
-
}
|
|
469
|
-
};
|
|
470
|
-
```
|
|
471
|
-
|
|
472
|
-
### Getter Chaining
|
|
473
|
-
|
|
474
|
-
Path getters can reference other path getters, forming a dependency chain. The cache is automatically invalidated when any upstream value changes:
|
|
475
|
-
|
|
476
|
-
```html
|
|
477
|
-
<wcs-state>
|
|
478
|
-
<script type="module">
|
|
479
|
-
export default {
|
|
480
|
-
taxRate: 0.1,
|
|
481
|
-
cart: {
|
|
482
|
-
items: [
|
|
483
|
-
{ productId: "P001", quantity: 2, unitPrice: 500 },
|
|
484
|
-
{ productId: "P002", quantity: 1, unitPrice: 1200 }
|
|
485
|
-
]
|
|
486
|
-
},
|
|
487
|
-
// Per-item subtotal
|
|
488
|
-
get "cart.items.*.subtotal"() {
|
|
489
|
-
return this["cart.items.*.unitPrice"] * this["cart.items.*.quantity"];
|
|
490
|
-
},
|
|
491
|
-
// Aggregate: sum of all subtotals
|
|
492
|
-
get "cart.totalPrice"() {
|
|
493
|
-
return this.$getAll("cart.items.*.subtotal", []).reduce((sum, v) => sum + v, 0);
|
|
494
|
-
},
|
|
495
|
-
// Chained: tax derived from totalPrice
|
|
496
|
-
get "cart.tax"() {
|
|
497
|
-
return this["cart.totalPrice"] * this.taxRate;
|
|
498
|
-
},
|
|
499
|
-
// Chained: grand total
|
|
500
|
-
get "cart.grandTotal"() {
|
|
501
|
-
return this["cart.totalPrice"] + this["cart.tax"];
|
|
502
|
-
}
|
|
503
|
-
};
|
|
504
|
-
</script>
|
|
505
|
-
</wcs-state>
|
|
506
|
-
|
|
507
|
-
<template data-wcs="for: cart.items">
|
|
508
|
-
<div>
|
|
509
|
-
<span data-wcs="textContent: .productId"></span>:
|
|
510
|
-
<span data-wcs="textContent: .subtotal|locale"></span>
|
|
511
|
-
</div>
|
|
512
|
-
</template>
|
|
513
|
-
<p>Total: <span data-wcs="textContent: cart.totalPrice|locale"></span></p>
|
|
514
|
-
<p>Tax: <span data-wcs="textContent: cart.tax|locale"></span></p>
|
|
515
|
-
<p>Grand Total: <span data-wcs="textContent: cart.grandTotal|locale"></span></p>
|
|
516
|
-
```
|
|
517
|
-
|
|
518
|
-
Dependency chain: `cart.grandTotal` → `cart.tax` → `cart.totalPrice` → `cart.items.*.subtotal` → `cart.items.*.unitPrice` / `cart.items.*.quantity`. Changing any item's `unitPrice` or `quantity` automatically recomputes the entire chain.
|
|
519
|
-
|
|
520
|
-
### Nested Wildcard Getters
|
|
521
|
-
|
|
522
|
-
Multiple wildcards are supported for nested array structures:
|
|
523
|
-
|
|
524
|
-
```html
|
|
525
|
-
<wcs-state>
|
|
526
|
-
<script type="module">
|
|
527
|
-
export default {
|
|
528
|
-
categories: [
|
|
529
|
-
{
|
|
530
|
-
name: "Fruits",
|
|
531
|
-
items: [
|
|
532
|
-
{ name: "Apple", price: 150 },
|
|
533
|
-
{ name: "Banana", price: 100 }
|
|
534
|
-
]
|
|
535
|
-
},
|
|
536
|
-
{
|
|
537
|
-
name: "Vegetables",
|
|
538
|
-
items: [
|
|
539
|
-
{ name: "Carrot", price: 80 }
|
|
540
|
-
]
|
|
541
|
-
}
|
|
542
|
-
],
|
|
543
|
-
get "categories.*.items.*.label"() {
|
|
544
|
-
return this["categories.*.name"] + " / " + this["categories.*.items.*.name"];
|
|
545
|
-
}
|
|
546
|
-
};
|
|
547
|
-
</script>
|
|
548
|
-
</wcs-state>
|
|
549
|
-
|
|
550
|
-
<template data-wcs="for: categories">
|
|
551
|
-
<h3 data-wcs="textContent: .name"></h3>
|
|
552
|
-
<template data-wcs="for: .items">
|
|
553
|
-
<div data-wcs="textContent: .label"></div>
|
|
554
|
-
</template>
|
|
555
|
-
</template>
|
|
556
|
-
<!-- Output:
|
|
557
|
-
Fruits
|
|
558
|
-
Fruits / Apple
|
|
559
|
-
Fruits / Banana
|
|
560
|
-
Vegetables
|
|
561
|
-
Vegetables / Carrot
|
|
562
|
-
-->
|
|
563
|
-
```
|
|
564
|
-
|
|
565
|
-
### Flat Virtual Properties Across Any Depth
|
|
566
|
-
|
|
567
|
-
A key advantage of path getters is that **no matter how deeply data is nested, all virtual properties are defined flat in one place**. This eliminates the need to split components just to hold computed properties at each nesting level.
|
|
568
|
-
|
|
569
|
-
```javascript
|
|
570
|
-
export default {
|
|
571
|
-
regions: [
|
|
572
|
-
{ name: "Kanto", prefectures: [
|
|
573
|
-
{ name: "Tokyo", cities: [
|
|
574
|
-
{ name: "Shibuya", population: 230000, area: 15.11 },
|
|
575
|
-
{ name: "Shinjuku", population: 346000, area: 18.22 }
|
|
576
|
-
]},
|
|
577
|
-
{ name: "Kanagawa", cities: [
|
|
578
|
-
{ name: "Yokohama", population: 3750000, area: 437.56 }
|
|
579
|
-
]}
|
|
580
|
-
]}
|
|
581
|
-
],
|
|
582
|
-
|
|
583
|
-
// --- All flat, regardless of nesting depth ---
|
|
584
|
-
|
|
585
|
-
// City level — virtual properties
|
|
586
|
-
get "regions.*.prefectures.*.cities.*.density"() {
|
|
587
|
-
return this["regions.*.prefectures.*.cities.*.population"]
|
|
588
|
-
/ this["regions.*.prefectures.*.cities.*.area"];
|
|
589
|
-
},
|
|
590
|
-
get "regions.*.prefectures.*.cities.*.label"() {
|
|
591
|
-
return this["regions.*.prefectures.*.name"] + " "
|
|
592
|
-
+ this["regions.*.prefectures.*.cities.*.name"];
|
|
593
|
-
},
|
|
594
|
-
|
|
595
|
-
// Prefecture level — aggregate from cities
|
|
596
|
-
get "regions.*.prefectures.*.totalPopulation"() {
|
|
597
|
-
return this.$getAll("regions.*.prefectures.*.cities.*.population", [])
|
|
598
|
-
.reduce((a, b) => a + b, 0);
|
|
599
|
-
},
|
|
600
|
-
|
|
601
|
-
// Region level — aggregate from prefectures
|
|
602
|
-
get "regions.*.totalPopulation"() {
|
|
603
|
-
return this.$getAll("regions.*.prefectures.*.totalPopulation", [])
|
|
604
|
-
.reduce((a, b) => a + b, 0);
|
|
605
|
-
},
|
|
606
|
-
|
|
607
|
-
// Top level — aggregate from regions
|
|
608
|
-
get totalPopulation() {
|
|
609
|
-
return this.$getAll("regions.*.totalPopulation", [])
|
|
610
|
-
.reduce((a, b) => a + b, 0);
|
|
611
|
-
}
|
|
612
|
-
};
|
|
613
|
-
```
|
|
614
|
-
|
|
615
|
-
Three levels of nesting, five virtual properties — all defined side by side in a single flat object. Each level can reference values from any depth, and aggregation flows naturally from bottom to top via `$getAll`. In component-based frameworks, the typical approach is to create a separate component for each nesting level and pass computed values through the tree. Path getters offer a different trade-off by keeping all definitions in one place.
|
|
616
|
-
|
|
617
|
-
### Accessing Sub-Properties of Getter Results
|
|
618
|
-
|
|
619
|
-
When a path getter returns an object, you can access its sub-properties via dot-path:
|
|
620
|
-
|
|
621
|
-
```javascript
|
|
622
|
-
export default {
|
|
623
|
-
products: [
|
|
624
|
-
{ id: "P001", name: "Widget", price: 500, stock: 10 },
|
|
625
|
-
{ id: "P002", name: "Gadget", price: 1200, stock: 3 }
|
|
626
|
-
],
|
|
627
|
-
cart: {
|
|
628
|
-
items: [
|
|
629
|
-
{ productId: "P001", quantity: 2 },
|
|
630
|
-
{ productId: "P002", quantity: 1 }
|
|
631
|
-
]
|
|
632
|
-
},
|
|
633
|
-
get productByProductId() {
|
|
634
|
-
return new Map(this.products.map(p => [p.id, p]));
|
|
635
|
-
},
|
|
636
|
-
// Returns the full product object
|
|
637
|
-
get "cart.items.*.product"() {
|
|
638
|
-
return this.productByProductId.get(this["cart.items.*.productId"]);
|
|
639
|
-
},
|
|
640
|
-
// Access sub-property of the returned object
|
|
641
|
-
get "cart.items.*.total"() {
|
|
642
|
-
return this["cart.items.*.product.price"] * this["cart.items.*.quantity"];
|
|
643
|
-
}
|
|
644
|
-
};
|
|
645
|
-
```
|
|
646
|
-
|
|
647
|
-
`this["cart.items.*.product.price"]` transparently chains through the object returned by the `cart.items.*.product` getter.
|
|
648
|
-
|
|
649
|
-
### Path Setters
|
|
650
|
-
|
|
651
|
-
Custom setter logic can be defined with `set "path"()`:
|
|
652
|
-
|
|
653
|
-
```javascript
|
|
654
|
-
export default {
|
|
655
|
-
users: [
|
|
656
|
-
{ firstName: "Alice", lastName: "Smith" },
|
|
657
|
-
{ firstName: "Bob", lastName: "Jones" }
|
|
658
|
-
],
|
|
659
|
-
get "users.*.fullName"() {
|
|
660
|
-
return this["users.*.firstName"] + " " + this["users.*.lastName"];
|
|
661
|
-
},
|
|
662
|
-
set "users.*.fullName"(value) {
|
|
663
|
-
const [first, ...rest] = value.split(" ");
|
|
664
|
-
this["users.*.firstName"] = first;
|
|
665
|
-
this["users.*.lastName"] = rest.join(" ");
|
|
666
|
-
}
|
|
667
|
-
};
|
|
668
|
-
```
|
|
669
|
-
|
|
670
|
-
```html
|
|
671
|
-
<template data-wcs="for: users">
|
|
672
|
-
<input type="text" data-wcs="value: .fullName">
|
|
673
|
-
</template>
|
|
674
|
-
```
|
|
675
|
-
|
|
676
|
-
Two-way binding works with path setters — editing the input calls the setter, which splits and writes back to `firstName` / `lastName`.
|
|
677
|
-
|
|
678
|
-
### Supported Path Getter Patterns
|
|
679
|
-
|
|
680
|
-
| Pattern | Description | Example |
|
|
681
|
-
|---|---|---|
|
|
682
|
-
| `get prop()` | Top-level computed | `get total()` |
|
|
683
|
-
| `get "a.b"()` | Nested computed (no wildcard) | `get "cart.totalPrice"()` |
|
|
684
|
-
| `get "a.*.b"()` | Single wildcard | `get "users.*.fullName"()` |
|
|
685
|
-
| `get "a.*.b.*.c"()` | Multiple wildcards | `get "categories.*.items.*.label"()` |
|
|
686
|
-
| `set "a.*.b"(v)` | Wildcard setter | `set "users.*.fullName"(v)` |
|
|
687
|
-
|
|
688
|
-
### How It Works
|
|
689
|
-
|
|
690
|
-
1. **Context resolution** — When a `for:` loop renders, each iteration pushes a `ListIndex` onto the address stack. Inside a path getter, `this["users.*.name"]` resolves the `*` using this stack, so it always points to the current element.
|
|
691
|
-
|
|
692
|
-
2. **Automatic dependency tracking** — When a getter accesses `this["users.*.name"]`, the system registers a dynamic dependency from `users.*.name` to the getter's path. When `users.*.name` changes, the getter's cache is dirtied.
|
|
693
|
-
|
|
694
|
-
3. **Caching** — Getter results are cached per concrete address (path + loop index). `users.*.fullName` at index 0 has a separate cache entry from index 1. The cache is invalidated only when dependencies change.
|
|
695
|
-
|
|
696
|
-
4. **Direct index access** — You can also access specific elements by numeric index: `this["users.0.name"]` resolves as `users[0].name` without needing loop context.
|
|
697
|
-
|
|
698
|
-
### Loop Index Variables (`$1`, `$2`, ...)
|
|
699
|
-
|
|
700
|
-
Inside getters and event handlers, `this.$1`, `this.$2`, etc. provide the current loop iteration index (0-based value, 1-based naming):
|
|
701
|
-
|
|
702
|
-
```javascript
|
|
703
|
-
export default {
|
|
704
|
-
users: ["Alice", "Bob", "Charlie"],
|
|
705
|
-
get "users.*.rowLabel"() {
|
|
706
|
-
return "#" + (this.$1 + 1) + ": " + this["users.*"];
|
|
707
|
-
}
|
|
708
|
-
};
|
|
709
|
-
```
|
|
710
|
-
|
|
711
|
-
```html
|
|
712
|
-
<template data-wcs="for: users">
|
|
713
|
-
<div data-wcs="textContent: .rowLabel"></div>
|
|
714
|
-
</template>
|
|
715
|
-
<!-- Output:
|
|
716
|
-
#1: Alice
|
|
717
|
-
#2: Bob
|
|
718
|
-
#3: Charlie
|
|
719
|
-
-->
|
|
720
|
-
```
|
|
721
|
-
|
|
722
|
-
For nested loops, `$1` is the outer index and `$2` is the inner index.
|
|
723
|
-
|
|
724
|
-
You can also display the loop index directly in templates:
|
|
725
|
-
|
|
726
|
-
```html
|
|
727
|
-
<template data-wcs="for: items">
|
|
728
|
-
<td>{{ $1|inc(1) }}</td> <!-- 1-based row number -->
|
|
729
|
-
</template>
|
|
730
|
-
```
|
|
731
|
-
|
|
732
|
-
### Proxy APIs
|
|
733
|
-
|
|
734
|
-
Inside state objects (getters / methods), the following APIs are available via `this`:
|
|
735
|
-
|
|
736
|
-
| API | Description |
|
|
737
|
-
|---|---|
|
|
738
|
-
| `this.$getAll(path, indexes?)` | Get all values matching a wildcard path |
|
|
739
|
-
| `this.$resolve(path, indexes, value?)` | Resolve a wildcard path with specific indexes |
|
|
740
|
-
| `this.$postUpdate(path)` | Manually trigger update notification for a path |
|
|
741
|
-
| `this.$trackDependency(path)` | Manually register a dependency for cache invalidation |
|
|
742
|
-
| `this.$stateElement` | Access to the `IStateElement` instance |
|
|
743
|
-
| `this.$1`, `this.$2`, ... | Current loop index (1-based naming, 0-based value) |
|
|
744
|
-
|
|
745
|
-
#### `$getAll` — Aggregate Across Array Elements
|
|
746
|
-
|
|
747
|
-
`$getAll` collects all values that match a wildcard path, returning them as an array. Essential for aggregation patterns:
|
|
748
|
-
|
|
749
|
-
```javascript
|
|
750
|
-
export default {
|
|
751
|
-
scores: [85, 92, 78, 95, 88],
|
|
752
|
-
get average() {
|
|
753
|
-
const all = this.$getAll("scores.*", []);
|
|
754
|
-
return all.reduce((sum, v) => sum + v, 0) / all.length;
|
|
755
|
-
},
|
|
756
|
-
get max() {
|
|
757
|
-
return Math.max(...this.$getAll("scores.*", []));
|
|
758
|
-
}
|
|
759
|
-
};
|
|
760
|
-
```
|
|
761
|
-
|
|
762
|
-
#### `$resolve` — Access by Explicit Index
|
|
763
|
-
|
|
764
|
-
`$resolve` reads or writes a value at a specific wildcard index:
|
|
765
|
-
|
|
766
|
-
```javascript
|
|
767
|
-
export default {
|
|
768
|
-
items: ["A", "B", "C"],
|
|
769
|
-
swapFirstTwo() {
|
|
770
|
-
const a = this.$resolve("items.*", [0]);
|
|
771
|
-
const b = this.$resolve("items.*", [1]);
|
|
772
|
-
this.$resolve("items.*", [0], b);
|
|
773
|
-
this.$resolve("items.*", [1], a);
|
|
774
|
-
}
|
|
775
|
-
};
|
|
776
|
-
```
|
|
777
|
-
|
|
778
|
-
## Event Handling
|
|
779
|
-
|
|
780
|
-
Bind event handlers with `on*` properties:
|
|
781
|
-
|
|
782
|
-
```html
|
|
783
|
-
<button data-wcs="onclick: handleClick">Click me</button>
|
|
784
|
-
<form data-wcs="onsubmit#prevent: handleSubmit">...</form>
|
|
785
|
-
```
|
|
786
|
-
|
|
787
|
-
Handler methods receive the event and loop indexes:
|
|
788
|
-
|
|
789
|
-
```javascript
|
|
790
|
-
export default {
|
|
791
|
-
items: ["A", "B", "C"],
|
|
792
|
-
handleClick(event) {
|
|
793
|
-
console.log("clicked");
|
|
794
|
-
},
|
|
795
|
-
removeItem(event, index) {
|
|
796
|
-
// index is the loop context ($1)
|
|
797
|
-
this.items = this.items.toSpliced(index, 1);
|
|
798
|
-
}
|
|
799
|
-
};
|
|
800
|
-
```
|
|
801
|
-
|
|
802
|
-
```html
|
|
803
|
-
<template data-wcs="for: items">
|
|
804
|
-
<button data-wcs="onclick: removeItem">Delete</button>
|
|
805
|
-
</template>
|
|
806
|
-
```
|
|
807
|
-
|
|
808
|
-
## Filters
|
|
809
|
-
|
|
810
|
-
40 built-in filters are available for both input (DOM → state) and output (state → DOM) directions.
|
|
811
|
-
|
|
812
|
-
### Comparison
|
|
813
|
-
|
|
814
|
-
| Filter | Description | Example |
|
|
815
|
-
|---|---|---|
|
|
816
|
-
| `eq(value)` | Equal | `count\|eq(0)` → `true/false` |
|
|
817
|
-
| `ne(value)` | Not equal | `count\|ne(0)` |
|
|
818
|
-
| `not` | Boolean NOT | `isActive\|not` |
|
|
819
|
-
| `lt(n)` | Less than | `count\|lt(10)` |
|
|
820
|
-
| `le(n)` | Less than or equal | `count\|le(10)` |
|
|
821
|
-
| `gt(n)` | Greater than | `count\|gt(0)` |
|
|
822
|
-
| `ge(n)` | Greater than or equal | `count\|ge(0)` |
|
|
823
|
-
|
|
824
|
-
### Arithmetic
|
|
825
|
-
|
|
826
|
-
| Filter | Description | Example |
|
|
827
|
-
|---|---|---|
|
|
828
|
-
| `inc(n)` | Add | `count\|inc(1)` |
|
|
829
|
-
| `dec(n)` | Subtract | `count\|dec(1)` |
|
|
830
|
-
| `mul(n)` | Multiply | `price\|mul(1.1)` |
|
|
831
|
-
| `div(n)` | Divide | `total\|div(100)` |
|
|
832
|
-
| `mod(n)` | Modulo | `index\|mod(2)` |
|
|
833
|
-
|
|
834
|
-
### Number Formatting
|
|
835
|
-
|
|
836
|
-
| Filter | Description | Example |
|
|
837
|
-
|---|---|---|
|
|
838
|
-
| `fix(n)` | Fixed decimal places | `price\|fix(2)` → `"100.00"` |
|
|
839
|
-
| `round(n?)` | Round | `value\|round(2)` |
|
|
840
|
-
| `floor(n?)` | Floor | `value\|floor` |
|
|
841
|
-
| `ceil(n?)` | Ceiling | `value\|ceil` |
|
|
842
|
-
| `locale(loc?)` | Locale number format | `count\|locale` / `count\|locale(ja-JP)` |
|
|
843
|
-
| `percent(n?)` | Percentage format | `ratio\|percent(1)` |
|
|
844
|
-
|
|
845
|
-
### String
|
|
846
|
-
|
|
847
|
-
| Filter | Description | Example |
|
|
848
|
-
|---|---|---|
|
|
849
|
-
| `uc` | Upper case | `name\|uc` |
|
|
850
|
-
| `lc` | Lower case | `name\|lc` |
|
|
851
|
-
| `cap` | Capitalize | `name\|cap` |
|
|
852
|
-
| `trim` | Trim whitespace | `text\|trim` |
|
|
853
|
-
| `slice(n)` | Slice string | `text\|slice(5)` |
|
|
854
|
-
| `substr(start, length)` | Substring | `text\|substr(0,10)` |
|
|
855
|
-
| `pad(n, char?)` | Pad start | `id\|pad(5,0)` → `"00001"` |
|
|
856
|
-
| `rep(n)` | Repeat | `text\|rep(3)` |
|
|
857
|
-
| `rev` | Reverse | `text\|rev` |
|
|
858
|
-
|
|
859
|
-
### Type Conversion
|
|
860
|
-
|
|
861
|
-
| Filter | Description | Example |
|
|
862
|
-
|---|---|---|
|
|
863
|
-
| `int` | Parse integer | `input\|int` |
|
|
864
|
-
| `float` | Parse float | `input\|float` |
|
|
865
|
-
| `boolean` | To boolean | `value\|boolean` |
|
|
866
|
-
| `number` | To number | `value\|number` |
|
|
867
|
-
| `string` | To string | `value\|string` |
|
|
868
|
-
| `null` | To null | `value\|null` |
|
|
869
|
-
|
|
870
|
-
### Date / Time
|
|
871
|
-
|
|
872
|
-
| Filter | Description | Example |
|
|
873
|
-
|---|---|---|
|
|
874
|
-
| `date(loc?)` | Date format | `timestamp\|date` / `timestamp\|date(ja-JP)` |
|
|
875
|
-
| `time(loc?)` | Time format | `timestamp\|time` |
|
|
876
|
-
| `datetime(loc?)` | Date + Time | `timestamp\|datetime(en-US)` |
|
|
877
|
-
| `ymd(sep?)` | YYYY-MM-DD | `timestamp\|ymd` / `timestamp\|ymd(/)` |
|
|
878
|
-
|
|
879
|
-
### Boolean / Default
|
|
880
|
-
|
|
881
|
-
| Filter | Description | Example |
|
|
882
|
-
|---|---|---|
|
|
883
|
-
| `truthy` | Truthy check | `value\|truthy` |
|
|
884
|
-
| `falsy` | Falsy check | `value\|falsy` |
|
|
885
|
-
| `defaults(v)` | Fallback value | `name\|defaults(Anonymous)` |
|
|
886
|
-
|
|
887
|
-
### Filter Chaining
|
|
888
|
-
|
|
889
|
-
Filters can be chained with `|`:
|
|
890
|
-
|
|
891
|
-
```html
|
|
892
|
-
<div data-wcs="textContent: price|mul(1.1)|round(2)|locale(ja-JP)"></div>
|
|
893
|
-
```
|
|
894
|
-
|
|
895
|
-
## Web Component Binding
|
|
896
|
-
|
|
897
|
-
`@wcstack/state` supports bidirectional state binding with custom elements using Shadow DOM or Light DOM.
|
|
898
|
-
|
|
899
|
-
Many frameworks use patterns like prop drilling, context providers, or external stores (Redux, Pinia) to share state across components. `@wcstack/state` takes a different approach: parent and child components are connected through **path contracts** — the parent binds an outer state path to an inner component property via `data-wcs`, and the child simply reads and writes its own state as usual:
|
|
900
|
-
|
|
901
|
-
1. The child references and updates the parent's state through its own state proxy — no props, no events, no awareness of the parent.
|
|
902
|
-
2. When the parent's state changes, the Proxy `set` trap automatically notifies any child bindings that reference the affected path.
|
|
903
|
-
3. Because the only coupling is the **path name**, both sides remain loosely coupled and independently testable.
|
|
904
|
-
4. The cost is path resolution (cached at O(1) after first access) plus change propagation through the dependency graph.
|
|
905
|
-
|
|
906
|
-
This provides a lightweight approach to cross-component state management based on path resolution rather than component-level abstractions.
|
|
907
|
-
|
|
908
|
-
### Component Definition (Shadow DOM)
|
|
909
|
-
|
|
910
|
-
```javascript
|
|
911
|
-
class MyComponent extends HTMLElement {
|
|
912
|
-
state = { message: "" };
|
|
913
|
-
|
|
914
|
-
constructor() {
|
|
915
|
-
super();
|
|
916
|
-
this.attachShadow({ mode: "open" });
|
|
917
|
-
this.shadowRoot.innerHTML = `
|
|
918
|
-
<wcs-state bind-component="state"></wcs-state>
|
|
919
|
-
<div>{{ message }}</div>
|
|
920
|
-
<input type="text" data-wcs="value: message" />
|
|
921
|
-
`;
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
customElements.define("my-component", MyComponent);
|
|
925
|
-
```
|
|
926
|
-
|
|
927
|
-
### Component Definition (Light DOM)
|
|
928
|
-
|
|
929
|
-
Light DOM components do not use Shadow DOM. The state namespace is shared with the parent scope (just like CSS), so a `name` attribute is required.
|
|
930
|
-
|
|
931
|
-
```javascript
|
|
932
|
-
class MyLightComponent extends HTMLElement {
|
|
933
|
-
state = { message: "" };
|
|
934
|
-
|
|
935
|
-
connectedCallback() {
|
|
936
|
-
this.innerHTML = `
|
|
937
|
-
<wcs-state bind-component="state" name="my-light"></wcs-state>
|
|
938
|
-
<div data-wcs="text: message@my-light"></div>
|
|
939
|
-
<input type="text" data-wcs="value: message@my-light" />
|
|
940
|
-
`;
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
customElements.define("my-light-component", MyLightComponent);
|
|
944
|
-
```
|
|
945
|
-
|
|
946
|
-
- `name` attribute is **required** for Light DOM components (namespace is shared with the parent scope)
|
|
947
|
-
- Bindings must explicitly reference the state name with `@my-light`
|
|
948
|
-
- `<wcs-state>` must be a direct child of the component element
|
|
949
|
-
|
|
950
|
-
### Host Usage
|
|
951
|
-
|
|
952
|
-
```html
|
|
953
|
-
<wcs-state>
|
|
954
|
-
<script type="module">
|
|
955
|
-
export default {
|
|
956
|
-
user: { name: "Alice" }
|
|
957
|
-
};
|
|
958
|
-
</script>
|
|
959
|
-
</wcs-state>
|
|
960
|
-
|
|
961
|
-
<!-- Bind component's state.message to outer user.name -->
|
|
962
|
-
<my-component data-wcs="state.message: user.name"></my-component>
|
|
963
|
-
```
|
|
964
|
-
|
|
965
|
-
- `bind-component="state"` maps the component's `state` property to `<wcs-state>`
|
|
966
|
-
- `data-wcs="state.message: user.name"` on the host element binds outer state paths to inner component state properties
|
|
967
|
-
- Changes propagate bidirectionally between the component and the outer state
|
|
968
|
-
|
|
969
|
-
### Standalone Web Component Injection (`__e2e__/single-component`)
|
|
970
|
-
|
|
971
|
-
Even when a component is independent from outer host state, you can inject reactive state with `bind-component`.
|
|
972
|
-
|
|
973
|
-
```javascript
|
|
974
|
-
class MyComponent extends HTMLElement {
|
|
975
|
-
state = Object.freeze({
|
|
976
|
-
message: "Hello, World!"
|
|
977
|
-
});
|
|
978
|
-
|
|
979
|
-
constructor() {
|
|
980
|
-
super();
|
|
981
|
-
this.attachShadow({ mode: "open" });
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
connectedCallback() {
|
|
985
|
-
this.shadowRoot.innerHTML = `
|
|
986
|
-
<wcs-state bind-component="state"></wcs-state>
|
|
987
|
-
<div>{{ message }}</div>
|
|
988
|
-
`;
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
async $stateReadyCallback(stateProp) {
|
|
992
|
-
console.log("state ready:", stateProp); // "state"
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
customElements.define("my-component", MyComponent);
|
|
996
|
-
```
|
|
997
|
-
|
|
998
|
-
- Initial component `state` can be defined with `Object.freeze(...)` (it is replaced with a writable reactive state after injection)
|
|
999
|
-
- `bind-component="state"` exposes `this.state` as a state proxy powered by `@wcstack/state`
|
|
1000
|
-
- Assignments like `this.state.message = "..."` immediately update `{{ message }}` inside Shadow DOM
|
|
1001
|
-
- `async $stateReadyCallback(stateProp)` is called right after component state becomes ready for use (`stateProp` is the property name from `bind-component`)
|
|
1002
|
-
|
|
1003
|
-
### Constraints
|
|
1004
|
-
|
|
1005
|
-
- `<wcs-state>` with `bind-component` must be a **direct child** of the component element (top-level)
|
|
1006
|
-
- The parent element must be a **custom element** (tag name containing a hyphen)
|
|
1007
|
-
- Light DOM components **require** a `name` attribute to avoid namespace conflicts with the parent scope
|
|
1008
|
-
- Light DOM bindings must reference the state name explicitly (e.g., `@my-light`)
|
|
1009
|
-
|
|
1010
|
-
### Loop with Components
|
|
1011
|
-
|
|
1012
|
-
```html
|
|
1013
|
-
<template data-wcs="for: users">
|
|
1014
|
-
<my-component data-wcs="state.message: .name"></my-component>
|
|
1015
|
-
</template>
|
|
1016
|
-
```
|
|
1017
|
-
|
|
1018
|
-
## Declarative Custom Components (DCC)
|
|
1019
|
-
|
|
1020
|
-
Define custom elements **entirely in HTML** — no JavaScript class definition needed. Using `data-wc-definition` and Declarative Shadow DOM (`<template shadowrootmode>`), you can declare reusable components with reactive state inline.
|
|
1021
|
-
|
|
1022
|
-
### Basic Definition
|
|
1023
|
-
|
|
1024
|
-
```html
|
|
1025
|
-
<!-- 1. Define the component (hidden by CSS) -->
|
|
1026
|
-
<my-counter data-wc-definition>
|
|
1027
|
-
<template shadowrootmode="open">
|
|
1028
|
-
<p>{{ count }}</p>
|
|
1029
|
-
<button data-wcs="onclick: increment">+1</button>
|
|
1030
|
-
<wcs-state>
|
|
1031
|
-
<script type="module">
|
|
1032
|
-
export default {
|
|
1033
|
-
count: 0,
|
|
1034
|
-
increment() { this.count++; },
|
|
1035
|
-
$bindables: ["count"]
|
|
1036
|
-
};
|
|
1037
|
-
</script>
|
|
1038
|
-
</wcs-state>
|
|
1039
|
-
</template>
|
|
1040
|
-
</my-counter>
|
|
1041
|
-
|
|
1042
|
-
<!-- 2. Use it — each instance gets its own state -->
|
|
1043
|
-
<my-counter></my-counter>
|
|
1044
|
-
<my-counter></my-counter>
|
|
1045
|
-
```
|
|
1046
|
-
|
|
1047
|
-
When `<wcs-state>` detects it is inside a `data-wc-definition` host, it:
|
|
1048
|
-
|
|
1049
|
-
1. Loads the state object (from `<script type="module">` or `src="*.js"`)
|
|
1050
|
-
2. Generates a custom element class with getter/setter/method properties on the prototype
|
|
1051
|
-
3. Registers it via `customElements.define()`
|
|
1052
|
-
|
|
1053
|
-
The definition element is hidden; each instance clones the template into its own Shadow DOM and initializes its own `<wcs-state>`.
|
|
1054
|
-
|
|
1055
|
-
### Recommended CSS
|
|
1056
|
-
|
|
1057
|
-
```css
|
|
1058
|
-
:not(:defined) { display: none; }
|
|
1059
|
-
[data-wc-definition] { display: none; }
|
|
1060
|
-
```
|
|
1061
|
-
|
|
1062
|
-
### `$bindables` and wc-bindable Protocol
|
|
1063
|
-
|
|
1064
|
-
The `$bindables` array declares which state properties are exposed as component properties with change events, following the [wc-bindable protocol](https://github.com/nicenemo/nicenemo/blob/main/docs/wc-bindable-protocol.md):
|
|
1065
|
-
|
|
1066
|
-
```javascript
|
|
1067
|
-
export default {
|
|
1068
|
-
count: 0,
|
|
1069
|
-
increment() { this.count++; },
|
|
1070
|
-
$bindables: ["count"]
|
|
1071
|
-
};
|
|
1072
|
-
```
|
|
1073
|
-
|
|
1074
|
-
This generates:
|
|
1075
|
-
|
|
1076
|
-
- `static wcBindable` on the class — protocol metadata for framework adapters
|
|
1077
|
-
- Getter/setter on the prototype — reads/writes go through the reactive proxy
|
|
1078
|
-
- `CustomEvent` dispatch — `my-counter:count-changed` fires on every mutation
|
|
1079
|
-
|
|
1080
|
-
### Binding to DCC Properties
|
|
1081
|
-
|
|
1082
|
-
Other `<wcs-state>` instances can bind to DCC properties just like any Web Component:
|
|
1083
|
-
|
|
1084
|
-
```html
|
|
1085
|
-
<my-counter data-wcs="count: parentCount"></my-counter>
|
|
1086
|
-
|
|
1087
|
-
<wcs-state>
|
|
1088
|
-
<script type="module">
|
|
1089
|
-
export default { parentCount: 0 };
|
|
1090
|
-
</script>
|
|
1091
|
-
</wcs-state>
|
|
1092
|
-
<div data-wcs="textContent: parentCount"></div>
|
|
1093
|
-
```
|
|
1094
|
-
|
|
1095
|
-
### Shadow Root Mode
|
|
1096
|
-
|
|
1097
|
-
Both `open` and `closed` modes are supported:
|
|
1098
|
-
|
|
1099
|
-
```html
|
|
1100
|
-
<my-component data-wc-definition>
|
|
1101
|
-
<template shadowrootmode="closed">
|
|
1102
|
-
<!-- closed shadow DOM -->
|
|
1103
|
-
</template>
|
|
1104
|
-
</my-component>
|
|
1105
|
-
```
|
|
1106
|
-
|
|
1107
|
-
### Internal Properties
|
|
1108
|
-
|
|
1109
|
-
Properties prefixed with `$` are internal and not exposed on the component prototype:
|
|
1110
|
-
|
|
1111
|
-
| Property | Purpose |
|
|
1112
|
-
|----------|---------|
|
|
1113
|
-
| `$bindables` | Declares observable properties |
|
|
1114
|
-
| `$connectedCallback` | Lifecycle hook (runs on each instance) |
|
|
1115
|
-
| `$disconnectedCallback` | Cleanup hook |
|
|
1116
|
-
| `$updatedCallback` | Called after state mutations |
|
|
1117
|
-
|
|
1118
|
-
## SVG Support
|
|
1119
|
-
|
|
1120
|
-
All bindings work inside `<svg>` elements. Use `attr.*` for SVG attributes:
|
|
1121
|
-
|
|
1122
|
-
```html
|
|
1123
|
-
<svg width="200" height="100">
|
|
1124
|
-
<template data-wcs="for: points">
|
|
1125
|
-
<circle data-wcs="attr.cx: .x; attr.cy: .y; attr.fill: .color" r="5" />
|
|
1126
|
-
</template>
|
|
1127
|
-
</svg>
|
|
1128
|
-
```
|
|
1129
|
-
|
|
1130
|
-
## Lifecycle Hooks
|
|
1131
|
-
|
|
1132
|
-
State objects can define `$connectedCallback`, `$disconnectedCallback`, and `$updatedCallback` for initialization, cleanup, and update lifecycle handling.
|
|
1133
|
-
|
|
1134
|
-
```html
|
|
1135
|
-
<wcs-state>
|
|
1136
|
-
<script type="module">
|
|
1137
|
-
export default {
|
|
1138
|
-
timer: null,
|
|
1139
|
-
count: 0,
|
|
1140
|
-
|
|
1141
|
-
// Called when <wcs-state> is connected to the DOM
|
|
1142
|
-
async $connectedCallback() {
|
|
1143
|
-
const res = await fetch("/api/initial-count");
|
|
1144
|
-
this.count = await res.json();
|
|
1145
|
-
this.timer = setInterval(() => { this.count++; }, 1000);
|
|
1146
|
-
},
|
|
1147
|
-
|
|
1148
|
-
// Called when <wcs-state> is disconnected from the DOM (sync only)
|
|
1149
|
-
$disconnectedCallback() {
|
|
1150
|
-
clearInterval(this.timer);
|
|
1151
|
-
}
|
|
1152
|
-
};
|
|
1153
|
-
</script>
|
|
1154
|
-
</wcs-state>
|
|
1155
|
-
```
|
|
1156
|
-
|
|
1157
|
-
| Hook | Timing | Async |
|
|
1158
|
-
|---|---|---|
|
|
1159
|
-
| `$connectedCallback` | After state initialization on first connect; on every reconnect thereafter | Yes (awaited) |
|
|
1160
|
-
| `$disconnectedCallback` | When the element is removed from the DOM | No (sync only) |
|
|
1161
|
-
| `$updatedCallback(paths, indexesListByPath)` | After state updates are applied | Yes (not awaited) |
|
|
1162
|
-
|
|
1163
|
-
All hooks except `$disconnectedCallback` support `async` — you can use `async/await` in any of them. Since the reactive proxy detects every property assignment as a change, standard `async/await` with direct property updates is sufficient for asynchronous operations — loading flags, fetched data, and error messages are all just property assignments, without requiring additional abstractions for async state management.
|
|
1164
|
-
|
|
1165
|
-
- `this` inside hooks is the state proxy with full read/write access
|
|
1166
|
-
- `$connectedCallback` is called **every time** the element is connected (including re-insertion after removal), making it suitable for setup that should be re-established
|
|
1167
|
-
- `$disconnectedCallback` is called synchronously — use it for cleanup such as clearing timers, removing event listeners, or releasing resources
|
|
1168
|
-
- `$updatedCallback(paths, indexesListByPath)` receives the updated path list. For wildcard updates, `indexesListByPath` contains the updated index sets. Can be `async`, but the return value is not awaited
|
|
1169
|
-
- In Web Components, define `async $stateReadyCallback(stateProp)` to receive a hook when the bound state becomes available via `bind-component`
|
|
1170
|
-
|
|
1171
|
-
## Configuration
|
|
1172
|
-
|
|
1173
|
-
Pass a partial configuration object to `bootstrapState()`:
|
|
1174
|
-
|
|
1175
|
-
```javascript
|
|
1176
|
-
import { bootstrapState } from '@wcstack/state';
|
|
1177
|
-
|
|
1178
|
-
bootstrapState({
|
|
1179
|
-
locale: 'ja-JP',
|
|
1180
|
-
debug: true,
|
|
1181
|
-
enableMustache: false,
|
|
1182
|
-
tagNames: { state: 'my-state' },
|
|
1183
|
-
});
|
|
1184
|
-
```
|
|
1185
|
-
|
|
1186
|
-
All options with defaults:
|
|
1187
|
-
|
|
1188
|
-
| Option | Default | Description |
|
|
1189
|
-
|---|---|---|
|
|
1190
|
-
| `bindAttributeName` | `'data-wcs'` | Binding attribute name |
|
|
1191
|
-
| `tagNames.state` | `'wcs-state'` | State element tag name |
|
|
1192
|
-
| `locale` | `'en'` | Default locale for filters |
|
|
1193
|
-
| `debug` | `false` | Debug mode |
|
|
1194
|
-
| `enableMustache` | `true` | Enable `{{ }}` syntax |
|
|
1195
|
-
|
|
1196
|
-
## TypeScript Support
|
|
1197
|
-
|
|
1198
|
-
`defineState()` wraps your state object and provides type-safe `this` inside methods and getters — with zero runtime cost (identity function).
|
|
1199
|
-
|
|
1200
|
-
```typescript
|
|
1201
|
-
import { defineState } from '@wcstack/state';
|
|
1202
|
-
|
|
1203
|
-
export default defineState({
|
|
1204
|
-
count: 0,
|
|
1205
|
-
users: [] as { name: string; age: number }[],
|
|
1206
|
-
|
|
1207
|
-
increment() {
|
|
1208
|
-
this.count++; // ✅ number
|
|
1209
|
-
this["users.*.name"]; // ✅ string (dot-path resolution)
|
|
1210
|
-
this.$getAll("users.*.age", []); // ✅ API method
|
|
1211
|
-
},
|
|
1212
|
-
|
|
1213
|
-
get "users.*.ageCategory"() {
|
|
1214
|
-
return this["users.*.age"] < 25 ? "Young" : "Adult";
|
|
1215
|
-
}
|
|
1216
|
-
});
|
|
1217
|
-
```
|
|
1218
|
-
|
|
1219
|
-
Utility types `WcsPaths<T>` and `WcsPathValue<T, P>` are also exported for advanced use cases. See [docs/define-state.md](docs/define-state.md) for full documentation.
|
|
1220
|
-
|
|
1221
|
-
## API Reference
|
|
1222
|
-
|
|
1223
|
-
### `bootstrapState()`
|
|
1224
|
-
|
|
1225
|
-
Initialize the state system. Registers `<wcs-state>` custom element and sets up DOM content loaded handler.
|
|
1226
|
-
|
|
1227
|
-
```javascript
|
|
1228
|
-
import { bootstrapState } from '@wcstack/state';
|
|
1229
|
-
bootstrapState();
|
|
1230
|
-
```
|
|
1231
|
-
|
|
1232
|
-
### `<wcs-state>` Element
|
|
1233
|
-
|
|
1234
|
-
| Attribute | Description |
|
|
1235
|
-
|---|---|
|
|
1236
|
-
| `name` | State name (default: `"default"`) |
|
|
1237
|
-
| `state` | ID of a `<script type="application/json">` element |
|
|
1238
|
-
| `src` | URL to `.json` or `.js` file |
|
|
1239
|
-
| `json` | Inline JSON string |
|
|
1240
|
-
| `bind-component` | Property name for web component binding |
|
|
1241
|
-
|
|
1242
|
-
### IStateElement
|
|
1243
|
-
|
|
1244
|
-
| Property / Method | Description |
|
|
1245
|
-
|---|---|
|
|
1246
|
-
| `name` | State name |
|
|
1247
|
-
| `initializePromise` | Resolves when state is fully initialized |
|
|
1248
|
-
| `listPaths` | Set of paths used in `for` loops |
|
|
1249
|
-
| `getterPaths` | Set of paths defined as getters |
|
|
1250
|
-
| `setterPaths` | Set of paths defined as setters |
|
|
1251
|
-
| `createState(mutability, callback)` | Create a state proxy (`"readonly"` or `"writable"`) |
|
|
1252
|
-
| `createStateAsync(mutability, callback)` | Async version of `createState` |
|
|
1253
|
-
| `setInitialState(state)` | Set state programmatically (before initialization) |
|
|
1254
|
-
| `bindProperty(prop, descriptor)` | Define a property on the raw state object |
|
|
1255
|
-
| `nextVersion()` | Increment and return version number |
|
|
1256
|
-
|
|
1257
|
-
## Architecture
|
|
1258
|
-
|
|
1259
|
-
```
|
|
1260
|
-
bootstrapState()
|
|
1261
|
-
└── registerComponents() // Register <wcs-state> custom element
|
|
1262
|
-
|
|
1263
|
-
<wcs-state> connectedCallback
|
|
1264
|
-
├── _initializeBindWebComponent() // bind-component: get state from parent component
|
|
1265
|
-
├── _initialize() // Load state (state attr / src / json / script / API)
|
|
1266
|
-
│ └── setStateElementByName() // Register to WeakMap<Node, Map<name, element>>
|
|
1267
|
-
│ └── (first registration per rootNode)
|
|
1268
|
-
│ └── queueMicrotask → buildBindings()
|
|
1269
|
-
├── _callStateConnectedCallback() // Call $connectedCallback if defined
|
|
1270
|
-
|
|
1271
|
-
buildBindings(root)
|
|
1272
|
-
├── waitForStateInitialize() // Wait for all <wcs-state> initializePromise
|
|
1273
|
-
├── convertMustacheToComments() // {{ }} → comment nodes
|
|
1274
|
-
├── collectStructuralFragments() // Collect for/if templates
|
|
1275
|
-
└── initializeBindings() // Walk DOM, parse data-wcs, set up bindings
|
|
1276
|
-
```
|
|
1277
|
-
|
|
1278
|
-
### Reactivity Flow
|
|
1279
|
-
|
|
1280
|
-
1. State changes via Proxy `set` trap → `setByAddress()`
|
|
1281
|
-
2. Address resolved → updater enqueues absolute address
|
|
1282
|
-
3. Dependency walker invalidates (dirties) downstream caches
|
|
1283
|
-
4. Updater applies changes to bound DOM nodes via `applyChangeFromBindings()`
|
|
1284
|
-
|
|
1285
|
-
### State Address System
|
|
1286
|
-
|
|
1287
|
-
Paths like `users.*.name` are decomposed into:
|
|
1288
|
-
|
|
1289
|
-
- **PathInfo** — static path metadata (segments, wildcard count, parent path)
|
|
1290
|
-
- **ListIndex** — runtime loop index chain
|
|
1291
|
-
- **StateAddress** — combination of PathInfo + ListIndex
|
|
1292
|
-
- **AbsoluteStateAddress** — state name + StateAddress (for cross-state references)
|
|
1293
|
-
|
|
1294
|
-
## Server-Side Rendering
|
|
1295
|
-
|
|
1296
|
-
`@wcstack/state` supports SSR via the companion [`@wcstack/server`](../server/) package. The same templates you write for the client render on the server — no changes needed.
|
|
1297
|
-
|
|
1298
|
-
### Quick Setup
|
|
1299
|
-
|
|
1300
|
-
1. Add `enable-ssr` to your `<wcs-state>` element:
|
|
1301
|
-
|
|
1302
|
-
```html
|
|
1303
|
-
<wcs-state enable-ssr>
|
|
1304
|
-
<script type="module">
|
|
1305
|
-
export default {
|
|
1306
|
-
items: [],
|
|
1307
|
-
async $connectedCallback() {
|
|
1308
|
-
const res = await fetch("/api/items");
|
|
1309
|
-
this.items = await res.json();
|
|
1310
|
-
}
|
|
1311
|
-
};
|
|
1312
|
-
</script>
|
|
1313
|
-
</wcs-state>
|
|
1314
|
-
<template data-wcs="for: items">
|
|
1315
|
-
<div data-wcs="textContent: items.*.name"></div>
|
|
1316
|
-
</template>
|
|
1317
|
-
```
|
|
1318
|
-
|
|
1319
|
-
2. Render on the server:
|
|
1320
|
-
|
|
1321
|
-
```javascript
|
|
1322
|
-
import { renderToString } from "@wcstack/server";
|
|
1323
|
-
|
|
1324
|
-
const html = await renderToString(template, {
|
|
1325
|
-
baseUrl: "http://localhost:3000"
|
|
1326
|
-
});
|
|
1327
|
-
```
|
|
1328
|
-
|
|
1329
|
-
That's it. The client-side `@wcstack/state` automatically detects the `<wcs-ssr>` element, restores state from the JSON snapshot, and resumes reactivity without re-rendering.
|
|
1330
|
-
|
|
1331
|
-
### How It Works
|
|
1332
|
-
|
|
1333
|
-
| Phase | What happens |
|
|
1334
|
-
|-------|-------------|
|
|
1335
|
-
| **Server** | `renderToString()` runs your template in happy-dom, executes `$connectedCallback` (including `fetch()`), applies all bindings, and outputs rendered HTML with a `<wcs-ssr>` element containing hydration data |
|
|
1336
|
-
| **Client** | `<wcs-state enable-ssr>` loads state from `<wcs-ssr>` JSON, skips `$connectedCallback`, and `hydrateBindings()` wires up reactivity on the existing DOM |
|
|
1337
|
-
| **Fallback** | If server/client versions mismatch, the SSR DOM is cleaned up and `buildBindings()` runs a full client-side render |
|
|
1338
|
-
|
|
1339
|
-
### What `enable-ssr` Does
|
|
1340
|
-
|
|
1341
|
-
| Context | Behavior |
|
|
1342
|
-
|---------|----------|
|
|
1343
|
-
| **Server** (`renderToString`) | Generates `<wcs-ssr>` with state JSON, template fragments, and property data |
|
|
1344
|
-
| **Client** (hydration) | Reads `<wcs-ssr>`, restores state, skips `$connectedCallback`, hydrates bindings on existing DOM |
|
|
1345
|
-
|
|
1346
|
-
See [`@wcstack/server` README](../server/README.md) for full API documentation.
|
|
1347
|
-
|
|
1348
|
-
## License
|
|
1349
|
-
|
|
1350
|
-
MIT
|
|
1
|
+
# @wcstack/state
|
|
2
|
+
|
|
3
|
+
**What if HTML had reactive data binding?**
|
|
4
|
+
|
|
5
|
+
Imagine a future where the browser natively understands state — you declare data inline, bind it to the DOM with attributes, and everything stays in sync. No virtual DOM, no compilation, no framework. Just HTML that reacts.
|
|
6
|
+
|
|
7
|
+
That's what `<wcs-state>` and `data-wcs` explore. One CDN import, zero dependencies, pure HTML syntax.
|
|
8
|
+
|
|
9
|
+
The CDN script only registers the custom element definition — nothing else happens at load time. When a `<wcs-state>` element connects to the DOM, it reads its state source, scans all `data-wcs` bindings within the same root node (`document` or `ShadowRoot`), and wires up reactivity. All initialization is driven by the element's lifecycle, not by your code.
|
|
10
|
+
|
|
11
|
+
## Design Philosophy
|
|
12
|
+
|
|
13
|
+
### Path as the Universal Contract
|
|
14
|
+
|
|
15
|
+
In every existing framework, the **component** is the coupling point between UI and state. Components import state hooks, selectors, or reactive primitives, and the binding happens inside JavaScript. No matter how cleanly you separate your state store, there is always glue code in the component that pulls state in.
|
|
16
|
+
|
|
17
|
+
`@wcstack/state` eliminates that coupling entirely. The **only** thing connecting UI and state is a **path string** — a dot-separated address like `user.name` or `cart.items.*.subtotal`. This is the sole contract between the two layers:
|
|
18
|
+
|
|
19
|
+
| Layer | What it knows | What it doesn't know |
|
|
20
|
+
|-------|---------------|----------------------|
|
|
21
|
+
| **State** (`<wcs-state>`) | Data structure and business logic | Which DOM nodes are bound |
|
|
22
|
+
| **UI** (`data-wcs`) | Path strings and display intent | How state is stored or computed |
|
|
23
|
+
| **Components** (`@name`) | The path they need from a named state | The other component's internals |
|
|
24
|
+
|
|
25
|
+
Three levels of path contracts keep everything loosely coupled:
|
|
26
|
+
|
|
27
|
+
1. **UI ↔ State** — A `data-wcs="textContent: user.name"` attribute is the entire binding. No hooks, no selectors, no reactive primitives. The component's JavaScript doesn't contain a single line that references state.
|
|
28
|
+
|
|
29
|
+
2. **Component ↔ Component** — Cross-component communication happens through named state references (`@stateName`). Components never import or depend on each other; they share a naming convention, nothing more.
|
|
30
|
+
|
|
31
|
+
3. **Loop context** — Inside a `for` loop, `*` acts as an abstract index. Bindings like `items.*.price` resolve to the current element automatically. The template doesn't know its concrete position — the wildcard is the contract.
|
|
32
|
+
|
|
33
|
+
### Why This Matters
|
|
34
|
+
|
|
35
|
+
This is complete separation of UI and state with **no JavaScript intermediary**. You can:
|
|
36
|
+
|
|
37
|
+
- Redesign the entire UI without touching state logic
|
|
38
|
+
- Refactor state structure and only update path strings
|
|
39
|
+
- Read the HTML alone and understand every data dependency
|
|
40
|
+
|
|
41
|
+
The path contract works like a URL in a REST API — a simple string that both sides agree on, with no shared code between them. It's the natural result of building on HTML's declarative nature rather than inventing a template language on top of JavaScript.
|
|
42
|
+
|
|
43
|
+
## 4 Steps to Reactive HTML
|
|
44
|
+
|
|
45
|
+
```html
|
|
46
|
+
<!-- 1. Load the CDN -->
|
|
47
|
+
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
|
|
48
|
+
|
|
49
|
+
<!-- 2. Write a <wcs-state> tag -->
|
|
50
|
+
<wcs-state>
|
|
51
|
+
<!-- 3. Define your state object -->
|
|
52
|
+
<script type="module">
|
|
53
|
+
export default {
|
|
54
|
+
message: "Hello, World!"
|
|
55
|
+
};
|
|
56
|
+
</script>
|
|
57
|
+
</wcs-state>
|
|
58
|
+
|
|
59
|
+
<!-- 4. Bind with data-wcs attributes -->
|
|
60
|
+
<div data-wcs="textContent: message"></div>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
That's it. No build, no bootstrap code, no framework.
|
|
64
|
+
|
|
65
|
+
## Features
|
|
66
|
+
|
|
67
|
+
- **Declarative data binding** — `data-wcs` attribute for property / text / event / structural binding
|
|
68
|
+
- **Reactive Proxy** — ES Proxy-based automatic DOM updates with dependency tracking
|
|
69
|
+
- **Structural directives** — `for`, `if` / `elseif` / `else` via `<template>` elements
|
|
70
|
+
- **Built-in filters** — 40 filters for formatting, comparison, arithmetic, date, and more
|
|
71
|
+
- **Two-way binding** — automatic for `<input>`, `<select>`, `<textarea>`
|
|
72
|
+
- **Web Component binding** — bidirectional state binding with Shadow DOM components
|
|
73
|
+
- **Path getters** — dot-path key getters (`get "users.*.fullName"()`) for virtual properties at any depth in a data tree, all defined flat in one place with automatic dependency tracking and caching
|
|
74
|
+
- **Mustache syntax** — `{{ path|filter }}` in text nodes
|
|
75
|
+
- **Multiple state sources** — JSON, JS module, inline script, API, attribute
|
|
76
|
+
- **SVG support** — full binding support inside `<svg>` elements
|
|
77
|
+
- **Lifecycle hooks** — `$connectedCallback` / `$disconnectedCallback` / `$updatedCallback`, plus `$stateReadyCallback` for Web Components
|
|
78
|
+
- **TypeScript support** — `defineState()` for typed state definitions with dot-path autocompletion ([details](docs/define-state.md))
|
|
79
|
+
- **Server-Side Rendering** — `enable-ssr` attribute + `@wcstack/server` for full SSR with automatic hydration
|
|
80
|
+
- **Zero dependencies** — no runtime dependencies
|
|
81
|
+
|
|
82
|
+
## Installation
|
|
83
|
+
|
|
84
|
+
### CDN (recommended)
|
|
85
|
+
|
|
86
|
+
```html
|
|
87
|
+
<!-- Auto-initialization — this is all you need -->
|
|
88
|
+
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### CDN (manual initialization)
|
|
92
|
+
|
|
93
|
+
```html
|
|
94
|
+
<script type="module">
|
|
95
|
+
import { bootstrapState } from 'https://esm.run/@wcstack/state';
|
|
96
|
+
bootstrapState();
|
|
97
|
+
</script>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Basic Usage
|
|
101
|
+
|
|
102
|
+
```html
|
|
103
|
+
<wcs-state>
|
|
104
|
+
<script type="module">
|
|
105
|
+
export default {
|
|
106
|
+
count: 0,
|
|
107
|
+
user: { id: 1, name: "Alice" },
|
|
108
|
+
users: [
|
|
109
|
+
{ id: 1, name: "Alice" },
|
|
110
|
+
{ id: 2, name: "Bob" },
|
|
111
|
+
{ id: 3, name: "Charlie" }
|
|
112
|
+
],
|
|
113
|
+
countUp() { this.count += 1; },
|
|
114
|
+
clearCount() { this.count = 0; },
|
|
115
|
+
get "users.*.displayName"() {
|
|
116
|
+
return this["users.*.name"] + " (ID: " + this["users.*.id"] + ")";
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
</script>
|
|
120
|
+
</wcs-state>
|
|
121
|
+
|
|
122
|
+
<!-- Text binding -->
|
|
123
|
+
<div data-wcs="textContent: count"></div>
|
|
124
|
+
{{ count }}
|
|
125
|
+
|
|
126
|
+
<!-- Two-way input binding -->
|
|
127
|
+
<input type="text" data-wcs="value: user.name">
|
|
128
|
+
|
|
129
|
+
<!-- Event binding -->
|
|
130
|
+
<button data-wcs="onclick: countUp">Increment</button>
|
|
131
|
+
|
|
132
|
+
<!-- Conditional class -->
|
|
133
|
+
<div data-wcs="textContent: count; class.over: count|gt(10)"></div>
|
|
134
|
+
|
|
135
|
+
<!-- Loop -->
|
|
136
|
+
<template data-wcs="for: users">
|
|
137
|
+
<div>
|
|
138
|
+
<span data-wcs="textContent: .id"></span>:
|
|
139
|
+
<span data-wcs="textContent: .displayName"></span>
|
|
140
|
+
</div>
|
|
141
|
+
</template>
|
|
142
|
+
|
|
143
|
+
<!-- Conditional rendering -->
|
|
144
|
+
<template data-wcs="if: count|gt(0)">
|
|
145
|
+
<p>The count is positive.</p>
|
|
146
|
+
</template>
|
|
147
|
+
<template data-wcs="elseif: count|lt(0)">
|
|
148
|
+
<p>The count is negative.</p>
|
|
149
|
+
</template>
|
|
150
|
+
<template data-wcs="else:">
|
|
151
|
+
<p>The count is zero.</p>
|
|
152
|
+
</template>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## State Initialization
|
|
156
|
+
|
|
157
|
+
`<wcs-state>` supports multiple ways to load initial state:
|
|
158
|
+
|
|
159
|
+
```html
|
|
160
|
+
<!-- 1. Reference a <script type="application/json"> by id -->
|
|
161
|
+
<script type="application/json" id="state">
|
|
162
|
+
{ "count": 0 }
|
|
163
|
+
</script>
|
|
164
|
+
<wcs-state state="state"></wcs-state>
|
|
165
|
+
|
|
166
|
+
<!-- 2. Inline JSON attribute -->
|
|
167
|
+
<wcs-state json='{ "count": 0 }'></wcs-state>
|
|
168
|
+
|
|
169
|
+
<!-- 3. External JSON file -->
|
|
170
|
+
<wcs-state src="./data.json"></wcs-state>
|
|
171
|
+
|
|
172
|
+
<!-- 4. External JS module (export default { ... }) -->
|
|
173
|
+
<wcs-state src="./state.js"></wcs-state>
|
|
174
|
+
|
|
175
|
+
<!-- 5. Inline script module -->
|
|
176
|
+
<wcs-state>
|
|
177
|
+
<script type="module">
|
|
178
|
+
export default { count: 0 };
|
|
179
|
+
</script>
|
|
180
|
+
</wcs-state>
|
|
181
|
+
|
|
182
|
+
<!-- 6. Programmatic API -->
|
|
183
|
+
<script>
|
|
184
|
+
const el = document.createElement('wcs-state');
|
|
185
|
+
el.setInitialState({ count: 0 });
|
|
186
|
+
document.body.appendChild(el);
|
|
187
|
+
</script>
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Resolution order: `state` → `src` (.json / .js) → `json` → inner `<script>` → wait for `setInitialState()`.
|
|
191
|
+
|
|
192
|
+
### Named State
|
|
193
|
+
|
|
194
|
+
Multiple state elements can coexist with the `name` attribute. Bindings reference them with `@name`:
|
|
195
|
+
|
|
196
|
+
```html
|
|
197
|
+
<wcs-state name="cart">...</wcs-state>
|
|
198
|
+
<wcs-state name="user">...</wcs-state>
|
|
199
|
+
|
|
200
|
+
<div data-wcs="textContent: total@cart"></div>
|
|
201
|
+
<div data-wcs="textContent: name@user"></div>
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Default name is `"default"` (no `@` needed).
|
|
205
|
+
|
|
206
|
+
## Updating State
|
|
207
|
+
|
|
208
|
+
In `@wcstack/state`, every piece of state has a **path** — like `count`, `user.name`, or `items`. To update state reactively, **assign to the path**:
|
|
209
|
+
|
|
210
|
+
```javascript
|
|
211
|
+
this.count = 10; // path "count"
|
|
212
|
+
this["user.name"] = "Bob"; // path "user.name"
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
That's the one rule: **assign to the path, and the DOM updates automatically.**
|
|
216
|
+
|
|
217
|
+
### Why `this.user.name = "Bob"` Doesn't Work
|
|
218
|
+
|
|
219
|
+
`this.user.name` first reads the `user` object via `this.user` (a path read), then sets `.name` on that plain object — this is not a path assignment, so the change is not detected:
|
|
220
|
+
|
|
221
|
+
```javascript
|
|
222
|
+
// ✅ Path assignment — change detected
|
|
223
|
+
this["user.name"] = "Bob";
|
|
224
|
+
|
|
225
|
+
// ❌ Not a path assignment — change NOT detected
|
|
226
|
+
this.user.name = "Bob";
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Arrays
|
|
230
|
+
|
|
231
|
+
The same rule applies: assign a new array to the path. Mutating methods (`push`, `splice`, `sort`, ...) modify the array in place without path assignment, so use non-destructive alternatives:
|
|
232
|
+
|
|
233
|
+
```javascript
|
|
234
|
+
// ✅ New array assigned to path — change detected
|
|
235
|
+
this.items = this.items.concat({ id: 4, text: "New" });
|
|
236
|
+
this.items = this.items.toSpliced(index, 1);
|
|
237
|
+
this.items = this.items.filter(item => !item.done);
|
|
238
|
+
this.items = this.items.toSorted((a, b) => a.id - b.id);
|
|
239
|
+
this.items = this.items.toReversed();
|
|
240
|
+
this.items = this.items.with(index, newValue);
|
|
241
|
+
|
|
242
|
+
// ❌ In-place mutation — no path assignment, change NOT detected
|
|
243
|
+
this.items.push({ id: 4, text: "New" });
|
|
244
|
+
this.items.splice(index, 1);
|
|
245
|
+
this.items.sort((a, b) => a.id - b.id);
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Binding Syntax
|
|
249
|
+
|
|
250
|
+
### `data-wcs` Attribute
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
property[#modifier]: path[@state][|filter[|filter(args)...]]
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Multiple bindings separated by `;`:
|
|
257
|
+
|
|
258
|
+
```html
|
|
259
|
+
<div data-wcs="textContent: count; class.over: count|gt(10)"></div>
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
| Part | Description | Example |
|
|
263
|
+
|---|---|---|
|
|
264
|
+
| `property` | DOM property to bind | `value`, `textContent`, `checked` |
|
|
265
|
+
| `#modifier` | Binding modifier | `#ro`, `#prevent`, `#stop`, `#onchange` |
|
|
266
|
+
| `path` | State property path | `count`, `user.name`, `users.*.name` |
|
|
267
|
+
| `@state` | Named state reference | `@cart`, `@user` |
|
|
268
|
+
| `\|filter` | Transform filter chain | `\|gt(0)`, `\|round\|locale` |
|
|
269
|
+
|
|
270
|
+
### Property Types
|
|
271
|
+
|
|
272
|
+
| Property | Description |
|
|
273
|
+
|---|---|
|
|
274
|
+
| `value` | Element value (two-way for inputs) |
|
|
275
|
+
| `checked` | Checkbox / radio checked state (two-way) |
|
|
276
|
+
| `textContent` | Text content |
|
|
277
|
+
| `text` | Alias for textContent |
|
|
278
|
+
| `html` | innerHTML |
|
|
279
|
+
| `class.NAME` | Toggle a CSS class |
|
|
280
|
+
| `style.PROP` | Set a CSS style property |
|
|
281
|
+
| `attr.NAME` | Set an attribute (supports SVG namespace) |
|
|
282
|
+
| `radio` | Radio button group binding (two-way) |
|
|
283
|
+
| `checkbox` | Checkbox group binding to array (two-way) |
|
|
284
|
+
| `onclick`, `on*` | Event handler binding |
|
|
285
|
+
|
|
286
|
+
### Modifiers
|
|
287
|
+
|
|
288
|
+
| Modifier | Description |
|
|
289
|
+
|---|---|
|
|
290
|
+
| `#ro` | Read-only — disables two-way binding |
|
|
291
|
+
| `#prevent` | Calls `event.preventDefault()` on event handlers |
|
|
292
|
+
| `#stop` | Calls `event.stopPropagation()` on event handlers |
|
|
293
|
+
| `#onchange` | Uses `change` event instead of `input` for two-way binding |
|
|
294
|
+
|
|
295
|
+
### Two-Way Binding
|
|
296
|
+
|
|
297
|
+
Automatically enabled for:
|
|
298
|
+
|
|
299
|
+
| Element | Property | Event |
|
|
300
|
+
|---|---|---|
|
|
301
|
+
| `<input type="checkbox/radio">` | `checked` | `input` |
|
|
302
|
+
| `<input>` (other types) | `value`, `valueAsNumber`, `valueAsDate` | `input` |
|
|
303
|
+
| `<select>` | `value` | `change` |
|
|
304
|
+
| `<textarea>` | `value` | `input` |
|
|
305
|
+
|
|
306
|
+
`<input type="button">` is excluded. Use `#ro` to disable, `#onchange` to change the event.
|
|
307
|
+
|
|
308
|
+
### Radio Binding
|
|
309
|
+
|
|
310
|
+
Bind a radio button group to a single state value with `radio`:
|
|
311
|
+
|
|
312
|
+
```html
|
|
313
|
+
<input type="radio" value="red" data-wcs="radio: selectedColor">
|
|
314
|
+
<input type="radio" value="blue" data-wcs="radio: selectedColor">
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
The radio button whose `value` matches the state value is automatically checked. When the user selects a different radio button, the state is updated. Use `#ro` for read-only.
|
|
318
|
+
|
|
319
|
+
Inside a `for` loop:
|
|
320
|
+
|
|
321
|
+
```html
|
|
322
|
+
<template data-wcs="for: branches">
|
|
323
|
+
<label>
|
|
324
|
+
<input type="radio" data-wcs="value: .; radio: currentBranch">
|
|
325
|
+
{{ . }}
|
|
326
|
+
</label>
|
|
327
|
+
</template>
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Checkbox Binding
|
|
331
|
+
|
|
332
|
+
Bind a checkbox group to a state array with `checkbox`:
|
|
333
|
+
|
|
334
|
+
```html
|
|
335
|
+
<input type="checkbox" value="apple" data-wcs="checkbox: selectedFruits">
|
|
336
|
+
<input type="checkbox" value="banana" data-wcs="checkbox: selectedFruits">
|
|
337
|
+
<input type="checkbox" value="orange" data-wcs="checkbox: selectedFruits">
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
A checkbox is checked when its `value` is included in the state array. Toggling a checkbox adds or removes the value from the array. Use `|int` to convert string values to numbers, and `#ro` for read-only.
|
|
341
|
+
|
|
342
|
+
### Mustache Syntax
|
|
343
|
+
|
|
344
|
+
When `enableMustache` is `true` (default), `{{ expression }}` in text nodes is supported:
|
|
345
|
+
|
|
346
|
+
```html
|
|
347
|
+
<p>Hello, {{ user.name }}!</p>
|
|
348
|
+
<p>Count: {{ count|locale }}</p>
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Internally converted to comment-based bindings (`<!--@@:expression-->`).
|
|
352
|
+
|
|
353
|
+
## Structural Directives
|
|
354
|
+
|
|
355
|
+
Structural directives use `<template>` elements:
|
|
356
|
+
|
|
357
|
+
### Loop (`for`)
|
|
358
|
+
|
|
359
|
+
```html
|
|
360
|
+
<template data-wcs="for: users">
|
|
361
|
+
<div>
|
|
362
|
+
<!-- Full path -->
|
|
363
|
+
<span data-wcs="textContent: users.*.name"></span>
|
|
364
|
+
<!-- Shorthand (relative to loop context) -->
|
|
365
|
+
<span data-wcs="textContent: .name"></span>
|
|
366
|
+
</div>
|
|
367
|
+
</template>
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
The `for:` directive uses a **value-based diff algorithm** — each array element's value itself serves as the identity key. There is no need for an explicit `key` attribute (like React's `key` or Vue's `:key`). When the array is reassigned, the differ matches old and new elements by value, reusing existing DOM nodes for unchanged items and efficiently adding, removing, or reordering the rest.
|
|
371
|
+
|
|
372
|
+
#### Dot Shorthand
|
|
373
|
+
|
|
374
|
+
Inside a `for` loop, paths starting with `.` are expanded relative to the loop's array path:
|
|
375
|
+
|
|
376
|
+
| Shorthand | Expanded to | Description |
|
|
377
|
+
|---|---|---|
|
|
378
|
+
| `.name` | `users.*.name` | Property of the current element |
|
|
379
|
+
| `.` | `users.*` | The current element itself |
|
|
380
|
+
| `.name\|uc` | `users.*.name\|uc` | Filters are preserved |
|
|
381
|
+
| `.name@state` | `users.*.name@state` | State name is preserved |
|
|
382
|
+
|
|
383
|
+
For primitive arrays, `.` refers to the element value directly:
|
|
384
|
+
|
|
385
|
+
```html
|
|
386
|
+
<template data-wcs="for: branches">
|
|
387
|
+
<label>
|
|
388
|
+
<input type="radio" data-wcs="value: .; radio: currentBranch">
|
|
389
|
+
{{ . }}
|
|
390
|
+
</label>
|
|
391
|
+
</template>
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Nested loops are supported with multi-level wildcards. The `.` shorthand in nested `for` directives also expands relative to the parent loop path:
|
|
395
|
+
|
|
396
|
+
```html
|
|
397
|
+
<template data-wcs="for: regions">
|
|
398
|
+
<!-- .states → regions.*.states -->
|
|
399
|
+
<template data-wcs="for: .states">
|
|
400
|
+
<!-- .name → regions.*.states.*.name -->
|
|
401
|
+
<span data-wcs="textContent: .name"></span>
|
|
402
|
+
</template>
|
|
403
|
+
</template>
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### Conditional (`if` / `elseif` / `else`)
|
|
407
|
+
|
|
408
|
+
```html
|
|
409
|
+
<template data-wcs="if: count|gt(0)">
|
|
410
|
+
<p>Positive</p>
|
|
411
|
+
</template>
|
|
412
|
+
<template data-wcs="elseif: count|lt(0)">
|
|
413
|
+
<p>Negative</p>
|
|
414
|
+
</template>
|
|
415
|
+
<template data-wcs="else:">
|
|
416
|
+
<p>Zero</p>
|
|
417
|
+
</template>
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Conditions can be chained. `elseif` automatically inverts the previous condition.
|
|
421
|
+
|
|
422
|
+
## Path Getters (Computed Properties)
|
|
423
|
+
|
|
424
|
+
**Path getters** are the core feature of `@wcstack/state`. Define computed properties using JavaScript getters with **dot-path string keys** containing wildcards (`*`). They act as **virtual properties that can be attached at any depth in a data tree — all defined flat in one place**. No matter how deeply data is nested, path getters keep definitions at the same level with automatic dependency tracking per loop element.
|
|
425
|
+
|
|
426
|
+
### Basic Path Getter
|
|
427
|
+
|
|
428
|
+
```html
|
|
429
|
+
<wcs-state>
|
|
430
|
+
<script type="module">
|
|
431
|
+
export default {
|
|
432
|
+
users: [
|
|
433
|
+
{ id: 1, firstName: "Alice", lastName: "Smith" },
|
|
434
|
+
{ id: 2, firstName: "Bob", lastName: "Jones" }
|
|
435
|
+
],
|
|
436
|
+
// Path getter — runs per-element inside a loop
|
|
437
|
+
get "users.*.fullName"() {
|
|
438
|
+
return this["users.*.firstName"] + " " + this["users.*.lastName"];
|
|
439
|
+
},
|
|
440
|
+
get "users.*.displayName"() {
|
|
441
|
+
return this["users.*.fullName"] + " (ID: " + this["users.*.id"] + ")";
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
</script>
|
|
445
|
+
</wcs-state>
|
|
446
|
+
|
|
447
|
+
<template data-wcs="for: users">
|
|
448
|
+
<div data-wcs="textContent: .displayName"></div>
|
|
449
|
+
</template>
|
|
450
|
+
<!-- Output:
|
|
451
|
+
Alice Smith (ID: 1)
|
|
452
|
+
Bob Jones (ID: 2)
|
|
453
|
+
-->
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
Inside a path getter, `this["users.*.firstName"]` automatically resolves to the current loop element — no manual indexing needed.
|
|
457
|
+
|
|
458
|
+
### Top-Level Computed Properties
|
|
459
|
+
|
|
460
|
+
Getters without wildcards work as standard computed properties:
|
|
461
|
+
|
|
462
|
+
```javascript
|
|
463
|
+
export default {
|
|
464
|
+
price: 100,
|
|
465
|
+
tax: 0.1,
|
|
466
|
+
get total() {
|
|
467
|
+
return this.price * (1 + this.tax);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
### Getter Chaining
|
|
473
|
+
|
|
474
|
+
Path getters can reference other path getters, forming a dependency chain. The cache is automatically invalidated when any upstream value changes:
|
|
475
|
+
|
|
476
|
+
```html
|
|
477
|
+
<wcs-state>
|
|
478
|
+
<script type="module">
|
|
479
|
+
export default {
|
|
480
|
+
taxRate: 0.1,
|
|
481
|
+
cart: {
|
|
482
|
+
items: [
|
|
483
|
+
{ productId: "P001", quantity: 2, unitPrice: 500 },
|
|
484
|
+
{ productId: "P002", quantity: 1, unitPrice: 1200 }
|
|
485
|
+
]
|
|
486
|
+
},
|
|
487
|
+
// Per-item subtotal
|
|
488
|
+
get "cart.items.*.subtotal"() {
|
|
489
|
+
return this["cart.items.*.unitPrice"] * this["cart.items.*.quantity"];
|
|
490
|
+
},
|
|
491
|
+
// Aggregate: sum of all subtotals
|
|
492
|
+
get "cart.totalPrice"() {
|
|
493
|
+
return this.$getAll("cart.items.*.subtotal", []).reduce((sum, v) => sum + v, 0);
|
|
494
|
+
},
|
|
495
|
+
// Chained: tax derived from totalPrice
|
|
496
|
+
get "cart.tax"() {
|
|
497
|
+
return this["cart.totalPrice"] * this.taxRate;
|
|
498
|
+
},
|
|
499
|
+
// Chained: grand total
|
|
500
|
+
get "cart.grandTotal"() {
|
|
501
|
+
return this["cart.totalPrice"] + this["cart.tax"];
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
</script>
|
|
505
|
+
</wcs-state>
|
|
506
|
+
|
|
507
|
+
<template data-wcs="for: cart.items">
|
|
508
|
+
<div>
|
|
509
|
+
<span data-wcs="textContent: .productId"></span>:
|
|
510
|
+
<span data-wcs="textContent: .subtotal|locale"></span>
|
|
511
|
+
</div>
|
|
512
|
+
</template>
|
|
513
|
+
<p>Total: <span data-wcs="textContent: cart.totalPrice|locale"></span></p>
|
|
514
|
+
<p>Tax: <span data-wcs="textContent: cart.tax|locale"></span></p>
|
|
515
|
+
<p>Grand Total: <span data-wcs="textContent: cart.grandTotal|locale"></span></p>
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Dependency chain: `cart.grandTotal` → `cart.tax` → `cart.totalPrice` → `cart.items.*.subtotal` → `cart.items.*.unitPrice` / `cart.items.*.quantity`. Changing any item's `unitPrice` or `quantity` automatically recomputes the entire chain.
|
|
519
|
+
|
|
520
|
+
### Nested Wildcard Getters
|
|
521
|
+
|
|
522
|
+
Multiple wildcards are supported for nested array structures:
|
|
523
|
+
|
|
524
|
+
```html
|
|
525
|
+
<wcs-state>
|
|
526
|
+
<script type="module">
|
|
527
|
+
export default {
|
|
528
|
+
categories: [
|
|
529
|
+
{
|
|
530
|
+
name: "Fruits",
|
|
531
|
+
items: [
|
|
532
|
+
{ name: "Apple", price: 150 },
|
|
533
|
+
{ name: "Banana", price: 100 }
|
|
534
|
+
]
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
name: "Vegetables",
|
|
538
|
+
items: [
|
|
539
|
+
{ name: "Carrot", price: 80 }
|
|
540
|
+
]
|
|
541
|
+
}
|
|
542
|
+
],
|
|
543
|
+
get "categories.*.items.*.label"() {
|
|
544
|
+
return this["categories.*.name"] + " / " + this["categories.*.items.*.name"];
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
</script>
|
|
548
|
+
</wcs-state>
|
|
549
|
+
|
|
550
|
+
<template data-wcs="for: categories">
|
|
551
|
+
<h3 data-wcs="textContent: .name"></h3>
|
|
552
|
+
<template data-wcs="for: .items">
|
|
553
|
+
<div data-wcs="textContent: .label"></div>
|
|
554
|
+
</template>
|
|
555
|
+
</template>
|
|
556
|
+
<!-- Output:
|
|
557
|
+
Fruits
|
|
558
|
+
Fruits / Apple
|
|
559
|
+
Fruits / Banana
|
|
560
|
+
Vegetables
|
|
561
|
+
Vegetables / Carrot
|
|
562
|
+
-->
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
### Flat Virtual Properties Across Any Depth
|
|
566
|
+
|
|
567
|
+
A key advantage of path getters is that **no matter how deeply data is nested, all virtual properties are defined flat in one place**. This eliminates the need to split components just to hold computed properties at each nesting level.
|
|
568
|
+
|
|
569
|
+
```javascript
|
|
570
|
+
export default {
|
|
571
|
+
regions: [
|
|
572
|
+
{ name: "Kanto", prefectures: [
|
|
573
|
+
{ name: "Tokyo", cities: [
|
|
574
|
+
{ name: "Shibuya", population: 230000, area: 15.11 },
|
|
575
|
+
{ name: "Shinjuku", population: 346000, area: 18.22 }
|
|
576
|
+
]},
|
|
577
|
+
{ name: "Kanagawa", cities: [
|
|
578
|
+
{ name: "Yokohama", population: 3750000, area: 437.56 }
|
|
579
|
+
]}
|
|
580
|
+
]}
|
|
581
|
+
],
|
|
582
|
+
|
|
583
|
+
// --- All flat, regardless of nesting depth ---
|
|
584
|
+
|
|
585
|
+
// City level — virtual properties
|
|
586
|
+
get "regions.*.prefectures.*.cities.*.density"() {
|
|
587
|
+
return this["regions.*.prefectures.*.cities.*.population"]
|
|
588
|
+
/ this["regions.*.prefectures.*.cities.*.area"];
|
|
589
|
+
},
|
|
590
|
+
get "regions.*.prefectures.*.cities.*.label"() {
|
|
591
|
+
return this["regions.*.prefectures.*.name"] + " "
|
|
592
|
+
+ this["regions.*.prefectures.*.cities.*.name"];
|
|
593
|
+
},
|
|
594
|
+
|
|
595
|
+
// Prefecture level — aggregate from cities
|
|
596
|
+
get "regions.*.prefectures.*.totalPopulation"() {
|
|
597
|
+
return this.$getAll("regions.*.prefectures.*.cities.*.population", [])
|
|
598
|
+
.reduce((a, b) => a + b, 0);
|
|
599
|
+
},
|
|
600
|
+
|
|
601
|
+
// Region level — aggregate from prefectures
|
|
602
|
+
get "regions.*.totalPopulation"() {
|
|
603
|
+
return this.$getAll("regions.*.prefectures.*.totalPopulation", [])
|
|
604
|
+
.reduce((a, b) => a + b, 0);
|
|
605
|
+
},
|
|
606
|
+
|
|
607
|
+
// Top level — aggregate from regions
|
|
608
|
+
get totalPopulation() {
|
|
609
|
+
return this.$getAll("regions.*.totalPopulation", [])
|
|
610
|
+
.reduce((a, b) => a + b, 0);
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
Three levels of nesting, five virtual properties — all defined side by side in a single flat object. Each level can reference values from any depth, and aggregation flows naturally from bottom to top via `$getAll`. In component-based frameworks, the typical approach is to create a separate component for each nesting level and pass computed values through the tree. Path getters offer a different trade-off by keeping all definitions in one place.
|
|
616
|
+
|
|
617
|
+
### Accessing Sub-Properties of Getter Results
|
|
618
|
+
|
|
619
|
+
When a path getter returns an object, you can access its sub-properties via dot-path:
|
|
620
|
+
|
|
621
|
+
```javascript
|
|
622
|
+
export default {
|
|
623
|
+
products: [
|
|
624
|
+
{ id: "P001", name: "Widget", price: 500, stock: 10 },
|
|
625
|
+
{ id: "P002", name: "Gadget", price: 1200, stock: 3 }
|
|
626
|
+
],
|
|
627
|
+
cart: {
|
|
628
|
+
items: [
|
|
629
|
+
{ productId: "P001", quantity: 2 },
|
|
630
|
+
{ productId: "P002", quantity: 1 }
|
|
631
|
+
]
|
|
632
|
+
},
|
|
633
|
+
get productByProductId() {
|
|
634
|
+
return new Map(this.products.map(p => [p.id, p]));
|
|
635
|
+
},
|
|
636
|
+
// Returns the full product object
|
|
637
|
+
get "cart.items.*.product"() {
|
|
638
|
+
return this.productByProductId.get(this["cart.items.*.productId"]);
|
|
639
|
+
},
|
|
640
|
+
// Access sub-property of the returned object
|
|
641
|
+
get "cart.items.*.total"() {
|
|
642
|
+
return this["cart.items.*.product.price"] * this["cart.items.*.quantity"];
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
`this["cart.items.*.product.price"]` transparently chains through the object returned by the `cart.items.*.product` getter.
|
|
648
|
+
|
|
649
|
+
### Path Setters
|
|
650
|
+
|
|
651
|
+
Custom setter logic can be defined with `set "path"()`:
|
|
652
|
+
|
|
653
|
+
```javascript
|
|
654
|
+
export default {
|
|
655
|
+
users: [
|
|
656
|
+
{ firstName: "Alice", lastName: "Smith" },
|
|
657
|
+
{ firstName: "Bob", lastName: "Jones" }
|
|
658
|
+
],
|
|
659
|
+
get "users.*.fullName"() {
|
|
660
|
+
return this["users.*.firstName"] + " " + this["users.*.lastName"];
|
|
661
|
+
},
|
|
662
|
+
set "users.*.fullName"(value) {
|
|
663
|
+
const [first, ...rest] = value.split(" ");
|
|
664
|
+
this["users.*.firstName"] = first;
|
|
665
|
+
this["users.*.lastName"] = rest.join(" ");
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
```html
|
|
671
|
+
<template data-wcs="for: users">
|
|
672
|
+
<input type="text" data-wcs="value: .fullName">
|
|
673
|
+
</template>
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
Two-way binding works with path setters — editing the input calls the setter, which splits and writes back to `firstName` / `lastName`.
|
|
677
|
+
|
|
678
|
+
### Supported Path Getter Patterns
|
|
679
|
+
|
|
680
|
+
| Pattern | Description | Example |
|
|
681
|
+
|---|---|---|
|
|
682
|
+
| `get prop()` | Top-level computed | `get total()` |
|
|
683
|
+
| `get "a.b"()` | Nested computed (no wildcard) | `get "cart.totalPrice"()` |
|
|
684
|
+
| `get "a.*.b"()` | Single wildcard | `get "users.*.fullName"()` |
|
|
685
|
+
| `get "a.*.b.*.c"()` | Multiple wildcards | `get "categories.*.items.*.label"()` |
|
|
686
|
+
| `set "a.*.b"(v)` | Wildcard setter | `set "users.*.fullName"(v)` |
|
|
687
|
+
|
|
688
|
+
### How It Works
|
|
689
|
+
|
|
690
|
+
1. **Context resolution** — When a `for:` loop renders, each iteration pushes a `ListIndex` onto the address stack. Inside a path getter, `this["users.*.name"]` resolves the `*` using this stack, so it always points to the current element.
|
|
691
|
+
|
|
692
|
+
2. **Automatic dependency tracking** — When a getter accesses `this["users.*.name"]`, the system registers a dynamic dependency from `users.*.name` to the getter's path. When `users.*.name` changes, the getter's cache is dirtied.
|
|
693
|
+
|
|
694
|
+
3. **Caching** — Getter results are cached per concrete address (path + loop index). `users.*.fullName` at index 0 has a separate cache entry from index 1. The cache is invalidated only when dependencies change.
|
|
695
|
+
|
|
696
|
+
4. **Direct index access** — You can also access specific elements by numeric index: `this["users.0.name"]` resolves as `users[0].name` without needing loop context.
|
|
697
|
+
|
|
698
|
+
### Loop Index Variables (`$1`, `$2`, ...)
|
|
699
|
+
|
|
700
|
+
Inside getters and event handlers, `this.$1`, `this.$2`, etc. provide the current loop iteration index (0-based value, 1-based naming):
|
|
701
|
+
|
|
702
|
+
```javascript
|
|
703
|
+
export default {
|
|
704
|
+
users: ["Alice", "Bob", "Charlie"],
|
|
705
|
+
get "users.*.rowLabel"() {
|
|
706
|
+
return "#" + (this.$1 + 1) + ": " + this["users.*"];
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
```html
|
|
712
|
+
<template data-wcs="for: users">
|
|
713
|
+
<div data-wcs="textContent: .rowLabel"></div>
|
|
714
|
+
</template>
|
|
715
|
+
<!-- Output:
|
|
716
|
+
#1: Alice
|
|
717
|
+
#2: Bob
|
|
718
|
+
#3: Charlie
|
|
719
|
+
-->
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
For nested loops, `$1` is the outer index and `$2` is the inner index.
|
|
723
|
+
|
|
724
|
+
You can also display the loop index directly in templates:
|
|
725
|
+
|
|
726
|
+
```html
|
|
727
|
+
<template data-wcs="for: items">
|
|
728
|
+
<td>{{ $1|inc(1) }}</td> <!-- 1-based row number -->
|
|
729
|
+
</template>
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
### Proxy APIs
|
|
733
|
+
|
|
734
|
+
Inside state objects (getters / methods), the following APIs are available via `this`:
|
|
735
|
+
|
|
736
|
+
| API | Description |
|
|
737
|
+
|---|---|
|
|
738
|
+
| `this.$getAll(path, indexes?)` | Get all values matching a wildcard path |
|
|
739
|
+
| `this.$resolve(path, indexes, value?)` | Resolve a wildcard path with specific indexes |
|
|
740
|
+
| `this.$postUpdate(path)` | Manually trigger update notification for a path |
|
|
741
|
+
| `this.$trackDependency(path)` | Manually register a dependency for cache invalidation |
|
|
742
|
+
| `this.$stateElement` | Access to the `IStateElement` instance |
|
|
743
|
+
| `this.$1`, `this.$2`, ... | Current loop index (1-based naming, 0-based value) |
|
|
744
|
+
|
|
745
|
+
#### `$getAll` — Aggregate Across Array Elements
|
|
746
|
+
|
|
747
|
+
`$getAll` collects all values that match a wildcard path, returning them as an array. Essential for aggregation patterns:
|
|
748
|
+
|
|
749
|
+
```javascript
|
|
750
|
+
export default {
|
|
751
|
+
scores: [85, 92, 78, 95, 88],
|
|
752
|
+
get average() {
|
|
753
|
+
const all = this.$getAll("scores.*", []);
|
|
754
|
+
return all.reduce((sum, v) => sum + v, 0) / all.length;
|
|
755
|
+
},
|
|
756
|
+
get max() {
|
|
757
|
+
return Math.max(...this.$getAll("scores.*", []));
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
#### `$resolve` — Access by Explicit Index
|
|
763
|
+
|
|
764
|
+
`$resolve` reads or writes a value at a specific wildcard index:
|
|
765
|
+
|
|
766
|
+
```javascript
|
|
767
|
+
export default {
|
|
768
|
+
items: ["A", "B", "C"],
|
|
769
|
+
swapFirstTwo() {
|
|
770
|
+
const a = this.$resolve("items.*", [0]);
|
|
771
|
+
const b = this.$resolve("items.*", [1]);
|
|
772
|
+
this.$resolve("items.*", [0], b);
|
|
773
|
+
this.$resolve("items.*", [1], a);
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
## Event Handling
|
|
779
|
+
|
|
780
|
+
Bind event handlers with `on*` properties:
|
|
781
|
+
|
|
782
|
+
```html
|
|
783
|
+
<button data-wcs="onclick: handleClick">Click me</button>
|
|
784
|
+
<form data-wcs="onsubmit#prevent: handleSubmit">...</form>
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
Handler methods receive the event and loop indexes:
|
|
788
|
+
|
|
789
|
+
```javascript
|
|
790
|
+
export default {
|
|
791
|
+
items: ["A", "B", "C"],
|
|
792
|
+
handleClick(event) {
|
|
793
|
+
console.log("clicked");
|
|
794
|
+
},
|
|
795
|
+
removeItem(event, index) {
|
|
796
|
+
// index is the loop context ($1)
|
|
797
|
+
this.items = this.items.toSpliced(index, 1);
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
```html
|
|
803
|
+
<template data-wcs="for: items">
|
|
804
|
+
<button data-wcs="onclick: removeItem">Delete</button>
|
|
805
|
+
</template>
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
## Filters
|
|
809
|
+
|
|
810
|
+
40 built-in filters are available for both input (DOM → state) and output (state → DOM) directions.
|
|
811
|
+
|
|
812
|
+
### Comparison
|
|
813
|
+
|
|
814
|
+
| Filter | Description | Example |
|
|
815
|
+
|---|---|---|
|
|
816
|
+
| `eq(value)` | Equal | `count\|eq(0)` → `true/false` |
|
|
817
|
+
| `ne(value)` | Not equal | `count\|ne(0)` |
|
|
818
|
+
| `not` | Boolean NOT | `isActive\|not` |
|
|
819
|
+
| `lt(n)` | Less than | `count\|lt(10)` |
|
|
820
|
+
| `le(n)` | Less than or equal | `count\|le(10)` |
|
|
821
|
+
| `gt(n)` | Greater than | `count\|gt(0)` |
|
|
822
|
+
| `ge(n)` | Greater than or equal | `count\|ge(0)` |
|
|
823
|
+
|
|
824
|
+
### Arithmetic
|
|
825
|
+
|
|
826
|
+
| Filter | Description | Example |
|
|
827
|
+
|---|---|---|
|
|
828
|
+
| `inc(n)` | Add | `count\|inc(1)` |
|
|
829
|
+
| `dec(n)` | Subtract | `count\|dec(1)` |
|
|
830
|
+
| `mul(n)` | Multiply | `price\|mul(1.1)` |
|
|
831
|
+
| `div(n)` | Divide | `total\|div(100)` |
|
|
832
|
+
| `mod(n)` | Modulo | `index\|mod(2)` |
|
|
833
|
+
|
|
834
|
+
### Number Formatting
|
|
835
|
+
|
|
836
|
+
| Filter | Description | Example |
|
|
837
|
+
|---|---|---|
|
|
838
|
+
| `fix(n)` | Fixed decimal places | `price\|fix(2)` → `"100.00"` |
|
|
839
|
+
| `round(n?)` | Round | `value\|round(2)` |
|
|
840
|
+
| `floor(n?)` | Floor | `value\|floor` |
|
|
841
|
+
| `ceil(n?)` | Ceiling | `value\|ceil` |
|
|
842
|
+
| `locale(loc?)` | Locale number format | `count\|locale` / `count\|locale(ja-JP)` |
|
|
843
|
+
| `percent(n?)` | Percentage format | `ratio\|percent(1)` |
|
|
844
|
+
|
|
845
|
+
### String
|
|
846
|
+
|
|
847
|
+
| Filter | Description | Example |
|
|
848
|
+
|---|---|---|
|
|
849
|
+
| `uc` | Upper case | `name\|uc` |
|
|
850
|
+
| `lc` | Lower case | `name\|lc` |
|
|
851
|
+
| `cap` | Capitalize | `name\|cap` |
|
|
852
|
+
| `trim` | Trim whitespace | `text\|trim` |
|
|
853
|
+
| `slice(n)` | Slice string | `text\|slice(5)` |
|
|
854
|
+
| `substr(start, length)` | Substring | `text\|substr(0,10)` |
|
|
855
|
+
| `pad(n, char?)` | Pad start | `id\|pad(5,0)` → `"00001"` |
|
|
856
|
+
| `rep(n)` | Repeat | `text\|rep(3)` |
|
|
857
|
+
| `rev` | Reverse | `text\|rev` |
|
|
858
|
+
|
|
859
|
+
### Type Conversion
|
|
860
|
+
|
|
861
|
+
| Filter | Description | Example |
|
|
862
|
+
|---|---|---|
|
|
863
|
+
| `int` | Parse integer | `input\|int` |
|
|
864
|
+
| `float` | Parse float | `input\|float` |
|
|
865
|
+
| `boolean` | To boolean | `value\|boolean` |
|
|
866
|
+
| `number` | To number | `value\|number` |
|
|
867
|
+
| `string` | To string | `value\|string` |
|
|
868
|
+
| `null` | To null | `value\|null` |
|
|
869
|
+
|
|
870
|
+
### Date / Time
|
|
871
|
+
|
|
872
|
+
| Filter | Description | Example |
|
|
873
|
+
|---|---|---|
|
|
874
|
+
| `date(loc?)` | Date format | `timestamp\|date` / `timestamp\|date(ja-JP)` |
|
|
875
|
+
| `time(loc?)` | Time format | `timestamp\|time` |
|
|
876
|
+
| `datetime(loc?)` | Date + Time | `timestamp\|datetime(en-US)` |
|
|
877
|
+
| `ymd(sep?)` | YYYY-MM-DD | `timestamp\|ymd` / `timestamp\|ymd(/)` |
|
|
878
|
+
|
|
879
|
+
### Boolean / Default
|
|
880
|
+
|
|
881
|
+
| Filter | Description | Example |
|
|
882
|
+
|---|---|---|
|
|
883
|
+
| `truthy` | Truthy check | `value\|truthy` |
|
|
884
|
+
| `falsy` | Falsy check | `value\|falsy` |
|
|
885
|
+
| `defaults(v)` | Fallback value | `name\|defaults(Anonymous)` |
|
|
886
|
+
|
|
887
|
+
### Filter Chaining
|
|
888
|
+
|
|
889
|
+
Filters can be chained with `|`:
|
|
890
|
+
|
|
891
|
+
```html
|
|
892
|
+
<div data-wcs="textContent: price|mul(1.1)|round(2)|locale(ja-JP)"></div>
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
## Web Component Binding
|
|
896
|
+
|
|
897
|
+
`@wcstack/state` supports bidirectional state binding with custom elements using Shadow DOM or Light DOM.
|
|
898
|
+
|
|
899
|
+
Many frameworks use patterns like prop drilling, context providers, or external stores (Redux, Pinia) to share state across components. `@wcstack/state` takes a different approach: parent and child components are connected through **path contracts** — the parent binds an outer state path to an inner component property via `data-wcs`, and the child simply reads and writes its own state as usual:
|
|
900
|
+
|
|
901
|
+
1. The child references and updates the parent's state through its own state proxy — no props, no events, no awareness of the parent.
|
|
902
|
+
2. When the parent's state changes, the Proxy `set` trap automatically notifies any child bindings that reference the affected path.
|
|
903
|
+
3. Because the only coupling is the **path name**, both sides remain loosely coupled and independently testable.
|
|
904
|
+
4. The cost is path resolution (cached at O(1) after first access) plus change propagation through the dependency graph.
|
|
905
|
+
|
|
906
|
+
This provides a lightweight approach to cross-component state management based on path resolution rather than component-level abstractions.
|
|
907
|
+
|
|
908
|
+
### Component Definition (Shadow DOM)
|
|
909
|
+
|
|
910
|
+
```javascript
|
|
911
|
+
class MyComponent extends HTMLElement {
|
|
912
|
+
state = { message: "" };
|
|
913
|
+
|
|
914
|
+
constructor() {
|
|
915
|
+
super();
|
|
916
|
+
this.attachShadow({ mode: "open" });
|
|
917
|
+
this.shadowRoot.innerHTML = `
|
|
918
|
+
<wcs-state bind-component="state"></wcs-state>
|
|
919
|
+
<div>{{ message }}</div>
|
|
920
|
+
<input type="text" data-wcs="value: message" />
|
|
921
|
+
`;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
customElements.define("my-component", MyComponent);
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
### Component Definition (Light DOM)
|
|
928
|
+
|
|
929
|
+
Light DOM components do not use Shadow DOM. The state namespace is shared with the parent scope (just like CSS), so a `name` attribute is required.
|
|
930
|
+
|
|
931
|
+
```javascript
|
|
932
|
+
class MyLightComponent extends HTMLElement {
|
|
933
|
+
state = { message: "" };
|
|
934
|
+
|
|
935
|
+
connectedCallback() {
|
|
936
|
+
this.innerHTML = `
|
|
937
|
+
<wcs-state bind-component="state" name="my-light"></wcs-state>
|
|
938
|
+
<div data-wcs="text: message@my-light"></div>
|
|
939
|
+
<input type="text" data-wcs="value: message@my-light" />
|
|
940
|
+
`;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
customElements.define("my-light-component", MyLightComponent);
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
- `name` attribute is **required** for Light DOM components (namespace is shared with the parent scope)
|
|
947
|
+
- Bindings must explicitly reference the state name with `@my-light`
|
|
948
|
+
- `<wcs-state>` must be a direct child of the component element
|
|
949
|
+
|
|
950
|
+
### Host Usage
|
|
951
|
+
|
|
952
|
+
```html
|
|
953
|
+
<wcs-state>
|
|
954
|
+
<script type="module">
|
|
955
|
+
export default {
|
|
956
|
+
user: { name: "Alice" }
|
|
957
|
+
};
|
|
958
|
+
</script>
|
|
959
|
+
</wcs-state>
|
|
960
|
+
|
|
961
|
+
<!-- Bind component's state.message to outer user.name -->
|
|
962
|
+
<my-component data-wcs="state.message: user.name"></my-component>
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
- `bind-component="state"` maps the component's `state` property to `<wcs-state>`
|
|
966
|
+
- `data-wcs="state.message: user.name"` on the host element binds outer state paths to inner component state properties
|
|
967
|
+
- Changes propagate bidirectionally between the component and the outer state
|
|
968
|
+
|
|
969
|
+
### Standalone Web Component Injection (`__e2e__/single-component`)
|
|
970
|
+
|
|
971
|
+
Even when a component is independent from outer host state, you can inject reactive state with `bind-component`.
|
|
972
|
+
|
|
973
|
+
```javascript
|
|
974
|
+
class MyComponent extends HTMLElement {
|
|
975
|
+
state = Object.freeze({
|
|
976
|
+
message: "Hello, World!"
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
constructor() {
|
|
980
|
+
super();
|
|
981
|
+
this.attachShadow({ mode: "open" });
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
connectedCallback() {
|
|
985
|
+
this.shadowRoot.innerHTML = `
|
|
986
|
+
<wcs-state bind-component="state"></wcs-state>
|
|
987
|
+
<div>{{ message }}</div>
|
|
988
|
+
`;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
async $stateReadyCallback(stateProp) {
|
|
992
|
+
console.log("state ready:", stateProp); // "state"
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
customElements.define("my-component", MyComponent);
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
- Initial component `state` can be defined with `Object.freeze(...)` (it is replaced with a writable reactive state after injection)
|
|
999
|
+
- `bind-component="state"` exposes `this.state` as a state proxy powered by `@wcstack/state`
|
|
1000
|
+
- Assignments like `this.state.message = "..."` immediately update `{{ message }}` inside Shadow DOM
|
|
1001
|
+
- `async $stateReadyCallback(stateProp)` is called right after component state becomes ready for use (`stateProp` is the property name from `bind-component`)
|
|
1002
|
+
|
|
1003
|
+
### Constraints
|
|
1004
|
+
|
|
1005
|
+
- `<wcs-state>` with `bind-component` must be a **direct child** of the component element (top-level)
|
|
1006
|
+
- The parent element must be a **custom element** (tag name containing a hyphen)
|
|
1007
|
+
- Light DOM components **require** a `name` attribute to avoid namespace conflicts with the parent scope
|
|
1008
|
+
- Light DOM bindings must reference the state name explicitly (e.g., `@my-light`)
|
|
1009
|
+
|
|
1010
|
+
### Loop with Components
|
|
1011
|
+
|
|
1012
|
+
```html
|
|
1013
|
+
<template data-wcs="for: users">
|
|
1014
|
+
<my-component data-wcs="state.message: .name"></my-component>
|
|
1015
|
+
</template>
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
## Declarative Custom Components (DCC)
|
|
1019
|
+
|
|
1020
|
+
Define custom elements **entirely in HTML** — no JavaScript class definition needed. Using `data-wc-definition` and Declarative Shadow DOM (`<template shadowrootmode>`), you can declare reusable components with reactive state inline.
|
|
1021
|
+
|
|
1022
|
+
### Basic Definition
|
|
1023
|
+
|
|
1024
|
+
```html
|
|
1025
|
+
<!-- 1. Define the component (hidden by CSS) -->
|
|
1026
|
+
<my-counter data-wc-definition>
|
|
1027
|
+
<template shadowrootmode="open">
|
|
1028
|
+
<p>{{ count }}</p>
|
|
1029
|
+
<button data-wcs="onclick: increment">+1</button>
|
|
1030
|
+
<wcs-state>
|
|
1031
|
+
<script type="module">
|
|
1032
|
+
export default {
|
|
1033
|
+
count: 0,
|
|
1034
|
+
increment() { this.count++; },
|
|
1035
|
+
$bindables: ["count"]
|
|
1036
|
+
};
|
|
1037
|
+
</script>
|
|
1038
|
+
</wcs-state>
|
|
1039
|
+
</template>
|
|
1040
|
+
</my-counter>
|
|
1041
|
+
|
|
1042
|
+
<!-- 2. Use it — each instance gets its own state -->
|
|
1043
|
+
<my-counter></my-counter>
|
|
1044
|
+
<my-counter></my-counter>
|
|
1045
|
+
```
|
|
1046
|
+
|
|
1047
|
+
When `<wcs-state>` detects it is inside a `data-wc-definition` host, it:
|
|
1048
|
+
|
|
1049
|
+
1. Loads the state object (from `<script type="module">` or `src="*.js"`)
|
|
1050
|
+
2. Generates a custom element class with getter/setter/method properties on the prototype
|
|
1051
|
+
3. Registers it via `customElements.define()`
|
|
1052
|
+
|
|
1053
|
+
The definition element is hidden; each instance clones the template into its own Shadow DOM and initializes its own `<wcs-state>`.
|
|
1054
|
+
|
|
1055
|
+
### Recommended CSS
|
|
1056
|
+
|
|
1057
|
+
```css
|
|
1058
|
+
:not(:defined) { display: none; }
|
|
1059
|
+
[data-wc-definition] { display: none; }
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
### `$bindables` and wc-bindable Protocol
|
|
1063
|
+
|
|
1064
|
+
The `$bindables` array declares which state properties are exposed as component properties with change events, following the [wc-bindable protocol](https://github.com/nicenemo/nicenemo/blob/main/docs/wc-bindable-protocol.md):
|
|
1065
|
+
|
|
1066
|
+
```javascript
|
|
1067
|
+
export default {
|
|
1068
|
+
count: 0,
|
|
1069
|
+
increment() { this.count++; },
|
|
1070
|
+
$bindables: ["count"]
|
|
1071
|
+
};
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
This generates:
|
|
1075
|
+
|
|
1076
|
+
- `static wcBindable` on the class — protocol metadata for framework adapters
|
|
1077
|
+
- Getter/setter on the prototype — reads/writes go through the reactive proxy
|
|
1078
|
+
- `CustomEvent` dispatch — `my-counter:count-changed` fires on every mutation
|
|
1079
|
+
|
|
1080
|
+
### Binding to DCC Properties
|
|
1081
|
+
|
|
1082
|
+
Other `<wcs-state>` instances can bind to DCC properties just like any Web Component:
|
|
1083
|
+
|
|
1084
|
+
```html
|
|
1085
|
+
<my-counter data-wcs="count: parentCount"></my-counter>
|
|
1086
|
+
|
|
1087
|
+
<wcs-state>
|
|
1088
|
+
<script type="module">
|
|
1089
|
+
export default { parentCount: 0 };
|
|
1090
|
+
</script>
|
|
1091
|
+
</wcs-state>
|
|
1092
|
+
<div data-wcs="textContent: parentCount"></div>
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
### Shadow Root Mode
|
|
1096
|
+
|
|
1097
|
+
Both `open` and `closed` modes are supported:
|
|
1098
|
+
|
|
1099
|
+
```html
|
|
1100
|
+
<my-component data-wc-definition>
|
|
1101
|
+
<template shadowrootmode="closed">
|
|
1102
|
+
<!-- closed shadow DOM -->
|
|
1103
|
+
</template>
|
|
1104
|
+
</my-component>
|
|
1105
|
+
```
|
|
1106
|
+
|
|
1107
|
+
### Internal Properties
|
|
1108
|
+
|
|
1109
|
+
Properties prefixed with `$` are internal and not exposed on the component prototype:
|
|
1110
|
+
|
|
1111
|
+
| Property | Purpose |
|
|
1112
|
+
|----------|---------|
|
|
1113
|
+
| `$bindables` | Declares observable properties |
|
|
1114
|
+
| `$connectedCallback` | Lifecycle hook (runs on each instance) |
|
|
1115
|
+
| `$disconnectedCallback` | Cleanup hook |
|
|
1116
|
+
| `$updatedCallback` | Called after state mutations |
|
|
1117
|
+
|
|
1118
|
+
## SVG Support
|
|
1119
|
+
|
|
1120
|
+
All bindings work inside `<svg>` elements. Use `attr.*` for SVG attributes:
|
|
1121
|
+
|
|
1122
|
+
```html
|
|
1123
|
+
<svg width="200" height="100">
|
|
1124
|
+
<template data-wcs="for: points">
|
|
1125
|
+
<circle data-wcs="attr.cx: .x; attr.cy: .y; attr.fill: .color" r="5" />
|
|
1126
|
+
</template>
|
|
1127
|
+
</svg>
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
## Lifecycle Hooks
|
|
1131
|
+
|
|
1132
|
+
State objects can define `$connectedCallback`, `$disconnectedCallback`, and `$updatedCallback` for initialization, cleanup, and update lifecycle handling.
|
|
1133
|
+
|
|
1134
|
+
```html
|
|
1135
|
+
<wcs-state>
|
|
1136
|
+
<script type="module">
|
|
1137
|
+
export default {
|
|
1138
|
+
timer: null,
|
|
1139
|
+
count: 0,
|
|
1140
|
+
|
|
1141
|
+
// Called when <wcs-state> is connected to the DOM
|
|
1142
|
+
async $connectedCallback() {
|
|
1143
|
+
const res = await fetch("/api/initial-count");
|
|
1144
|
+
this.count = await res.json();
|
|
1145
|
+
this.timer = setInterval(() => { this.count++; }, 1000);
|
|
1146
|
+
},
|
|
1147
|
+
|
|
1148
|
+
// Called when <wcs-state> is disconnected from the DOM (sync only)
|
|
1149
|
+
$disconnectedCallback() {
|
|
1150
|
+
clearInterval(this.timer);
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
</script>
|
|
1154
|
+
</wcs-state>
|
|
1155
|
+
```
|
|
1156
|
+
|
|
1157
|
+
| Hook | Timing | Async |
|
|
1158
|
+
|---|---|---|
|
|
1159
|
+
| `$connectedCallback` | After state initialization on first connect; on every reconnect thereafter | Yes (awaited) |
|
|
1160
|
+
| `$disconnectedCallback` | When the element is removed from the DOM | No (sync only) |
|
|
1161
|
+
| `$updatedCallback(paths, indexesListByPath)` | After state updates are applied | Yes (not awaited) |
|
|
1162
|
+
|
|
1163
|
+
All hooks except `$disconnectedCallback` support `async` — you can use `async/await` in any of them. Since the reactive proxy detects every property assignment as a change, standard `async/await` with direct property updates is sufficient for asynchronous operations — loading flags, fetched data, and error messages are all just property assignments, without requiring additional abstractions for async state management.
|
|
1164
|
+
|
|
1165
|
+
- `this` inside hooks is the state proxy with full read/write access
|
|
1166
|
+
- `$connectedCallback` is called **every time** the element is connected (including re-insertion after removal), making it suitable for setup that should be re-established
|
|
1167
|
+
- `$disconnectedCallback` is called synchronously — use it for cleanup such as clearing timers, removing event listeners, or releasing resources
|
|
1168
|
+
- `$updatedCallback(paths, indexesListByPath)` receives the updated path list. For wildcard updates, `indexesListByPath` contains the updated index sets. Can be `async`, but the return value is not awaited
|
|
1169
|
+
- In Web Components, define `async $stateReadyCallback(stateProp)` to receive a hook when the bound state becomes available via `bind-component`
|
|
1170
|
+
|
|
1171
|
+
## Configuration
|
|
1172
|
+
|
|
1173
|
+
Pass a partial configuration object to `bootstrapState()`:
|
|
1174
|
+
|
|
1175
|
+
```javascript
|
|
1176
|
+
import { bootstrapState } from '@wcstack/state';
|
|
1177
|
+
|
|
1178
|
+
bootstrapState({
|
|
1179
|
+
locale: 'ja-JP',
|
|
1180
|
+
debug: true,
|
|
1181
|
+
enableMustache: false,
|
|
1182
|
+
tagNames: { state: 'my-state' },
|
|
1183
|
+
});
|
|
1184
|
+
```
|
|
1185
|
+
|
|
1186
|
+
All options with defaults:
|
|
1187
|
+
|
|
1188
|
+
| Option | Default | Description |
|
|
1189
|
+
|---|---|---|
|
|
1190
|
+
| `bindAttributeName` | `'data-wcs'` | Binding attribute name |
|
|
1191
|
+
| `tagNames.state` | `'wcs-state'` | State element tag name |
|
|
1192
|
+
| `locale` | `'en'` | Default locale for filters |
|
|
1193
|
+
| `debug` | `false` | Debug mode |
|
|
1194
|
+
| `enableMustache` | `true` | Enable `{{ }}` syntax |
|
|
1195
|
+
|
|
1196
|
+
## TypeScript Support
|
|
1197
|
+
|
|
1198
|
+
`defineState()` wraps your state object and provides type-safe `this` inside methods and getters — with zero runtime cost (identity function).
|
|
1199
|
+
|
|
1200
|
+
```typescript
|
|
1201
|
+
import { defineState } from '@wcstack/state';
|
|
1202
|
+
|
|
1203
|
+
export default defineState({
|
|
1204
|
+
count: 0,
|
|
1205
|
+
users: [] as { name: string; age: number }[],
|
|
1206
|
+
|
|
1207
|
+
increment() {
|
|
1208
|
+
this.count++; // ✅ number
|
|
1209
|
+
this["users.*.name"]; // ✅ string (dot-path resolution)
|
|
1210
|
+
this.$getAll("users.*.age", []); // ✅ API method
|
|
1211
|
+
},
|
|
1212
|
+
|
|
1213
|
+
get "users.*.ageCategory"() {
|
|
1214
|
+
return this["users.*.age"] < 25 ? "Young" : "Adult";
|
|
1215
|
+
}
|
|
1216
|
+
});
|
|
1217
|
+
```
|
|
1218
|
+
|
|
1219
|
+
Utility types `WcsPaths<T>` and `WcsPathValue<T, P>` are also exported for advanced use cases. See [docs/define-state.md](docs/define-state.md) for full documentation.
|
|
1220
|
+
|
|
1221
|
+
## API Reference
|
|
1222
|
+
|
|
1223
|
+
### `bootstrapState()`
|
|
1224
|
+
|
|
1225
|
+
Initialize the state system. Registers `<wcs-state>` custom element and sets up DOM content loaded handler.
|
|
1226
|
+
|
|
1227
|
+
```javascript
|
|
1228
|
+
import { bootstrapState } from '@wcstack/state';
|
|
1229
|
+
bootstrapState();
|
|
1230
|
+
```
|
|
1231
|
+
|
|
1232
|
+
### `<wcs-state>` Element
|
|
1233
|
+
|
|
1234
|
+
| Attribute | Description |
|
|
1235
|
+
|---|---|
|
|
1236
|
+
| `name` | State name (default: `"default"`) |
|
|
1237
|
+
| `state` | ID of a `<script type="application/json">` element |
|
|
1238
|
+
| `src` | URL to `.json` or `.js` file |
|
|
1239
|
+
| `json` | Inline JSON string |
|
|
1240
|
+
| `bind-component` | Property name for web component binding |
|
|
1241
|
+
|
|
1242
|
+
### IStateElement
|
|
1243
|
+
|
|
1244
|
+
| Property / Method | Description |
|
|
1245
|
+
|---|---|
|
|
1246
|
+
| `name` | State name |
|
|
1247
|
+
| `initializePromise` | Resolves when state is fully initialized |
|
|
1248
|
+
| `listPaths` | Set of paths used in `for` loops |
|
|
1249
|
+
| `getterPaths` | Set of paths defined as getters |
|
|
1250
|
+
| `setterPaths` | Set of paths defined as setters |
|
|
1251
|
+
| `createState(mutability, callback)` | Create a state proxy (`"readonly"` or `"writable"`) |
|
|
1252
|
+
| `createStateAsync(mutability, callback)` | Async version of `createState` |
|
|
1253
|
+
| `setInitialState(state)` | Set state programmatically (before initialization) |
|
|
1254
|
+
| `bindProperty(prop, descriptor)` | Define a property on the raw state object |
|
|
1255
|
+
| `nextVersion()` | Increment and return version number |
|
|
1256
|
+
|
|
1257
|
+
## Architecture
|
|
1258
|
+
|
|
1259
|
+
```
|
|
1260
|
+
bootstrapState()
|
|
1261
|
+
└── registerComponents() // Register <wcs-state> custom element
|
|
1262
|
+
|
|
1263
|
+
<wcs-state> connectedCallback
|
|
1264
|
+
├── _initializeBindWebComponent() // bind-component: get state from parent component
|
|
1265
|
+
├── _initialize() // Load state (state attr / src / json / script / API)
|
|
1266
|
+
│ └── setStateElementByName() // Register to WeakMap<Node, Map<name, element>>
|
|
1267
|
+
│ └── (first registration per rootNode)
|
|
1268
|
+
│ └── queueMicrotask → buildBindings()
|
|
1269
|
+
├── _callStateConnectedCallback() // Call $connectedCallback if defined
|
|
1270
|
+
|
|
1271
|
+
buildBindings(root)
|
|
1272
|
+
├── waitForStateInitialize() // Wait for all <wcs-state> initializePromise
|
|
1273
|
+
├── convertMustacheToComments() // {{ }} → comment nodes
|
|
1274
|
+
├── collectStructuralFragments() // Collect for/if templates
|
|
1275
|
+
└── initializeBindings() // Walk DOM, parse data-wcs, set up bindings
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
### Reactivity Flow
|
|
1279
|
+
|
|
1280
|
+
1. State changes via Proxy `set` trap → `setByAddress()`
|
|
1281
|
+
2. Address resolved → updater enqueues absolute address
|
|
1282
|
+
3. Dependency walker invalidates (dirties) downstream caches
|
|
1283
|
+
4. Updater applies changes to bound DOM nodes via `applyChangeFromBindings()`
|
|
1284
|
+
|
|
1285
|
+
### State Address System
|
|
1286
|
+
|
|
1287
|
+
Paths like `users.*.name` are decomposed into:
|
|
1288
|
+
|
|
1289
|
+
- **PathInfo** — static path metadata (segments, wildcard count, parent path)
|
|
1290
|
+
- **ListIndex** — runtime loop index chain
|
|
1291
|
+
- **StateAddress** — combination of PathInfo + ListIndex
|
|
1292
|
+
- **AbsoluteStateAddress** — state name + StateAddress (for cross-state references)
|
|
1293
|
+
|
|
1294
|
+
## Server-Side Rendering
|
|
1295
|
+
|
|
1296
|
+
`@wcstack/state` supports SSR via the companion [`@wcstack/server`](../server/) package. The same templates you write for the client render on the server — no changes needed.
|
|
1297
|
+
|
|
1298
|
+
### Quick Setup
|
|
1299
|
+
|
|
1300
|
+
1. Add `enable-ssr` to your `<wcs-state>` element:
|
|
1301
|
+
|
|
1302
|
+
```html
|
|
1303
|
+
<wcs-state enable-ssr>
|
|
1304
|
+
<script type="module">
|
|
1305
|
+
export default {
|
|
1306
|
+
items: [],
|
|
1307
|
+
async $connectedCallback() {
|
|
1308
|
+
const res = await fetch("/api/items");
|
|
1309
|
+
this.items = await res.json();
|
|
1310
|
+
}
|
|
1311
|
+
};
|
|
1312
|
+
</script>
|
|
1313
|
+
</wcs-state>
|
|
1314
|
+
<template data-wcs="for: items">
|
|
1315
|
+
<div data-wcs="textContent: items.*.name"></div>
|
|
1316
|
+
</template>
|
|
1317
|
+
```
|
|
1318
|
+
|
|
1319
|
+
2. Render on the server:
|
|
1320
|
+
|
|
1321
|
+
```javascript
|
|
1322
|
+
import { renderToString } from "@wcstack/server";
|
|
1323
|
+
|
|
1324
|
+
const html = await renderToString(template, {
|
|
1325
|
+
baseUrl: "http://localhost:3000"
|
|
1326
|
+
});
|
|
1327
|
+
```
|
|
1328
|
+
|
|
1329
|
+
That's it. The client-side `@wcstack/state` automatically detects the `<wcs-ssr>` element, restores state from the JSON snapshot, and resumes reactivity without re-rendering.
|
|
1330
|
+
|
|
1331
|
+
### How It Works
|
|
1332
|
+
|
|
1333
|
+
| Phase | What happens |
|
|
1334
|
+
|-------|-------------|
|
|
1335
|
+
| **Server** | `renderToString()` runs your template in happy-dom, executes `$connectedCallback` (including `fetch()`), applies all bindings, and outputs rendered HTML with a `<wcs-ssr>` element containing hydration data |
|
|
1336
|
+
| **Client** | `<wcs-state enable-ssr>` loads state from `<wcs-ssr>` JSON, skips `$connectedCallback`, and `hydrateBindings()` wires up reactivity on the existing DOM |
|
|
1337
|
+
| **Fallback** | If server/client versions mismatch, the SSR DOM is cleaned up and `buildBindings()` runs a full client-side render |
|
|
1338
|
+
|
|
1339
|
+
### What `enable-ssr` Does
|
|
1340
|
+
|
|
1341
|
+
| Context | Behavior |
|
|
1342
|
+
|---------|----------|
|
|
1343
|
+
| **Server** (`renderToString`) | Generates `<wcs-ssr>` with state JSON, template fragments, and property data |
|
|
1344
|
+
| **Client** (hydration) | Reads `<wcs-ssr>`, restores state, skips `$connectedCallback`, hydrates bindings on existing DOM |
|
|
1345
|
+
|
|
1346
|
+
See [`@wcstack/server` README](../server/README.md) for full API documentation.
|
|
1347
|
+
|
|
1348
|
+
## License
|
|
1349
|
+
|
|
1350
|
+
MIT
|