tutuca 0.9.97 → 0.9.99
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.md +5 -3
- package/dist/tutuca-cli.js +208 -69
- package/dist/tutuca-components.js +2444 -0
- package/dist/tutuca-dev.ext.js +92 -45
- package/dist/tutuca-dev.js +92 -45
- package/dist/tutuca-dev.min.js +2 -2
- package/dist/tutuca-extra.ext.js +56 -28
- package/dist/tutuca-extra.js +56 -28
- package/dist/tutuca-extra.min.js +2 -2
- package/dist/tutuca-storybook.js +136 -10
- package/dist/tutuca.ext.js +56 -28
- package/dist/tutuca.js +56 -28
- package/dist/tutuca.min.js +2 -2
- package/package.json +5 -3
- package/skill/tutuca/advanced.md +14 -5
- package/skill/tutuca/cli.md +10 -68
- package/skill/tutuca/core.md +103 -89
- package/skill/tutuca/margaui.md +25 -13
- package/skill/tutuca/patterns/README.md +1 -0
- package/skill/tutuca/patterns/filter-a-list.md +3 -1
- package/skill/tutuca/patterns/filter-and-paginate.md +116 -0
- package/skill/tutuca/patterns/paginate-a-list.md +3 -1
- package/skill/tutuca/semantics.md +4 -3
- package/skill/tutuca/storybook.md +7 -2
- package/skill/tutuca/testing.md +14 -6
- package/skill/tutuca-source/tutuca.ext.js +56 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tutuca",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.99",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Zero-dependency SPA framework with immutable state and virtual DOM",
|
|
6
6
|
"main": "./dist/tutuca.js",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"./extra-ext": "./dist/tutuca-extra.ext.js",
|
|
15
15
|
"./dev-ext": "./dist/tutuca-dev.ext.js",
|
|
16
16
|
"./storybook": "./dist/tutuca-storybook.js",
|
|
17
|
+
"./components": "./dist/tutuca-components.js",
|
|
17
18
|
"./immutable": "./dist/immutable.js",
|
|
18
19
|
"./chai": "./dist/chai.js",
|
|
19
20
|
"./package.json": "./package.json"
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
"dist/tutuca-extra.ext.js",
|
|
60
61
|
"dist/tutuca-dev.ext.js",
|
|
61
62
|
"dist/tutuca-storybook.js",
|
|
63
|
+
"dist/tutuca-components.js",
|
|
62
64
|
"dist/immutable.js",
|
|
63
65
|
"dist/chai.js",
|
|
64
66
|
"skill"
|
|
@@ -99,9 +101,9 @@
|
|
|
99
101
|
},
|
|
100
102
|
"homepage": "https://github.com/marianoguerra/tutuca#readme",
|
|
101
103
|
"devDependencies": {
|
|
102
|
-
"@biomejs/biome": "^2.
|
|
104
|
+
"@biomejs/biome": "^2.5.1",
|
|
103
105
|
"chai": "^6.2.2",
|
|
104
|
-
"fast-check": "^4.
|
|
106
|
+
"fast-check": "^4.8.0",
|
|
105
107
|
"htmlparser2": "^12.0.0"
|
|
106
108
|
}
|
|
107
109
|
}
|
package/skill/tutuca/advanced.md
CHANGED
|
@@ -124,11 +124,20 @@ in the producer's own view update in lock-step.
|
|
|
124
124
|
Tutuca's special operations (`render`, `render-it`, `render-each`, `text`,
|
|
125
125
|
`show`, `hide`, `slot`) live on the `<x>` tag. That works almost
|
|
126
126
|
everywhere, but the browser's HTML parser refuses to keep `<x>` (or any
|
|
127
|
-
unknown tag) as a child of certain elements
|
|
128
|
-
`<option>` / `<optgroup>`, `<table>` only allows `<thead>` / `<tbody>` /
|
|
129
|
-
`<tr>`, `<tr>` only allows `<th>` / `<td>`, etc. Drop `<x render-each>`
|
|
127
|
+
unknown tag) as a child of certain elements. Drop `<x render-each>`
|
|
130
128
|
inside one of those and the parser silently strips it.
|
|
131
129
|
|
|
130
|
+
The parser strips `<x>` only inside the **table family** and **`<select>`**.
|
|
131
|
+
Use pseudo-`@x` when the parent is one of:
|
|
132
|
+
|
|
133
|
+
`table`, `thead`, `tbody`, `tfoot`, `tr`, `colgroup`, `select`, `optgroup`.
|
|
134
|
+
|
|
135
|
+
Everywhere else `<x>` is kept and needs no workaround — including `ul`, `ol`,
|
|
136
|
+
`li`, `dl`, `dt`, `dd`, `details`, `summary`, `caption`, `td`, `th`. So
|
|
137
|
+
`<ul><x render-each=".items">…</x></ul>` is fine. (When in doubt, the rule of
|
|
138
|
+
thumb is: any element whose HTML content model only permits *specific* child
|
|
139
|
+
tags — table sections and `<select>` — strips `<x>`.)
|
|
140
|
+
|
|
132
141
|
The escape hatch: prefix the **first** attribute on a *legal* tag with
|
|
133
142
|
`@x`. Tutuca treats that tag as if it were `<x>` and reads the next
|
|
134
143
|
attribute as the special op.
|
|
@@ -151,8 +160,8 @@ Notes:
|
|
|
151
160
|
`render`, `text`, `show`, ...) is the second.
|
|
152
161
|
- The host tag (here `<option>`) is otherwise ignored — only the special
|
|
153
162
|
op runs. Tutuca produces the rendered children directly.
|
|
154
|
-
- Same trick works inside
|
|
155
|
-
`<
|
|
163
|
+
- Same trick works inside any of the stripping parents listed above
|
|
164
|
+
(`<table>`/`<tr>`/`<colgroup>`/`<select>`/…).
|
|
156
165
|
|
|
157
166
|
## Registering a custom seq type
|
|
158
167
|
|
package/skill/tutuca/cli.md
CHANGED
|
@@ -151,77 +151,19 @@ Filters:
|
|
|
151
151
|
Default format is `cli` (a tree with ✓/✗/○ and per-test durations);
|
|
152
152
|
`-f md` and `-f json` work too.
|
|
153
153
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
```js
|
|
158
|
-
export function getTests({ describe, test, expect }) {
|
|
159
|
-
describe(Counter, () => {
|
|
160
|
-
describe("inc()", () => { // method
|
|
161
|
-
test("returns a Counter with count + 1", () => {
|
|
162
|
-
const next = Counter.make().inc();
|
|
163
|
-
expect(next).toBeInstanceOf(Counter.Class);
|
|
164
|
-
expect(next.count).toBe(1);
|
|
165
|
-
});
|
|
166
|
-
test("does not mutate the original instance", () => {
|
|
167
|
-
const c = Counter.make({ count: 7 });
|
|
168
|
-
c.inc();
|
|
169
|
-
expect(c.count).toBe(7); // immutability
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
describe("dec()", () => { // input handler
|
|
174
|
-
test("returns a Counter with count - 1", () => {
|
|
175
|
-
const next = Counter.input.dec.call(Counter.make());
|
|
176
|
-
expect(next.count).toBe(-1);
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
test("inc and dec round-trip", () => { // untagged path
|
|
181
|
-
expect(Counter.input.dec.call(Counter.make().inc()).count).toBe(0);
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
`describe(Counter, fn)` auto-tags the suite path with `Counter.name`, so
|
|
188
|
-
`tutuca test <module> Counter` picks it up. Untagged `test(...)` at the
|
|
189
|
-
top of a tagged `describe` inherits the tag.
|
|
154
|
+
The `getTests` shape and the handler calling conventions (`Comp.method()`,
|
|
155
|
+
`Comp.input.x.call(inst, …)`, the `drive` cascade helper, iteration
|
|
156
|
+
handlers) are in [testing.md](./testing.md).
|
|
190
157
|
|
|
191
158
|
## storybook — live component catalog
|
|
192
159
|
|
|
193
|
-
`tutuca storybook [dir]` serves a browser storybook
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
tutuca storybook ./packages/ui # scan + serve another directory
|
|
201
|
-
tutuca storybook --port 4321 # preferred port (falls back to a free one if taken)
|
|
202
|
-
tutuca storybook --out ./_site # write a static index.html + bootstrap instead of serving
|
|
203
|
-
tutuca storybook --dry-run # do all the prep + print what would be shown, don't serve (smoke test)
|
|
204
|
-
tutuca storybook --dry-run --json # same, machine-readable for agents
|
|
205
|
-
tutuca storybook --no-tests # skip the pre-serve getTests() run
|
|
206
|
-
tutuca storybook --no-margaui # render unstyled (skip margaui)
|
|
207
|
-
tutuca storybook --no-check # skip the in-browser check(app)
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
It is **batteries-included by default**: before serving it runs each module's
|
|
211
|
-
`getTests()` in the terminal, the page wires margaui styling, and the browser
|
|
212
|
-
runs `check(app)`. Each is individually disablable with the `--no-*` flags.
|
|
213
|
-
|
|
214
|
-
How tutuca itself is resolved (convention over configuration): a local
|
|
215
|
-
`node_modules/tutuca` install if present, else the CLI's own `dist`, else the
|
|
216
|
-
version-pinned CDN. All tutuca specifiers resolve to a single runtime, which
|
|
217
|
-
component scope/identity requires. `--out` always pins the CDN so the static
|
|
218
|
-
artifact is portable (host it from the project root so `/*.dev.js` paths resolve).
|
|
219
|
-
|
|
220
|
-
### Authoring `.dev.js` story modules
|
|
221
|
-
|
|
222
|
-
The `*.dev.js` convention (a dev-only module holding `getComponents()` +
|
|
223
|
-
`getExamples()` + `getTests()`, never shipped), the example/section shape, and
|
|
224
|
-
per-example request mocking are covered in [storybook.md](./storybook.md).
|
|
160
|
+
`tutuca storybook [dir]` serves a browser storybook with no setup — it
|
|
161
|
+
discovers co-located `*.dev.js` modules, runs their `getTests()`, wires
|
|
162
|
+
margaui, and serves an ephemeral page. Its flags (`--port`, `--out`,
|
|
163
|
+
`--dry-run`, `--no-margaui`, `--no-check`, `--no-tests`) are in the
|
|
164
|
+
Commands table above. Authoring `.dev.js` modules, the example/section shape,
|
|
165
|
+
per-example request mocking, and runtime resolution are all in
|
|
166
|
+
[storybook.md](./storybook.md).
|
|
225
167
|
|
|
226
168
|
## Install skill assets
|
|
227
169
|
|
package/skill/tutuca/core.md
CHANGED
|
@@ -248,8 +248,9 @@ The one exception is **boolean predicates** in conditional slots
|
|
|
248
248
|
a value, written predicate-first like a handler call —
|
|
249
249
|
`empty?`, `truthy?`, `falsy?`, `null?`, `equals?`. E.g.
|
|
250
250
|
`@hide="empty? .items"`, `@show="truthy? .query"`. A conditional slot
|
|
251
|
-
|
|
252
|
-
(`@show="$canSubmit"`)
|
|
251
|
+
otherwise accepts the same value forms as `@text` — a plain field
|
|
252
|
+
(`@show=".isOpen"`), a no-arg method (`@show="$canSubmit"`), or a loop/scope
|
|
253
|
+
`@binding` (`@show="@isSelected"`, `@hide="@hasDesc"`) — read as a boolean.
|
|
253
254
|
|
|
254
255
|
`equals?` takes two args and is the idiomatic way to show/hide by name,
|
|
255
256
|
e.g. `@show="equals? .view 'detail'"`. Predicate args (and handler
|
|
@@ -517,13 +518,21 @@ other inline content, or a loop binding). Both take the same value forms
|
|
|
517
518
|
```html
|
|
518
519
|
<input :value=".str" @on.input="$setStr value" />
|
|
519
520
|
<a :href=".url" :title="$'Hi {.name}'">link</a> <!-- string template -->
|
|
520
|
-
<button
|
|
521
|
+
<button :class="$'btn {.color}'">x</button>
|
|
521
522
|
```
|
|
522
523
|
|
|
523
524
|
Plain attrs are static. `:attr="..."` is a dynamic expression. Boolean
|
|
524
525
|
HTML attributes (`disabled`, `checked`, `hidden`, …) are auto-recognized;
|
|
525
526
|
pass a boolean field.
|
|
526
527
|
|
|
528
|
+
A static `class="…"` and a dynamic `:class`/`@if.class` **cannot coexist on the
|
|
529
|
+
same element** — setting one attribute two ways is a lint error
|
|
530
|
+
(`DUPLICATE_ATTR_DEFINITION`), and at runtime the dynamic value wins and the
|
|
531
|
+
static class is dropped. Fold any structural classes into the bound expression,
|
|
532
|
+
e.g. `:class="$'btn {.color}'"` (note `btn` is part of the template, not a
|
|
533
|
+
separate `class="btn"`). The same applies to other attributes — see the
|
|
534
|
+
duplicate-attribute note below.
|
|
535
|
+
|
|
527
536
|
The HTML parser lowercases attribute names before Tutuca sees them, so
|
|
528
537
|
`:mapId` arrives as `:mapid` and `<x:Card>` becomes `<x:card>`. Three
|
|
529
538
|
consequences:
|
|
@@ -549,6 +558,24 @@ via `is="..."` (e.g. `<button is="x-fancy">`); `is` is applied when the
|
|
|
549
558
|
element is created, so it must be a static attribute — setting it later
|
|
550
559
|
does not upgrade the element.
|
|
551
560
|
|
|
561
|
+
### When nothing renders (or renders unstyled)
|
|
562
|
+
|
|
563
|
+
A few mistakes fail quietly — no error, just a blank or unstyled result, which
|
|
564
|
+
is the slowest kind to debug. **Run `tutuca lint <module>` first**: it catches
|
|
565
|
+
several of these. The usual suspects:
|
|
566
|
+
|
|
567
|
+
- **Unparseable attribute value** → the attribute is silently dropped. A bare
|
|
568
|
+
multi-word value isn't a string — quote it (`:label="'two words'"`) or make it
|
|
569
|
+
a template (`:label="$'{.a} {.b}'"`). Lint flags this as `BAD_VALUE`.
|
|
570
|
+
- **camelCase attribute on a custom element** → setter no-op (see the lowercasing
|
|
571
|
+
note above). Use kebab-case attributes. Not lintable — the HTML parser
|
|
572
|
+
lowercases the name before either Tutuca or the linter sees it.
|
|
573
|
+
- **Forgotten margaui `_palette`/decoy view** → classes assembled in methods or
|
|
574
|
+
interpolations render unstyled. See [margaui.md](./margaui.md). Not lintable.
|
|
575
|
+
- **A whitespace-only `html\`\``** → blank render. A *leading* newline before the
|
|
576
|
+
root element is fine (the parser trims it); a template with no element at all
|
|
577
|
+
is not.
|
|
578
|
+
|
|
552
579
|
## Event Handling
|
|
553
580
|
|
|
554
581
|
```html
|
|
@@ -630,12 +657,11 @@ inbound sources (WebSocket, `postMessage`, timers) have no element to bind
|
|
|
630
657
|
— route those through `app.sendAtRoot` instead (see
|
|
631
658
|
[request-response.md](./request-response.md)).
|
|
632
659
|
|
|
633
|
-
Pitfall: binding camelCase JS
|
|
634
|
-
fails
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
custom elements for use with Tutuca. See *Attribute Binding* above.
|
|
660
|
+
Pitfall: binding a camelCase JS property on a custom element silently
|
|
661
|
+
fails — `:mapId=".id"` assigns to `node.mapid`, never invoking
|
|
662
|
+
`set mapId`. Author custom elements with kebab-case attributes /
|
|
663
|
+
lowercased setters and bind via `:kebab-name`. See *Attribute Binding*
|
|
664
|
+
above for the full explanation.
|
|
639
665
|
|
|
640
666
|
## Conditional Display
|
|
641
667
|
|
|
@@ -714,8 +740,8 @@ share the handler-name resolution rules below.
|
|
|
714
740
|
alter: {
|
|
715
741
|
filterItem(_key, item, iterData) { return item.includes(iterData.q); },
|
|
716
742
|
enrichItem(binds, _key, item, iterData) { binds.count = item.length; },
|
|
717
|
-
// `@loop-with` returns { iterData?, start?, end? }
|
|
718
|
-
getIterData(seq) {
|
|
743
|
+
// `@loop-with` is `(seq, ctx)` and returns { iterData?, start?, end?, keys? }.
|
|
744
|
+
getIterData(seq, ctx) {
|
|
719
745
|
const start = this.page * this.pageSize;
|
|
720
746
|
return { iterData: { q: this.query.toLowerCase() }, start, end: start + this.pageSize };
|
|
721
747
|
},
|
|
@@ -724,7 +750,7 @@ alter: {
|
|
|
724
750
|
|
|
725
751
|
#### `@loop-with` return shape — `iterData` + slicing
|
|
726
752
|
|
|
727
|
-
A `@loop-with` handler returns an object with up to
|
|
753
|
+
A `@loop-with` handler returns an object with up to four optional keys:
|
|
728
754
|
|
|
729
755
|
- **`iterData`** — the shared per-loop value handed to `@when` /
|
|
730
756
|
`@enrich-with`. Defaults to `{ seq }` when omitted.
|
|
@@ -733,11 +759,43 @@ A `@loop-with` handler returns an object with up to three optional keys:
|
|
|
733
759
|
from the end (`end: -3` drops the last 3), `undefined` means the
|
|
734
760
|
natural bound. Use this to **paginate** — skip a prefix and/or suffix
|
|
735
761
|
without iterating or rendering it.
|
|
762
|
+
- **`keys`** — an explicit, ordered array of **original keys** to visit,
|
|
763
|
+
for **filter-then-paginate**. The handler filters/sorts/slices the full
|
|
764
|
+
sequence itself and returns the current page's slice of original keys;
|
|
765
|
+
the renderer visits exactly those (`seq.get(key)`), in order. Takes
|
|
766
|
+
precedence over `start`/`end` when both are present.
|
|
736
767
|
|
|
737
768
|
Slicing is positional but **preserves each item's original key**: a List
|
|
738
769
|
sliced to `start: 2` still binds `@key` to `2, 3, …`, so events, drag,
|
|
739
|
-
and two-way binding keep their identity. `@when` then
|
|
740
|
-
the window, so a page may yield fewer than `end - start`
|
|
770
|
+
and two-way binding keep their identity. With `start`/`end`, `@when` then
|
|
771
|
+
filters *within* the window, so a page may yield fewer than `end - start`
|
|
772
|
+
items — to filter *before* paging (so the page count reflects the filtered
|
|
773
|
+
total), return `keys` instead. `keys` are original keys, so identity is
|
|
774
|
+
preserved there too: editing or deleting a row on page 2 of a filtered view
|
|
775
|
+
hits the right item. A `keys` return is **authoritative** — the renderer
|
|
776
|
+
visits exactly those keys and does **not** re-apply `@when` (the handler has
|
|
777
|
+
already decided what renders).
|
|
778
|
+
|
|
779
|
+
#### `@loop-with` handler context — `(seq, ctx)`
|
|
780
|
+
|
|
781
|
+
The handler's second argument is `ctx = { lookup, filter }` (an object so it
|
|
782
|
+
can grow):
|
|
783
|
+
|
|
784
|
+
- **`ctx.lookup(name)`** — reads a scope `@`-binding, e.g. one published by an
|
|
785
|
+
ancestor scope `@enrich-with`. Lets the handler **reuse a value the enrich
|
|
786
|
+
already computed** instead of recomputing it.
|
|
787
|
+
- **`ctx.filter(key, value, iterData)`** — wraps the declared `@when` predicate
|
|
788
|
+
(always callable; a no-op that returns `true` when there is no `@when`). Lets
|
|
789
|
+
the handler apply the *declared* filter while building its `keys` slice,
|
|
790
|
+
rather than re-implementing the match test.
|
|
791
|
+
|
|
792
|
+
This is the **filter-then-paginate, minimal-work** pattern: a scope
|
|
793
|
+
`@enrich-with` on an ancestor does **one** counting scan and publishes the
|
|
794
|
+
clamped page + pager labels (which the page controls, sitting outside the loop,
|
|
795
|
+
read as `@`-bindings); the `@loop-with` handler then reads the clamped page via
|
|
796
|
+
`ctx.lookup`, reuses the predicate via `ctx.filter`, and collects only the
|
|
797
|
+
current page's keys — early-exiting once the page is full. See
|
|
798
|
+
[patterns/filter-and-paginate.md](patterns/filter-and-paginate.md).
|
|
741
799
|
|
|
742
800
|
### Lifecycle of `@each`
|
|
743
801
|
|
|
@@ -745,14 +803,18 @@ For each render of an element with `@each=".items"`:
|
|
|
745
803
|
|
|
746
804
|
1. **Resolve sequence** — evaluate `.items`. Lists, IMaps, OMaps, ISets,
|
|
747
805
|
and any class declaring a `SEQ_INFO` walker are recognized.
|
|
748
|
-
2. **`@loop-with`** (once per render) — `getIterData.call(this, seq)`
|
|
749
|
-
called with the full sequence
|
|
750
|
-
per-loop value and its `start`/`end`
|
|
751
|
-
no `@loop-with`; then `iterData` is
|
|
752
|
-
is iterated.
|
|
753
|
-
|
|
806
|
+
2. **`@loop-with`** (once per render) — `getIterData.call(this, seq, ctx)`
|
|
807
|
+
is called with the full sequence and the `{ lookup, filter }` context;
|
|
808
|
+
its `iterData` becomes the shared per-loop value and its `start`/`end`
|
|
809
|
+
slice the iteration. Skipped if no `@loop-with`; then `iterData` is
|
|
810
|
+
`{ seq }` and the whole sequence is iterated. If it returns `keys`,
|
|
811
|
+
those exact keys are visited in order (filter-then-paginate) and
|
|
812
|
+
`start`/`end` are ignored.
|
|
813
|
+
3. For each `(key, value)` pair in the sliced sequence (or each `key` in
|
|
814
|
+
`keys`):
|
|
754
815
|
1. **`@when`** — `filterItem.call(this, key, value, iterData)`; if it
|
|
755
|
-
returns `false`, the item is skipped.
|
|
816
|
+
returns `false`, the item is skipped. **Not applied** when the
|
|
817
|
+
handler returned `keys` (those are authoritative).
|
|
756
818
|
2. **`@enrich-with`** — `enrichItem.call(this, binds, key, value, iterData)`.
|
|
757
819
|
`binds` is a **mutable object** seeded with `{ key, value }`;
|
|
758
820
|
mutating it (`binds.count = ...`) creates `@`-prefixed bindings
|
|
@@ -868,66 +930,29 @@ CSS via the extra build), see [margaui.md](./margaui.md).
|
|
|
868
930
|
|
|
869
931
|
## Triggers and Handlers
|
|
870
932
|
|
|
871
|
-
Tutuca has four orchestration channels. Each
|
|
872
|
-
|
|
933
|
+
Tutuca has four orchestration channels. Each pairs a trigger with a
|
|
934
|
+
same-shape handler block:
|
|
873
935
|
|
|
874
|
-
| Triggered by | Handler block |
|
|
875
|
-
| ------------------------------------------- | ------------------- |
|
|
876
|
-
| DOM event (`click`, `input`, …) | `input: { ... }` |
|
|
877
|
-
| `ctx.
|
|
878
|
-
| `ctx.
|
|
879
|
-
| `ctx.
|
|
936
|
+
| Triggered by | Handler block | Use for |
|
|
937
|
+
| ------------------------------------------- | ------------------- | --------------------------------------------------- |
|
|
938
|
+
| DOM event (`click`, `input`, …) | `input: { ... }` | the component handling its own events |
|
|
939
|
+
| `ctx.bubble(name)` — event up the tree | `bubble: { ... }` | aggregate state an ancestor owns (logs, selections) |
|
|
940
|
+
| `ctx.send(name)` — message to a target path | `receive: { ... }` | addressing one known component (or self) |
|
|
941
|
+
| `ctx.request(name)` — async request | `response: { ... }` | fetch / timer / IndexedDB, result routed back |
|
|
880
942
|
|
|
881
943
|
Every handler is called as `handler(...args, ctx)` and returns a
|
|
882
|
-
(possibly updated) instance of `this
|
|
883
|
-
|
|
884
|
-
beyond `input` —
|
|
885
|
-
|
|
886
|
-
are
|
|
887
|
-
|
|
944
|
+
(possibly updated) instance of `this`, which the framework swaps into
|
|
945
|
+
the dispatch path; `ctx` is always the trailing argument. The three
|
|
946
|
+
channels beyond `input` — plus `ctx.at`, the `$unknown` fallback,
|
|
947
|
+
per-call handler-name overrides, error handling, and request-handler
|
|
948
|
+
registration — are in [request-response.md](./request-response.md);
|
|
949
|
+
worked snippets in
|
|
950
|
+
[patterns/coordinate-components.md](./patterns/coordinate-components.md).
|
|
888
951
|
|
|
889
952
|
`alter` is a fifth handler block, but unlike the four above it isn't
|
|
890
953
|
event-triggered — the renderer invokes alter handlers to produce
|
|
891
954
|
binds, not to update state. See *Mental model* and *Scope Enrichment*.
|
|
892
955
|
|
|
893
|
-
## Orchestration channels (bubble / send-receive / request-response)
|
|
894
|
-
|
|
895
|
-
Beyond local `input` handlers, three channels move state between
|
|
896
|
-
components. Full mechanics — when-to-use guidance, the `ctx.at`
|
|
897
|
-
`PathBuilder`, error handling, per-call handler-name overrides, the
|
|
898
|
-
`$unknown` fallback, and request-handler registration — are in
|
|
899
|
-
[request-response.md](./request-response.md). The essentials:
|
|
900
|
-
|
|
901
|
-
- **`bubble`** — `ctx.bubble("name", args)` walks the dispatch path
|
|
902
|
-
toward the root; each ancestor with `bubble.<name>(...args, ctx)`
|
|
903
|
-
runs (after descendants transact); `ctx.stopPropagation()` halts it.
|
|
904
|
-
Use for aggregate state owned by an ancestor (logs, selections).
|
|
905
|
-
|
|
906
|
-
```js
|
|
907
|
-
input: { onClick(ctx) { ctx.bubble("itemSelected", [this]); return this; } },
|
|
908
|
-
bubble: { itemSelected(item, ctx) { return this.insertInLogAt(0, item.label); } },
|
|
909
|
-
```
|
|
910
|
-
|
|
911
|
-
- **`send` / `receive`** — `ctx.send("name", args)` delivers a message
|
|
912
|
-
to one target (self by default, or `ctx.at.field("x").send(...)` /
|
|
913
|
-
`.index(name, i)` / `.key(name, k)` for another); the target's
|
|
914
|
-
`receive.<name>(...args, ctx)` runs. `receive.init` is a convention,
|
|
915
|
-
not a lifecycle hook — dispatch it via `app.sendAtRoot("init")`.
|
|
916
|
-
|
|
917
|
-
- **`request` / `response`** — `ctx.request("name", args)` runs a
|
|
918
|
-
host-registered async handler (registered with
|
|
919
|
-
`scope.registerRequestHandlers({...})`) and routes the result to
|
|
920
|
-
`response.<name>(res, err, ctx)` — `res` set on success, `err` on
|
|
921
|
-
failure. Use for fetch / timer / IndexedDB work.
|
|
922
|
-
|
|
923
|
-
```js
|
|
924
|
-
receive: { init(ctx) { ctx.request("loadData"); return this.setIsLoading(true); } },
|
|
925
|
-
response: { loadData(res, err, ctx) { return this.setIsLoading(false).setItems(res); } },
|
|
926
|
-
```
|
|
927
|
-
|
|
928
|
-
`ctx` is always the last argument of every `bubble` / `receive` /
|
|
929
|
-
`response` handler.
|
|
930
|
-
|
|
931
956
|
## Macros
|
|
932
957
|
|
|
933
958
|
Pure template expansion — no state, no methods. Calls inside a macro
|
|
@@ -1052,22 +1077,11 @@ export function getTests({ describe, test, expect }) { /*...*/ } // optiona
|
|
|
1052
1077
|
```
|
|
1053
1078
|
|
|
1054
1079
|
An example item may carry an optional **`requestHandlers`** map — per-example
|
|
1055
|
-
mocks
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
loading state:
|
|
1061
|
-
|
|
1062
|
-
```js
|
|
1063
|
-
items: [
|
|
1064
|
-
{ title: "Loaded", value: Widget.make(),
|
|
1065
|
-
requestHandlers: { async load() { return [{ id: 1, name: "Ada" }]; } } },
|
|
1066
|
-
{ title: "Error", value: Widget.make(),
|
|
1067
|
-
requestHandlers: { async load() { throw new Error("boom"); } } },
|
|
1068
|
-
{ title: "Default", value: Widget.make() }, // no mock → real handler / 404
|
|
1069
|
-
]
|
|
1070
|
-
```
|
|
1080
|
+
mocks (keyed by request name) that override the module's real
|
|
1081
|
+
`getRequestHandlers()` for that one instance, so two examples of the same
|
|
1082
|
+
component show different responses side by side. Return a fixture, `throw` for
|
|
1083
|
+
the error path, or never resolve to hold a loading state. Full treatment, plus
|
|
1084
|
+
the `on` lifecycle hooks, in [storybook.md](./storybook.md).
|
|
1071
1085
|
|
|
1072
1086
|
Best practice: have `getComponents()` return **every** component the module
|
|
1073
1087
|
defines — child and helper components included — and give each one at least
|
package/skill/tutuca/margaui.md
CHANGED
|
@@ -95,24 +95,36 @@ available (`npx tutuca install-skill --margaui-skill`) — it lists the
|
|
|
95
95
|
available components and their canonical class strings, which is what the
|
|
96
96
|
`compile` step expects.
|
|
97
97
|
|
|
98
|
-
## Pitfall:
|
|
98
|
+
## Pitfall: assembled class names are invisible to the scanner
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
100
|
+
The scanner only reads **constant** class literals out of parsed templates. It
|
|
101
|
+
cannot see a class name that is assembled rather than written out verbatim, so
|
|
102
|
+
the margaui CSS for that class is never emitted and it renders unstyled. Two
|
|
103
|
+
cases:
|
|
104
|
+
|
|
105
|
+
- **Interpolated templates** — `:class="$'bg-{.color}'"` contributes only the
|
|
106
|
+
constant prefix `bg-`, never `bg-red` / `bg-blue`. Same for any `${…}` segment.
|
|
107
|
+
- **Classes built in a method** — anything a method returns (e.g. a `headerClass()`
|
|
108
|
+
that builds `` `progress-${this.color}` ``) is never scanned at all; the walker
|
|
109
|
+
only reads view templates, not JS bodies.
|
|
110
|
+
|
|
111
|
+
(Literal `@then` / `@else` strings on `@if.class` — e.g.
|
|
112
|
+
`@if.class=".active" @then="'btn-success'" @else="'btn-ghost'"` — **are** now
|
|
113
|
+
collected, so those don't need the workaround.)
|
|
114
|
+
|
|
115
|
+
Workaround: add a hidden "decoy"/palette view on the component that lists every
|
|
116
|
+
possible assembled class as a real literal, so the walker picks them up:
|
|
107
117
|
|
|
108
118
|
```js
|
|
109
|
-
|
|
119
|
+
// enumerate color × utility so each full class name appears verbatim
|
|
120
|
+
_margauiClasses: html`<p class="bg-red bg-blue progress-red progress-blue"></p>`,
|
|
110
121
|
```
|
|
111
122
|
|
|
112
|
-
The view does not need to be rendered anywhere — registration is enough
|
|
113
|
-
|
|
114
|
-
[component-design.md](./component-design.md) gives for runtime-assembled
|
|
115
|
-
|
|
123
|
+
The view does not need to be rendered anywhere — registration is enough for the
|
|
124
|
+
template walker to find it. (This is the same rule
|
|
125
|
+
[component-design.md](./component-design.md) gives for runtime-assembled margaui
|
|
126
|
+
classes.) The cost is that the palette and the methods can drift apart with no
|
|
127
|
+
check catching it; keep them adjacent and update both together.
|
|
116
128
|
|
|
117
129
|
## See also
|
|
118
130
|
|
|
@@ -14,6 +14,7 @@ task.
|
|
|
14
14
|
- [Filter a list](filter-a-list.md) — keep only matching items with `@when`.
|
|
15
15
|
- [Enrich each item](enrich-each-item.md) — expose derived per-item values as `@`-bindings.
|
|
16
16
|
- [Paginate a list](paginate-a-list.md) — slice the iteration with `@loop-with` `start`/`end`.
|
|
17
|
+
- [Filter and paginate a list](filter-and-paginate.md) — do both with `@loop-with` `keys` (filter-then-slice, identity preserved).
|
|
17
18
|
|
|
18
19
|
## Conditional content & attributes
|
|
19
20
|
|
|
@@ -20,4 +20,6 @@ alter: {
|
|
|
20
20
|
`@when` names an `alter` handler called per item as `(key, value, iterData)`;
|
|
21
21
|
return `false` to skip. It filters *after* any `@loop-with` slice, so a page
|
|
22
22
|
can yield fewer than its window. Filtering reads other fields off `this`
|
|
23
|
-
directly (`this.query`) — there are no paths in the template.
|
|
23
|
+
directly (`this.query`) — there are no paths in the template. To filter
|
|
24
|
+
*before* paging, return `keys` from `@loop-with` instead — see
|
|
25
|
+
[filter-and-paginate.md](filter-and-paginate.md).
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Filter and paginate a list
|
|
2
|
+
|
|
3
|
+
**Problem:** show one page of the items that match a query — filtering
|
|
4
|
+
*before* paging, so page counts reflect the filtered total and a row's
|
|
5
|
+
identity survives editing or deleting across pages — without scanning the
|
|
6
|
+
list more than necessary.
|
|
7
|
+
|
|
8
|
+
```html
|
|
9
|
+
<section @enrich-with="pageInfo"> <!-- COUNT pass: runs once -->
|
|
10
|
+
<input :value=".query" @on.input="search value" />
|
|
11
|
+
<li @each=".items" @when="onlyMatches" @loop-with="page"> <!-- COLLECT pass -->
|
|
12
|
+
<span @text="@key"></span> <x render-it></x>
|
|
13
|
+
<button @on.click="$removeInItemsAt @key">✕</button>
|
|
14
|
+
</li>
|
|
15
|
+
<button :disabled="@isFirst" @on.click="prev">‹</button>
|
|
16
|
+
<button @text="@pageLabel"></button>
|
|
17
|
+
<button :disabled="@isLast" @on.click="next">›</button>
|
|
18
|
+
</section>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
methods: { matchCount() { /* one scan: how many match this.query */ } },
|
|
23
|
+
alter: {
|
|
24
|
+
onlyMatches(_key, p) { return matches(p, this.query); }, // the predicate
|
|
25
|
+
pageInfo() { // scope enrich: the COUNT scan
|
|
26
|
+
const total = this.matchCount();
|
|
27
|
+
const { pageCount, currentPage } = clamp(this.page, total, this.pageSize);
|
|
28
|
+
return { currentPage, isFirst: currentPage <= 0, isLast: currentPage >= pageCount - 1,
|
|
29
|
+
pageLabel: `Page ${currentPage + 1} of ${pageCount} · ${total}` };
|
|
30
|
+
},
|
|
31
|
+
page(seq, { lookup, filter }) { // @loop-with: the COLLECT scan
|
|
32
|
+
const start = lookup("currentPage") * this.pageSize, end = start + this.pageSize;
|
|
33
|
+
const keys = [];
|
|
34
|
+
let m = 0;
|
|
35
|
+
for (let i = 0; i < seq.size && m < end; i++) // early-exit: stops at page end
|
|
36
|
+
if (filter(i, seq.get(i))) { if (m >= start) keys.push(i); m++; }
|
|
37
|
+
return { keys };
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Returning **`keys`** (ordered *original* keys) is what makes this work: the
|
|
43
|
+
renderer visits exactly those and does **not** re-apply `@when`, and because
|
|
44
|
+
`@key` stays the original index, deleting row `@key` on page 2 of a filtered
|
|
45
|
+
view hits the right item. The page controls live *outside* the loop, so they
|
|
46
|
+
can't read its `iterData`; instead a scope `@enrich-with` does the one counting
|
|
47
|
+
scan and publishes the clamped page + labels as `@`-bindings. The `@loop-with`
|
|
48
|
+
handler's `ctx` lets it avoid repeating that work: `ctx.lookup` reads the
|
|
49
|
+
clamped page the enrich already computed, and `ctx.filter` reuses the declared
|
|
50
|
+
`@when` predicate — so the collect pass scans just far enough to fill the page.
|
|
51
|
+
Reset `page` to 0 when the query changes.
|
|
52
|
+
|
|
53
|
+
## Three ways to wire it
|
|
54
|
+
|
|
55
|
+
The block above is the **shared** strategy. There are three, trading simplicity
|
|
56
|
+
for scans-per-render (all return `keys`, so all keep identity):
|
|
57
|
+
|
|
58
|
+
**1. Naive — two independent scans.** The loop scans + slices the whole list
|
|
59
|
+
itself; a separate `@enrich-with` scans again for the labels. Simplest, nothing
|
|
60
|
+
shared:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
naiveTablePage(seq, { filter }) { // builds the WHOLE matching list…
|
|
64
|
+
const all = [];
|
|
65
|
+
for (let i = 0; i < seq.size; i++) if (filter(i, seq.get(i))) all.push(i);
|
|
66
|
+
const start = clamp(this.page, all.length, this.pageSize).currentPage * this.pageSize;
|
|
67
|
+
return { keys: all.slice(start, start + this.pageSize) }; // …just to slice it
|
|
68
|
+
},
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**2. Shared — one count + one partial collect** (the block above). The enrich
|
|
72
|
+
counts and publishes `@currentPage`; the loop reuses it via `lookup` and stops
|
|
73
|
+
once the page is full.
|
|
74
|
+
|
|
75
|
+
**3. Coupled — one scan.** The enrich does *everything*, including the page keys,
|
|
76
|
+
and stashes them in a binding only the loop reads. Fastest, but the two handlers
|
|
77
|
+
are welded together — name them so it shows:
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
enrichBuildsKeysForTheLoopBelow() { // the only scan: count + labels + keys
|
|
81
|
+
const all = []; /* …collect matching indices… */
|
|
82
|
+
const { pageCount, currentPage } = clamp(this.page, all.length, this.pageSize);
|
|
83
|
+
const start = currentPage * this.pageSize;
|
|
84
|
+
return { __keys__: all.slice(start, start + this.pageSize), /* …labels… */ };
|
|
85
|
+
},
|
|
86
|
+
loopJustForwardsTheEnrichsKeys(_seq, { lookup }) { // useless without the enrich
|
|
87
|
+
return { keys: lookup("__keys__") };
|
|
88
|
+
},
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Testing each strategy
|
|
92
|
+
|
|
93
|
+
`collectIterBindings(Comp, instance, seq, opts)` drives a loop exactly like the
|
|
94
|
+
renderer and returns the `{ key, value }` binds it would render. Map the
|
|
95
|
+
template's directives to `opts` — `when` → `@when`, `loopWith` → `@loop-with`,
|
|
96
|
+
`scopeEnrich` → the ancestor scope `@enrich-with` whose result the loop reads via
|
|
97
|
+
`ctx.lookup`:
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
import { collectIterBindings } from "tutuca";
|
|
101
|
+
const keys = (Comp, c, opts) => collectIterBindings(Comp, c, c.items, opts).map((b) => b.key);
|
|
102
|
+
|
|
103
|
+
// each strategy wires the directives differently, yet renders the same page:
|
|
104
|
+
keys(Naive, c, { when: "onlyMatches", loopWith: "naiveTablePage" });
|
|
105
|
+
keys(Shared, c, { when: "onlyMatches", loopWith: "page", scopeEnrich: "pageInfo" });
|
|
106
|
+
keys(Coupled, c, { loopWith: "loopJustForwardsTheEnrichsKeys",
|
|
107
|
+
scopeEnrich: "enrichBuildsKeysForTheLoopBelow" });
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
A `keys` return is honored as-is (no `@when` re-applied), and `scopeEnrich` runs
|
|
111
|
+
the named scope handler so a `lookup("currentPage")` / `lookup("__keys__")`
|
|
112
|
+
resolves in the test. `collectIterBindings` lives in the **dev build**; both the
|
|
113
|
+
playground's Test tab (via its import map) and `tutuca test` (which redirects the
|
|
114
|
+
`"tutuca"` import to the dev build under Node) resolve it to the real
|
|
115
|
+
implementation. See [filter-a-list.md](filter-a-list.md) and
|
|
116
|
+
[paginate-a-list.md](paginate-a-list.md) for each half on its own.
|
|
@@ -24,4 +24,6 @@ slice with `Array.prototype.slice` semantics (`end` exclusive, negatives count
|
|
|
24
24
|
from the end). Slicing is positional but **preserves each item's original
|
|
25
25
|
key** — `@key` is the index in the full list, so events and two-way binding
|
|
26
26
|
keep their identity across pages. `iterData` is the shared per-loop value
|
|
27
|
-
handed to `@when` / `@enrich-with`.
|
|
27
|
+
handed to `@when` / `@enrich-with`. To paginate a *filtered* list, return
|
|
28
|
+
`keys` instead of `start`/`end` — see
|
|
29
|
+
[filter-and-paginate.md](filter-and-paginate.md).
|