tutuca 0.9.97 → 0.9.98
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/dist/tutuca-cli.js +190 -59
- package/dist/tutuca-dev.ext.js +76 -29
- package/dist/tutuca-dev.js +76 -29
- 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 +11 -4
- package/dist/tutuca.ext.js +56 -28
- package/dist/tutuca.js +56 -28
- package/dist/tutuca.min.js +2 -2
- package/package.json +3 -3
- package/skill/tutuca/advanced.md +14 -5
- package/skill/tutuca/core.md +78 -15
- 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/storybook.md +7 -2
- package/skill/tutuca/testing.md +11 -0
- 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.98",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Zero-dependency SPA framework with immutable state and virtual DOM",
|
|
6
6
|
"main": "./dist/tutuca.js",
|
|
@@ -99,9 +99,9 @@
|
|
|
99
99
|
},
|
|
100
100
|
"homepage": "https://github.com/marianoguerra/tutuca#readme",
|
|
101
101
|
"devDependencies": {
|
|
102
|
-
"@biomejs/biome": "^2.
|
|
102
|
+
"@biomejs/biome": "^2.5.1",
|
|
103
103
|
"chai": "^6.2.2",
|
|
104
|
-
"fast-check": "^4.
|
|
104
|
+
"fast-check": "^4.8.0",
|
|
105
105
|
"htmlparser2": "^12.0.0"
|
|
106
106
|
}
|
|
107
107
|
}
|
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/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
|
|
@@ -714,8 +741,8 @@ share the handler-name resolution rules below.
|
|
|
714
741
|
alter: {
|
|
715
742
|
filterItem(_key, item, iterData) { return item.includes(iterData.q); },
|
|
716
743
|
enrichItem(binds, _key, item, iterData) { binds.count = item.length; },
|
|
717
|
-
// `@loop-with` returns { iterData?, start?, end? }
|
|
718
|
-
getIterData(seq) {
|
|
744
|
+
// `@loop-with` is `(seq, ctx)` and returns { iterData?, start?, end?, keys? }.
|
|
745
|
+
getIterData(seq, ctx) {
|
|
719
746
|
const start = this.page * this.pageSize;
|
|
720
747
|
return { iterData: { q: this.query.toLowerCase() }, start, end: start + this.pageSize };
|
|
721
748
|
},
|
|
@@ -724,7 +751,7 @@ alter: {
|
|
|
724
751
|
|
|
725
752
|
#### `@loop-with` return shape — `iterData` + slicing
|
|
726
753
|
|
|
727
|
-
A `@loop-with` handler returns an object with up to
|
|
754
|
+
A `@loop-with` handler returns an object with up to four optional keys:
|
|
728
755
|
|
|
729
756
|
- **`iterData`** — the shared per-loop value handed to `@when` /
|
|
730
757
|
`@enrich-with`. Defaults to `{ seq }` when omitted.
|
|
@@ -733,11 +760,43 @@ A `@loop-with` handler returns an object with up to three optional keys:
|
|
|
733
760
|
from the end (`end: -3` drops the last 3), `undefined` means the
|
|
734
761
|
natural bound. Use this to **paginate** — skip a prefix and/or suffix
|
|
735
762
|
without iterating or rendering it.
|
|
763
|
+
- **`keys`** — an explicit, ordered array of **original keys** to visit,
|
|
764
|
+
for **filter-then-paginate**. The handler filters/sorts/slices the full
|
|
765
|
+
sequence itself and returns the current page's slice of original keys;
|
|
766
|
+
the renderer visits exactly those (`seq.get(key)`), in order. Takes
|
|
767
|
+
precedence over `start`/`end` when both are present.
|
|
736
768
|
|
|
737
769
|
Slicing is positional but **preserves each item's original key**: a List
|
|
738
770
|
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`
|
|
771
|
+
and two-way binding keep their identity. With `start`/`end`, `@when` then
|
|
772
|
+
filters *within* the window, so a page may yield fewer than `end - start`
|
|
773
|
+
items — to filter *before* paging (so the page count reflects the filtered
|
|
774
|
+
total), return `keys` instead. `keys` are original keys, so identity is
|
|
775
|
+
preserved there too: editing or deleting a row on page 2 of a filtered view
|
|
776
|
+
hits the right item. A `keys` return is **authoritative** — the renderer
|
|
777
|
+
visits exactly those keys and does **not** re-apply `@when` (the handler has
|
|
778
|
+
already decided what renders).
|
|
779
|
+
|
|
780
|
+
#### `@loop-with` handler context — `(seq, ctx)`
|
|
781
|
+
|
|
782
|
+
The handler's second argument is `ctx = { lookup, filter }` (an object so it
|
|
783
|
+
can grow):
|
|
784
|
+
|
|
785
|
+
- **`ctx.lookup(name)`** — reads a scope `@`-binding, e.g. one published by an
|
|
786
|
+
ancestor scope `@enrich-with`. Lets the handler **reuse a value the enrich
|
|
787
|
+
already computed** instead of recomputing it.
|
|
788
|
+
- **`ctx.filter(key, value, iterData)`** — wraps the declared `@when` predicate
|
|
789
|
+
(always callable; a no-op that returns `true` when there is no `@when`). Lets
|
|
790
|
+
the handler apply the *declared* filter while building its `keys` slice,
|
|
791
|
+
rather than re-implementing the match test.
|
|
792
|
+
|
|
793
|
+
This is the **filter-then-paginate, minimal-work** pattern: a scope
|
|
794
|
+
`@enrich-with` on an ancestor does **one** counting scan and publishes the
|
|
795
|
+
clamped page + pager labels (which the page controls, sitting outside the loop,
|
|
796
|
+
read as `@`-bindings); the `@loop-with` handler then reads the clamped page via
|
|
797
|
+
`ctx.lookup`, reuses the predicate via `ctx.filter`, and collects only the
|
|
798
|
+
current page's keys — early-exiting once the page is full. See
|
|
799
|
+
[patterns/filter-and-paginate.md](patterns/filter-and-paginate.md).
|
|
741
800
|
|
|
742
801
|
### Lifecycle of `@each`
|
|
743
802
|
|
|
@@ -745,14 +804,18 @@ For each render of an element with `@each=".items"`:
|
|
|
745
804
|
|
|
746
805
|
1. **Resolve sequence** — evaluate `.items`. Lists, IMaps, OMaps, ISets,
|
|
747
806
|
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
|
-
|
|
807
|
+
2. **`@loop-with`** (once per render) — `getIterData.call(this, seq, ctx)`
|
|
808
|
+
is called with the full sequence and the `{ lookup, filter }` context;
|
|
809
|
+
its `iterData` becomes the shared per-loop value and its `start`/`end`
|
|
810
|
+
slice the iteration. Skipped if no `@loop-with`; then `iterData` is
|
|
811
|
+
`{ seq }` and the whole sequence is iterated. If it returns `keys`,
|
|
812
|
+
those exact keys are visited in order (filter-then-paginate) and
|
|
813
|
+
`start`/`end` are ignored.
|
|
814
|
+
3. For each `(key, value)` pair in the sliced sequence (or each `key` in
|
|
815
|
+
`keys`):
|
|
754
816
|
1. **`@when`** — `filterItem.call(this, key, value, iterData)`; if it
|
|
755
|
-
returns `false`, the item is skipped.
|
|
817
|
+
returns `false`, the item is skipped. **Not applied** when the
|
|
818
|
+
handler returned `keys` (those are authoritative).
|
|
756
819
|
2. **`@enrich-with`** — `enrichItem.call(this, binds, key, value, iterData)`.
|
|
757
820
|
`binds` is a **mutable object** seeded with `{ key, value }`;
|
|
758
821
|
mutating it (`binds.count = ...`) creates `@`-prefixed bindings
|
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).
|
|
@@ -27,7 +27,7 @@ same file is a valid target for `tutuca lint` / `test` / `render` too.
|
|
|
27
27
|
|
|
28
28
|
| Export | Returns | Used for |
|
|
29
29
|
| ------ | ------- | -------- |
|
|
30
|
-
| `getComponents()` | `[Comp, ...]` | stories — return **every** component the module defines, children and helpers included |
|
|
30
|
+
| `getComponents()` | `[Comp, ...]` | stories — return **every** component the module defines, children and helpers included. Components dedup by identity, so re-listing a leaf that another module also lists is safe (a composition module can re-list every leaf it uses). |
|
|
31
31
|
| `getExamples()` | one section, or an array of sections | the catalog cards |
|
|
32
32
|
| `getTests({ describe, test, expect })` | tests | the pre-serve test run (optional) |
|
|
33
33
|
| `getMacros()` | `{ name: macro }` | macros referenced in views (optional) |
|
|
@@ -37,7 +37,12 @@ same file is a valid target for `tutuca lint` / `test` / `render` too.
|
|
|
37
37
|
## Authoring stories (`getExamples`)
|
|
38
38
|
|
|
39
39
|
Return one section, or an array of sections to group examples under multiple
|
|
40
|
-
headings. A section is `{ title, description?, items: [...] }
|
|
40
|
+
headings. A section is `{ title, description?, items: [...] }`. An array of one
|
|
41
|
+
section behaves exactly like returning that section directly — both go through
|
|
42
|
+
the same `Section.fromData`, which **throws** on a malformed section (missing
|
|
43
|
+
`title`, not an object) rather than rendering a placeholder title. (If you saw
|
|
44
|
+
broken titles from a one-element array in an older build, that predates array
|
|
45
|
+
support — it has shipped since well before 0.9.88.)
|
|
41
46
|
|
|
42
47
|
```js
|
|
43
48
|
import { component, html } from "tutuca";
|
package/skill/tutuca/testing.md
CHANGED
|
@@ -114,10 +114,21 @@ export function getTests({ describe, test, expect, drive }) {
|
|
|
114
114
|
- `drive(value, phase, opts?)` builds a transactor over `value`, dispatches the
|
|
115
115
|
phase's actions at the root, awaits the whole cascade (including async
|
|
116
116
|
requests), and returns the **settled** instance.
|
|
117
|
+
- `drive` **always originates at the root** — there is no `at:`/path option. To
|
|
118
|
+
exercise a handler on a nested child, call it directly with `.call(child, …)`.
|
|
117
119
|
- `phase` is the same shape as an example's `on.init`
|
|
118
120
|
(`{ send, bubble, request, input, do }`; see
|
|
119
121
|
[storybook.md](./storybook.md#lifecycle-hooks-on)). `args` may be a function
|
|
120
122
|
`(self) => [...]`.
|
|
123
|
+
- A `bubble` action is a **no-op under `drive`**: bubbles travel child→parent, and
|
|
124
|
+
at the root there is no ancestor to receive it (the root's own `bubble` handler
|
|
125
|
+
is skipped too). To test a `bubble` handler, call it directly. (`drive` warns
|
|
126
|
+
when a phase contains a `bubble`.)
|
|
127
|
+
- These are *action kinds*, not methods. `$`-prefixed **methods** (auto setters/
|
|
128
|
+
togglers, `$foo`) are not an action kind — `on`/`drive` can only reach state
|
|
129
|
+
through `input`/`receive`/`response` handlers. To put a component into a method-
|
|
130
|
+
driven state for a test, call the method directly or route it through an `input`
|
|
131
|
+
handler.
|
|
121
132
|
- `request` actions resolve against the module's `getRequestHandlers()`.
|
|
122
133
|
- `opts.onMessage(message, before, after)` observes every committed transaction —
|
|
123
134
|
`message` is `{ kind, name, args, path }`, `before`/`after` are the root values
|
|
@@ -1744,37 +1744,47 @@ class Renderer {
|
|
|
1744
1744
|
renderEach(stack, iterInfo, node, viewName) {
|
|
1745
1745
|
const { seq, filter, loopWith } = iterInfo.eval(stack);
|
|
1746
1746
|
const r = [];
|
|
1747
|
-
const { iterData, start, end } = unpackLoopResult(loopWith.call(stack.it, seq), seq);
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1747
|
+
const { iterData, start, end, keys } = unpackLoopResult(loopWith.call(stack.it, seq, makeLoopCtx(stack, filter)), seq);
|
|
1748
|
+
const renderOne = (key, value, attrName) => {
|
|
1749
|
+
const dom = this.renderIt(stack.enter(value, { key }, true), node, key, viewName);
|
|
1750
|
+
this.pushEachEntry(r, node.nodeId, attrName, key, dom);
|
|
1751
|
+
};
|
|
1752
|
+
if (keys)
|
|
1753
|
+
imKeysIter(seq, renderOne, keys);
|
|
1754
|
+
else
|
|
1755
|
+
getSeqInfo(seq)(seq, (key, value, attrName) => {
|
|
1756
|
+
if (filter.call(stack.it, key, value, iterData))
|
|
1757
|
+
renderOne(key, value, attrName);
|
|
1758
|
+
}, start, end);
|
|
1754
1759
|
return r;
|
|
1755
1760
|
}
|
|
1756
1761
|
renderEachWhen(stack, iterInfo, view, nid) {
|
|
1757
1762
|
const { seq, filter, loopWith, enricher } = iterInfo.eval(stack);
|
|
1758
1763
|
const r = [];
|
|
1759
1764
|
const it = stack.it;
|
|
1760
|
-
const { iterData, start, end } = unpackLoopResult(loopWith.call(it, seq), seq);
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
this.cache.set(cachePath, cacheKey, dom);
|
|
1775
|
-
}
|
|
1765
|
+
const { iterData, start, end, keys } = unpackLoopResult(loopWith.call(it, seq, makeLoopCtx(stack, filter)), seq);
|
|
1766
|
+
const renderOne = (key, value, attrName) => {
|
|
1767
|
+
const cachePath = enricher ? [view, it, value] : [view, value];
|
|
1768
|
+
const binds = { key, value };
|
|
1769
|
+
const cacheKey = `${nid}-${key}`;
|
|
1770
|
+
if (enricher)
|
|
1771
|
+
enricher.call(it, binds, key, value, iterData);
|
|
1772
|
+
const cachedNode = this.cache.get(cachePath, cacheKey);
|
|
1773
|
+
if (cachedNode)
|
|
1774
|
+
this.pushEachEntry(r, nid, attrName, key, cachedNode);
|
|
1775
|
+
else {
|
|
1776
|
+
const dom = this.renderView(view, stack.enter(value, binds, false));
|
|
1777
|
+
this.pushEachEntry(r, nid, attrName, key, dom);
|
|
1778
|
+
this.cache.set(cachePath, cacheKey, dom);
|
|
1776
1779
|
}
|
|
1777
|
-
}
|
|
1780
|
+
};
|
|
1781
|
+
if (keys)
|
|
1782
|
+
imKeysIter(seq, renderOne, keys);
|
|
1783
|
+
else
|
|
1784
|
+
getSeqInfo(seq)(seq, (key, value, attrName) => {
|
|
1785
|
+
if (filter.call(it, key, value, iterData))
|
|
1786
|
+
renderOne(key, value, attrName);
|
|
1787
|
+
}, start, end);
|
|
1778
1788
|
return r;
|
|
1779
1789
|
}
|
|
1780
1790
|
renderView(view, stack) {
|
|
@@ -1810,8 +1820,17 @@ var filterAlwaysTrue = (_v, _k, _seq) => true;
|
|
|
1810
1820
|
var nullLoopWith = (seq) => ({ iterData: { seq } });
|
|
1811
1821
|
var unpackLoopResult = (result, seq) => {
|
|
1812
1822
|
const r = result ?? {};
|
|
1813
|
-
return { iterData: r.iterData ?? { seq }, start: r.start, end: r.end };
|
|
1823
|
+
return { iterData: r.iterData ?? { seq }, start: r.start, end: r.end, keys: r.keys };
|
|
1824
|
+
};
|
|
1825
|
+
var imKeysIter = (seq, visit, keys) => {
|
|
1826
|
+
const attrName = isIndexed(seq) ? "si" : "sk";
|
|
1827
|
+
for (const key of keys)
|
|
1828
|
+
visit(key, seq.get(key), attrName);
|
|
1814
1829
|
};
|
|
1830
|
+
var makeLoopCtx = (stack, filter) => ({
|
|
1831
|
+
lookup: (name) => stack.lookupBind(name),
|
|
1832
|
+
filter: (key, value, iterData) => filter.call(stack.it, key, value, iterData)
|
|
1833
|
+
});
|
|
1815
1834
|
var imIndexedIter = (seq, visit, start, end) => {
|
|
1816
1835
|
const [s, e] = normalizeRange(start, end, seq.size);
|
|
1817
1836
|
for (let i = s;i < e; i++)
|
|
@@ -2359,11 +2378,11 @@ class IterInfo {
|
|
|
2359
2378
|
return { seq, filter, loopWith, enricher };
|
|
2360
2379
|
}
|
|
2361
2380
|
enrichBinds(stack, key) {
|
|
2362
|
-
const { seq, loopWith, enricher } = this.eval(stack);
|
|
2381
|
+
const { seq, filter, loopWith, enricher } = this.eval(stack);
|
|
2363
2382
|
const value = seq?.get ? seq.get(key, null) : null;
|
|
2364
2383
|
const binds = { key, value };
|
|
2365
2384
|
if (enricher) {
|
|
2366
|
-
const { iterData } = unpackLoopResult(loopWith.call(stack.it, seq), seq);
|
|
2385
|
+
const { iterData } = unpackLoopResult(loopWith.call(stack.it, seq, makeLoopCtx(stack, filter)), seq);
|
|
2367
2386
|
enricher.call(stack.it, binds, key, value, iterData);
|
|
2368
2387
|
}
|
|
2369
2388
|
return binds;
|
|
@@ -3714,6 +3733,13 @@ function phaseOps(phase) {
|
|
|
3714
3733
|
function resolveArgs(args, self) {
|
|
3715
3734
|
return typeof args === "function" ? args(self) ?? [] : args ?? [];
|
|
3716
3735
|
}
|
|
3736
|
+
function phaseHasBubble(phase) {
|
|
3737
|
+
if (!phase)
|
|
3738
|
+
return false;
|
|
3739
|
+
if (phase.bubble?.length)
|
|
3740
|
+
return true;
|
|
3741
|
+
return (phase.do ?? []).some((op) => op.type === "bubble");
|
|
3742
|
+
}
|
|
3717
3743
|
function dispatchPhase(dispatcher, targetPath, phase, self) {
|
|
3718
3744
|
if (!phase)
|
|
3719
3745
|
return;
|
|
@@ -4007,7 +4033,8 @@ class FieldSet extends Field {
|
|
|
4007
4033
|
}
|
|
4008
4034
|
function mkCompField(field, scope, args) {
|
|
4009
4035
|
const Comp = scope?.lookupComponent(field.type) ?? null;
|
|
4010
|
-
|
|
4036
|
+
if (Comp === null)
|
|
4037
|
+
console.warn(scope ? `component field "${field.name}": component "${field.type}" not found in scope` : `component field "${field.name}": cannot resolve component "${field.type}" — built without a registered scope (use ${field.type}.make({}) as the default, or build via a registered component)`);
|
|
4011
4038
|
return Comp?.make({ ...field.args, ...args }, { scope }) ?? null;
|
|
4012
4039
|
}
|
|
4013
4040
|
|
|
@@ -4156,6 +4183,7 @@ export {
|
|
|
4156
4183
|
removeIn,
|
|
4157
4184
|
remove,
|
|
4158
4185
|
phaseOps,
|
|
4186
|
+
phaseHasBubble,
|
|
4159
4187
|
mergeWith,
|
|
4160
4188
|
mergeDeepWith,
|
|
4161
4189
|
mergeDeep,
|