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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tutuca",
3
- "version": "0.9.97",
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.4.12",
104
+ "@biomejs/biome": "^2.5.1",
103
105
  "chai": "^6.2.2",
104
- "fast-check": "^4.7.0",
106
+ "fast-check": "^4.8.0",
105
107
  "htmlparser2": "^12.0.0"
106
108
  }
107
109
  }
@@ -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: `<select>` only allows
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 `<tr>`, `<table>`, `<colgroup>`, `<dl>`,
155
- `<details>`, or anywhere else the parser would discard `<x>`.
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
 
@@ -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
- A worked `getTests()` export covering methods, input handlers (called
155
- via `Comp.input.x.call(inst)`), and immutability:
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 for a project with no
194
- setup. It recursively discovers co-located `*.dev.js` modules (see the
195
- `.dev.js` convention below), mounts them via the shipped `tutuca/storybook`
196
- library, and serves an ephemeral page — no config, no HTML to write.
197
-
198
- ```sh
199
- tutuca storybook # scan + serve the current directory
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
 
@@ -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
- still accepts a plain field (`@show=".isOpen"`) or no-arg method
252
- (`@show="$canSubmit"`) name too.
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 class="btn" :class="$'btn {.color}'">x</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 properties on a custom element silently
634
- fails. `:mapId=".id"` does *not* invoke a `set mapId` setter
635
- the HTML parser lowercased the attribute name, so the framework assigns
636
- to `node.mapid` instead, creating an own property and bypassing the
637
- setter. Use kebab-case attributes / lowercased setters when authoring
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? } — all optional.
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 three optional keys:
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 filters *within*
740
- the window, so a page may yield fewer than `end - start` items.
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)` is
749
- called with the full sequence; its `iterData` becomes the shared
750
- per-loop value and its `start`/`end` slice the iteration. Skipped if
751
- no `@loop-with`; then `iterData` is `{ seq }` and the whole sequence
752
- is iterated.
753
- 3. For each `(key, value)` pair in the sliced sequence:
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 one pairs a trigger with
872
- a same-shape handler block:
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.send(name)` — message to a target path | `receive: { ... }` |
878
- | `ctx.request(name)` — async request | `response: { ... }` |
879
- | `ctx.bubble(name)` — event up the tree | `bubble: { ... }` |
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`; the framework swaps the
883
- returned value into the dispatch path. The three event-driven channels
884
- beyond `input` — `bubble`, `send`/`receive`, async `request`/`response`
885
- plus the shared `$unknown` fallback and request-handler registration
886
- are documented in [request-response.md](./request-response.md); the
887
- brief anchors below cover the essentials.
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 for the request handlers its component triggers, used by the storybook
1056
- only. Each is a plain async function keyed by request name; it overrides the
1057
- module's real `getRequestHandlers()` handler **for that example instance only**,
1058
- so two examples of the same component can show different responses side by side.
1059
- Return a fixture, `throw` to exercise the error path, or never resolve to hold a
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
@@ -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: @if.class is invisible to the scanner
98
+ ## Pitfall: assembled class names are invisible to the scanner
99
99
 
100
- Classes inside `@then` / `@else` (e.g.
101
- `@if.class=".active" @then="'btn-success'" @else="'btn-ghost'"`) are not
102
- literals in `class=` / `:class=`, so `compileClassesToStyleText` skips
103
- them and the margaui CSS for those classes is never emitted — the
104
- conditional class renders unstyled. Workaround: add a hidden "decoy" view
105
- on the component that lists every conditional class as a real literal, so
106
- the walker picks them up:
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
- _margauiClasses: html`<p class="btn-success btn-ghost on off"></p>`,
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
- for the template walker to find it. (This is the same rule
114
- [component-design.md](./component-design.md) gives for runtime-assembled
115
- margaui classes.)
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).