tutuca 0.11.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tutuca-cli.js +130 -104
- package/dist/tutuca-dev.ext.js +121 -90
- package/dist/tutuca-dev.js +121 -90
- package/dist/tutuca-dev.min.js +3 -3
- package/dist/tutuca-extra.ext.js +116 -59
- package/dist/tutuca-extra.js +116 -59
- package/dist/tutuca-extra.min.js +2 -2
- package/dist/tutuca-storybook.js +3 -3
- package/dist/tutuca.ext.js +116 -59
- package/dist/tutuca.js +116 -59
- package/dist/tutuca.min.js +2 -2
- package/package.json +1 -1
- package/skill/margaui/SKILL.md +105 -0
- package/skill/margaui/components/accordion.md +127 -0
- package/skill/margaui/components/alert.md +174 -0
- package/skill/margaui/components/aura.md +97 -0
- package/skill/margaui/components/avatar.md +220 -0
- package/skill/margaui/components/badge.md +193 -0
- package/skill/margaui/components/breadcrumbs.md +103 -0
- package/skill/margaui/components/button.md +322 -0
- package/skill/margaui/components/calendar.md +67 -0
- package/skill/margaui/components/card.md +373 -0
- package/skill/margaui/components/carousel.md +387 -0
- package/skill/margaui/components/chat.md +171 -0
- package/skill/margaui/components/checkbox.md +101 -0
- package/skill/margaui/components/collapse.md +172 -0
- package/skill/margaui/components/countdown.md +165 -0
- package/skill/margaui/components/diff.md +53 -0
- package/skill/margaui/components/divider.md +107 -0
- package/skill/margaui/components/dock.md +173 -0
- package/skill/margaui/components/drawer.md +184 -0
- package/skill/margaui/components/dropdown.md +388 -0
- package/skill/margaui/components/fab.md +346 -0
- package/skill/margaui/components/fieldset.md +88 -0
- package/skill/margaui/components/file-input.md +84 -0
- package/skill/margaui/components/filter.md +52 -0
- package/skill/margaui/components/footer.md +583 -0
- package/skill/margaui/components/hero.md +135 -0
- package/skill/margaui/components/hover-3d.md +129 -0
- package/skill/margaui/components/hover-gallery.md +49 -0
- package/skill/margaui/components/indicator.md +265 -0
- package/skill/margaui/components/input.md +389 -0
- package/skill/margaui/components/join.md +100 -0
- package/skill/margaui/components/kbd.md +127 -0
- package/skill/margaui/components/label.md +102 -0
- package/skill/margaui/components/link.md +96 -0
- package/skill/margaui/components/list.md +182 -0
- package/skill/margaui/components/loading.md +105 -0
- package/skill/margaui/components/mask.md +168 -0
- package/skill/margaui/components/megamenu.md +131 -0
- package/skill/margaui/components/menu.md +887 -0
- package/skill/margaui/components/mockup-browser.md +39 -0
- package/skill/margaui/components/mockup-code.md +81 -0
- package/skill/margaui/components/mockup-phone.md +39 -0
- package/skill/margaui/components/mockup-window.md +33 -0
- package/skill/margaui/components/modal.md +196 -0
- package/skill/margaui/components/navbar.md +282 -0
- package/skill/margaui/components/otp.md +171 -0
- package/skill/margaui/components/pagination.md +122 -0
- package/skill/margaui/components/progress.md +135 -0
- package/skill/margaui/components/radial-progress.md +67 -0
- package/skill/margaui/components/radio.md +133 -0
- package/skill/margaui/components/range.md +134 -0
- package/skill/margaui/components/rating.md +170 -0
- package/skill/margaui/components/select.md +225 -0
- package/skill/margaui/components/skeleton.md +64 -0
- package/skill/margaui/components/stack.md +142 -0
- package/skill/margaui/components/stat.md +254 -0
- package/skill/margaui/components/status.md +73 -0
- package/skill/margaui/components/steps.md +138 -0
- package/skill/margaui/components/swap.md +152 -0
- package/skill/margaui/components/tab.md +248 -0
- package/skill/margaui/components/table.md +1018 -0
- package/skill/margaui/components/text-rotate.md +91 -0
- package/skill/margaui/components/textarea.md +85 -0
- package/skill/margaui/components/theme-controller.md +266 -0
- package/skill/margaui/components/timeline.md +1356 -0
- package/skill/margaui/components/toast.md +165 -0
- package/skill/margaui/components/toggle.md +135 -0
- package/skill/margaui/components/tooltip.md +181 -0
- package/skill/margaui/components/validator.md +163 -0
- package/skill/tutuca/SKILL.md +56 -0
- package/skill/tutuca/advanced.md +212 -0
- package/skill/tutuca/cli.md +239 -0
- package/skill/tutuca/component-design.md +168 -0
- package/skill/tutuca/core.md +918 -0
- package/skill/tutuca/iteration.md +207 -0
- package/skill/tutuca/macros.md +86 -0
- package/skill/tutuca/margaui.md +175 -0
- package/skill/tutuca/messages-and-intents.md +399 -0
- package/skill/tutuca/patterns/README.md +48 -0
- package/skill/tutuca/patterns/add-a-story.md +26 -0
- package/skill/tutuca/patterns/bind-text-and-attributes.md +30 -0
- package/skill/tutuca/patterns/conditional-attribute-value.md +29 -0
- package/skill/tutuca/patterns/coordinate-components.md +54 -0
- package/skill/tutuca/patterns/edit-through-a-dynamic-target.md +27 -0
- package/skill/tutuca/patterns/enrich-each-item.md +25 -0
- package/skill/tutuca/patterns/file-input.md +39 -0
- package/skill/tutuca/patterns/filter-a-list.md +25 -0
- package/skill/tutuca/patterns/filter-and-paginate.md +60 -0
- package/skill/tutuca/patterns/handle-events.md +44 -0
- package/skill/tutuca/patterns/iterate-a-list.md +18 -0
- package/skill/tutuca/patterns/paginate-a-list.md +29 -0
- package/skill/tutuca/patterns/render-a-child-component.md +21 -0
- package/skill/tutuca/patterns/reuse-markup-with-macros.md +36 -0
- package/skill/tutuca/patterns/share-state-across-the-tree.md +38 -0
- package/skill/tutuca/patterns/show-or-hide-content.md +23 -0
- package/skill/tutuca/patterns/switch-between-views.md +30 -0
- package/skill/tutuca/patterns/tabbed-interface.md +43 -0
- package/skill/tutuca/semantics.md +195 -0
- package/skill/tutuca/storybook.md +270 -0
- package/skill/tutuca/styles.md +48 -0
- package/skill/tutuca/testing.md +345 -0
- package/skill/tutuca-source/SKILL.md +33 -0
- package/skill/tutuca-source/tutuca.ext.js +4301 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Read a picked file
|
|
2
|
+
|
|
3
|
+
**Problem:** let the user pick a file and show its metadata.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
component({
|
|
7
|
+
name: "FilePicker",
|
|
8
|
+
fields: { name: "", size: 0, type: "", hasFile: false },
|
|
9
|
+
receive: {
|
|
10
|
+
// e.target is the <input> node; the File is on e.target.files
|
|
11
|
+
onPickFile(draft, target) {
|
|
12
|
+
const file = target.files?.[0];
|
|
13
|
+
draft.hasFile = !!file;
|
|
14
|
+
if (!file) return;
|
|
15
|
+
draft.name = file.name;
|
|
16
|
+
draft.size = file.size;
|
|
17
|
+
draft.type = file.type;
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
view: html`<section>
|
|
21
|
+
<input type="file" @on.change="onPickFile e.target" />
|
|
22
|
+
<p @hide=".hasFile">No file selected yet.</p>
|
|
23
|
+
<dl @show=".hasFile">
|
|
24
|
+
<dt>Name</dt><dd @text=".name"></dd>
|
|
25
|
+
<dt>Size</dt><dd @text=".size"></dd>
|
|
26
|
+
<dt>Type</dt><dd @text=".type"></dd>
|
|
27
|
+
</dl>
|
|
28
|
+
</section>`,
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Pass `event` (not `value`) to the handler: for a file input `value` is just the
|
|
33
|
+
fake `C:\fakepath\…` string, while `event.target.files` holds the real `File`
|
|
34
|
+
objects. The metadata (`name`/`size`/`type`/`lastModified`) is available
|
|
35
|
+
synchronously; the *contents* are not — read those with the async `File` API
|
|
36
|
+
(`file.text()`, `file.arrayBuffer()`) and feed the result back in through a
|
|
37
|
+
an `intent` or a follow-up `send`. Flatten what you need into fields so
|
|
38
|
+
the view can bind each piece; gate the summary on a `hasFile` flag with
|
|
39
|
+
`@show`/`@hide`.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Filter a list
|
|
2
|
+
|
|
3
|
+
**Problem:** render only the items that match a condition.
|
|
4
|
+
|
|
5
|
+
```html
|
|
6
|
+
<li @each=".items" @when="filterItem">
|
|
7
|
+
<span @text="@key"></span>: <x text="@value"></x>
|
|
8
|
+
</li>
|
|
9
|
+
<!-- on <x render-each> the same directive applies: @when="filterItem" -->
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
alter: {
|
|
14
|
+
filterItem(_key, item) {
|
|
15
|
+
return item.toLowerCase().includes(this.query.toLowerCase());
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`@when` names an `alter` handler called per item as `(key, value, iterData)`;
|
|
21
|
+
return `false` to skip. It filters *after* any `@loop-with` slice, so a page
|
|
22
|
+
can yield fewer than its window. Filtering reads other fields off `this`
|
|
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,60 @@
|
|
|
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 e.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
|
+
This is one of three wiring strategies (naive two-scan, shared, coupled
|
|
54
|
+
one-scan) — the trade-offs and the other two are in
|
|
55
|
+
[iteration.md](../iteration.md) *Filter-then-paginate strategies*. Test
|
|
56
|
+
whichever wiring with `collectIterBindings` — pass
|
|
57
|
+
`{ when, loopWith, scopeEnrich }` and assert on the returned keys; see
|
|
58
|
+
[testing.md](../testing.md) *Testing iteration handlers*. See
|
|
59
|
+
[filter-a-list.md](filter-a-list.md) and
|
|
60
|
+
[paginate-a-list.md](paginate-a-list.md) for each half on its own.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Handle events
|
|
2
|
+
|
|
3
|
+
**Problem:** respond to a DOM event and update state.
|
|
4
|
+
|
|
5
|
+
```html
|
|
6
|
+
<button @on.click="inc">+</button>
|
|
7
|
+
<button @on.click="dec">-</button> <!-- bare name = receive handler -->
|
|
8
|
+
|
|
9
|
+
<!-- pass args by name; ctx is auto-appended last -->
|
|
10
|
+
<input @on.input="setStr e.value" />
|
|
11
|
+
<input @on.input="setN e.valueAsInt" />
|
|
12
|
+
<button @on.click="addItem JsonSelector">+</button>
|
|
13
|
+
|
|
14
|
+
<!-- guards: keydown +send (Enter) / +cancel (Esc), and +ctrl/+cmd/+alt -->
|
|
15
|
+
<input @on.keydown+send="submit e.value" @on.keydown+cancel="reset" />
|
|
16
|
+
|
|
17
|
+
<!-- effects on any event: +prevent, +stop (applied only if the guards passed) -->
|
|
18
|
+
<form @on.submit+prevent="save">…</form>
|
|
19
|
+
|
|
20
|
+
<!-- custom elements: any CustomEvent reaches @on.<name>, detail is `e.value` -->
|
|
21
|
+
<emoji-picker @on.emoji-click="onPick e.value"></emoji-picker>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Event handlers are entries in `receive`: Tutuca passes an Immer draft first,
|
|
25
|
+
the written arguments next, and `ctx` last. Returning nothing commits draft
|
|
26
|
+
changes; returning another component swaps the current component. The first
|
|
27
|
+
slot in `@on.*` is always a bare receive name; `$method` is rejected. Later slots always carry a sigil — event-member reads (`e.value`,
|
|
28
|
+
`e.key`, `e.altKey`, `e.target`, dotted paths like `e.target.dataset.slot`
|
|
29
|
+
or `e.detail.x`, null-safe at every link), one-level computed conveniences
|
|
30
|
+
(`e.valueAsInt`, `e.isCtrl` mac-aware, and on drags `e.dragInfo`/
|
|
31
|
+
`e.dragKey`/`e.dragValue`/`e.dragType`), state fields (`.field`), bindings
|
|
32
|
+
(`@bind`), methods (`$m`), dynamics (`*dyn`). A sigil-less word fails to
|
|
33
|
+
parse.
|
|
34
|
+
`e.value` normalizes the read: `.checked` for a checkbox, `detail` for a
|
|
35
|
+
`CustomEvent`, otherwise `target.value`. Bind events declaratively with `@on.`
|
|
36
|
+
rather than reaching for the node and `addEventListener` — an outside listener
|
|
37
|
+
bypasses the transactor.
|
|
38
|
+
|
|
39
|
+
Pass the most granular arg the handler needs — `e.value`/`e.valueAsInt`/`e.key`,
|
|
40
|
+
not the raw event object — so tests call it with plain literals; reach for
|
|
41
|
+
`e.target` only when nothing narrower fits (e.g. a file input reading
|
|
42
|
+
`e.target.files`).
|
|
43
|
+
Why this keeps tests simple: [testing.md](../testing.md) *Designing handlers so
|
|
44
|
+
tests stay simple*.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Iterate a list
|
|
2
|
+
|
|
3
|
+
**Problem:** render one element per item in a list/map field.
|
|
4
|
+
|
|
5
|
+
```html
|
|
6
|
+
<!-- a host element per item: @key and @value are bound in the loop -->
|
|
7
|
+
<li @each=".items"><span @text="@key"></span>: <x text="@value"></x></li>
|
|
8
|
+
|
|
9
|
+
<!-- a child component per item -->
|
|
10
|
+
<x render-each=".items"></x>
|
|
11
|
+
<div @each=".items"><x render-it></x></div>
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`@each` accepts a `.field` or a `*dynamic` (not a `$method` — a method result
|
|
15
|
+
has no addressable path for event dispatch). `@key`/`@value` are auto-bound on
|
|
16
|
+
host-element loops; under `render-each` / `render-it` each item is rendered as
|
|
17
|
+
its own component (no `@value`). Use `render-each` for lists of components,
|
|
18
|
+
`@each` for plain values.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Paginate a list
|
|
2
|
+
|
|
3
|
+
**Problem:** show one page at a time without iterating or rendering the
|
|
4
|
+
off-page items.
|
|
5
|
+
|
|
6
|
+
```html
|
|
7
|
+
<li @each=".items" @loop-with="paginate">
|
|
8
|
+
<span class="badge" @text="@key"></span> <x text="@value"></x>
|
|
9
|
+
</li>
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
fields: { items: [], page: 0, pageSize: 5 },
|
|
14
|
+
alter: {
|
|
15
|
+
paginate(seq) { // runs once per render, before iteration
|
|
16
|
+
const start = this.page * this.pageSize;
|
|
17
|
+
return { iterData: { total: seq.size }, start, end: start + this.pageSize };
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`@loop-with` returns `{ iterData?, start?, end? }`, all optional. `start`/`end`
|
|
23
|
+
slice with `Array.prototype.slice` semantics (`end` exclusive, negatives count
|
|
24
|
+
from the end). Slicing is positional but **preserves each item's original
|
|
25
|
+
key** — `@key` is the index in the full list, so events and two-way binding
|
|
26
|
+
keep their identity across pages. `iterData` is the shared per-loop value
|
|
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).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Render a child component
|
|
2
|
+
|
|
3
|
+
**Problem:** a component holds another component in a field and wants to render
|
|
4
|
+
it (reaching into nested data is not allowed — `@text=".child.name"` fails).
|
|
5
|
+
|
|
6
|
+
```js
|
|
7
|
+
fields: { greeting: Greeting.make({ name: "world" }) },
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```html
|
|
11
|
+
<x render=".greeting"></x> <!-- default ("main") view -->
|
|
12
|
+
<x render=".greeting" as="edit"></x> <!-- a named view -->
|
|
13
|
+
<x render=".greeting" as=".mode"></x> <!-- view chosen by a field at runtime -->
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The child draws its own view from its own fields, so inside `Greeting`'s view
|
|
17
|
+
`@text=".name"` reads the child's `name`. This is the idiomatic way to display
|
|
18
|
+
nested structure: make the nested thing a component and render it, rather than
|
|
19
|
+
trying to path into it. For a list of children use `render-each` (see the
|
|
20
|
+
iterate-a-list recipe); to flip which view renders, see the switch-between-views
|
|
21
|
+
recipe.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Reuse markup with macros
|
|
2
|
+
|
|
3
|
+
**Problem:** the same markup fragment repeats across a view and you want one
|
|
4
|
+
definition — but it has no state of its own.
|
|
5
|
+
|
|
6
|
+
```js
|
|
7
|
+
import { macro, html } from "tutuca";
|
|
8
|
+
|
|
9
|
+
const badge = macro(
|
|
10
|
+
{ label: "'New'", kind: "'info'" }, // defaults are *expressions*
|
|
11
|
+
html`<span :class="$'badge badge-{^kind}'" @text="^label"></span>`,
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const card = macro(
|
|
15
|
+
{ title: "'Card'" },
|
|
16
|
+
html`<div class="card"><h2 @text="^title"></h2><x:slot></x:slot></div>`,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export function getMacros() { return { badge, card }; }
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```html
|
|
23
|
+
<x:badge></x:badge> <!-- defaults -->
|
|
24
|
+
<x:badge label="Sale"></x:badge> <!-- static string (no quotes needed) -->
|
|
25
|
+
<x:badge :label=".status"></x:badge> <!-- bind a field -->
|
|
26
|
+
<x:card title="Hi"><p>body</p></x:card> <!-- children fill <x:slot> -->
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
A macro is pure template expansion — no fields, no handlers. Parameters are
|
|
30
|
+
read as `^name`; calls inside the body (`$method`, `.field`) resolve against
|
|
31
|
+
the *host* component. `<x:slot>` (or `<x:slot name="…">` for named slots)
|
|
32
|
+
receives the caller's children. Register with `scope.registerMacros(...)`;
|
|
33
|
+
registry keys are lowercased (`<x:Card>` → `card`). Full semantics (named
|
|
34
|
+
slots, quoting of parameter values) in [macros.md](../macros.md). For repeated
|
|
35
|
+
markup that *does* need state, use a child component instead (see the
|
|
36
|
+
render-a-child-component recipe).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Share state across the tree
|
|
2
|
+
|
|
3
|
+
**Problem:** a deep descendant needs a value owned by a distant ancestor, and
|
|
4
|
+
you don't want to thread it through every component in between.
|
|
5
|
+
|
|
6
|
+
> **Reach for this last.** Keep state local to the component and use
|
|
7
|
+
> `provide` / `lookup` only when it is genuinely the only solution — a value
|
|
8
|
+
> owned far away that a deep descendant needs and nothing in between should
|
|
9
|
+
> know about. Dynamic bindings couple a consumer to a producer that may not be
|
|
10
|
+
> in scope, so keep components as self-contained as possible: let a child
|
|
11
|
+
> render the field it needs from its owner, and lift state only as far up the
|
|
12
|
+
> tree as it needs to live.
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
// producer — exposes one of its fields under a name
|
|
16
|
+
const Producer = component({
|
|
17
|
+
name: "EntryEditorAndSelector",
|
|
18
|
+
fields: { items: [] },
|
|
19
|
+
provide: { entries: ".items" },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// consumer — forwards to the producer's binding by "Component.name"
|
|
23
|
+
const Consumer = component({
|
|
24
|
+
name: "Selector",
|
|
25
|
+
lookup: { entries: { for: "EntryEditorAndSelector.entries", default: ".items" } },
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```html
|
|
30
|
+
<!-- read the dynamic with the * prefix — iterate or render it -->
|
|
31
|
+
<option @each="*entries" :value="@value" @text="@label"></option>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`provide` publishes a field under a name; a descendant's `lookup` resolves
|
|
35
|
+
`*name` to the nearest matching producer, falling back to `default` when none
|
|
36
|
+
is in scope. `*name` works wherever a `.field` does for iteration/rendering.
|
|
37
|
+
This is the **read** side; to edit the producer's value through the dynamic,
|
|
38
|
+
see the edit-through-a-dynamic-target recipe.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Show or hide content
|
|
2
|
+
|
|
3
|
+
**Problem:** render an element only when a condition holds.
|
|
4
|
+
|
|
5
|
+
```html
|
|
6
|
+
<div @show=".isOpen">Details</div>
|
|
7
|
+
<p @hide=".isOpen">(hidden when open)</p>
|
|
8
|
+
|
|
9
|
+
<!-- boolean predicates for one-field checks -->
|
|
10
|
+
<p @show="empty? .items">No results</p>
|
|
11
|
+
<p @show="truthy? .query">Searching…</p>
|
|
12
|
+
<div @show="equals? .view 'detail'">detail view</div>
|
|
13
|
+
|
|
14
|
+
<!-- on an <x> render op: wraps the produced node, no extra DOM element -->
|
|
15
|
+
<x text=".count" @show=".isOpen"></x>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The closed set of predicates is `empty?`, `truthy?`, `falsy?`, `null?`,
|
|
19
|
+
`equals?` (binary) — semantics in [core.md](../core.md) *Conditional Display*.
|
|
20
|
+
For a condition spanning multiple fields, use a no-arg method instead
|
|
21
|
+
(`@show="$canSubmit"`). `@show`/`@hide` toggle visibility on a host element;
|
|
22
|
+
the wrapper form (`show=` / `hide=` on `<x>`) conditionally emits the node
|
|
23
|
+
with no surrounding element.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Switch between views
|
|
2
|
+
|
|
3
|
+
**Problem:** render the *same* component in a different view (e.g. a read-only
|
|
4
|
+
"main" vs an "edit" form).
|
|
5
|
+
|
|
6
|
+
```js
|
|
7
|
+
component({
|
|
8
|
+
view: html`<p @text=".title"></p>`, // "main"
|
|
9
|
+
views: { edit: html`<input :value=".title" @on.input="setTitle e.value" />` },
|
|
10
|
+
});
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```html
|
|
14
|
+
<!-- as= picks the view for one <x render> element only -->
|
|
15
|
+
<x render=".value"></x>
|
|
16
|
+
<x render=".value" as="edit"></x>
|
|
17
|
+
<x render=".value" as=".mode"></x> <!-- view chosen by a field at runtime -->
|
|
18
|
+
|
|
19
|
+
<!-- @push-view forces a view on every component rendered under the host -->
|
|
20
|
+
<div @push-view=".view"><x render-each=".items"></x></div>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`as` applies to the direct component only and falls back to `main` if the view
|
|
24
|
+
is absent. It takes the same value forms as `@push-view` — a literal name
|
|
25
|
+
(`as="edit"`) or a dynamic value (`as=".mode"`, `*dyn`, `@bind`, `$method`,
|
|
26
|
+
`$'…'`), evaluated against the host component at render time (for `render-each`,
|
|
27
|
+
once for all items). `@push-view` instead pushes a view name onto the render
|
|
28
|
+
stack so every descendant picks the first matching view (else `main`) — use it
|
|
29
|
+
to flip a whole subtree (e.g. a list) into edit mode at once. To toggle
|
|
30
|
+
*sibling panels* by a field instead, see the tabbed-interface recipe.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Tabbed interface
|
|
2
|
+
|
|
3
|
+
**Problem:** build tabs — a single `currentView` field decides which panel
|
|
4
|
+
shows, and the active tab button is highlighted.
|
|
5
|
+
|
|
6
|
+
```html
|
|
7
|
+
<div role="tablist" class="tabs">
|
|
8
|
+
<button
|
|
9
|
+
role="tab"
|
|
10
|
+
@if.class="equals? .currentView 'overview'"
|
|
11
|
+
@then="'tab tab-active'"
|
|
12
|
+
@else="'tab'"
|
|
13
|
+
@on.click="selectView 'overview'"
|
|
14
|
+
>Overview</button>
|
|
15
|
+
<button
|
|
16
|
+
role="tab"
|
|
17
|
+
@if.class="equals? .currentView 'pricing'"
|
|
18
|
+
@then="'tab tab-active'"
|
|
19
|
+
@else="'tab'"
|
|
20
|
+
@on.click="selectView 'pricing'"
|
|
21
|
+
>Pricing</button>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div @show="equals? .currentView 'overview'">…overview…</div>
|
|
25
|
+
<div @show="equals? .currentView 'pricing'">…pricing…</div>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
fields: { currentView: "overview" },
|
|
30
|
+
receive: {
|
|
31
|
+
selectView(draft, view) {
|
|
32
|
+
draft.currentView = view;
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
One string field is the whole state machine. `equals? .currentView 'overview'`
|
|
38
|
+
drives both the panel's `@show` and the active-tab class via `@if.class` /
|
|
39
|
+
`@then` / `@else`. Tab clicks call the draft recipe with a string-literal
|
|
40
|
+
argument (`@on.click="selectView 'pricing'"`). This toggles
|
|
41
|
+
**sibling panels** by predicate; to swap a *component's own* rendered view
|
|
42
|
+
instead, see the switch-between-views recipe. The field name is yours to pick
|
|
43
|
+
(`tab`, `currentView`, …).
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Tutuca — Runtime Semantics (paths · transactions · dispatch)
|
|
2
|
+
|
|
3
|
+
How a click becomes a state mutation, and what survives across async.
|
|
4
|
+
Read this when reasoning about **why** a handler ran where it did,
|
|
5
|
+
debugging a dispatch or async-timing bug, or changing `src/path.js` /
|
|
6
|
+
`src/transactor.js`. Not needed for ordinary component authoring — for
|
|
7
|
+
that start at [core.md](./core.md).
|
|
8
|
+
|
|
9
|
+
The step and transaction names below are the ones in the source; confirm
|
|
10
|
+
behavior against `src/path.js` / `src/transactor.js` (or grep the
|
|
11
|
+
`tutuca-source` skill) rather than trusting this doc when they disagree.
|
|
12
|
+
|
|
13
|
+
## State & identity (in one paragraph)
|
|
14
|
+
|
|
15
|
+
The application is a single immutable root value; the view is a pure
|
|
16
|
+
function of it; every handler takes the old self and returns a new self,
|
|
17
|
+
and the transactor swaps the root atomically. Updating a deep child
|
|
18
|
+
produces a new root that shares structure with the old one along the
|
|
19
|
+
unchanged spine, so the renderer's `===`-keyed cache skips untouched
|
|
20
|
+
subtrees. Full version: *Mental model* in [core.md](./core.md).
|
|
21
|
+
|
|
22
|
+
## Paths are positional addresses
|
|
23
|
+
|
|
24
|
+
A `Path` is an array of `Step`s from the root to the value a handler runs
|
|
25
|
+
against — a **position**, not a captured reference (see *Paths, not
|
|
26
|
+
references* in [core.md](./core.md)). The step kinds:
|
|
27
|
+
|
|
28
|
+
| Step | Addresses | Source syntax |
|
|
29
|
+
| ------------------- | ---------------------------------- | ------------------------ |
|
|
30
|
+
| `FieldStep` | a named field | `.field` |
|
|
31
|
+
| `SeqStep` | a sequence entry by **literal** key/index | `.items[2]` |
|
|
32
|
+
| `SeqAccessStep` | a sequence entry whose key is **read from another field** | `.sheets[.selId]` |
|
|
33
|
+
| `EachRenderItStep` | an iterated `render-it` item | `<x render-it>` per iter |
|
|
34
|
+
| `DynStep` / `DynEachStep` | a dynamic-var (`*x`) render target — a teleport marker | `<x render="*x">` |
|
|
35
|
+
| `BindStep` / `EachBindStep` | nothing — frame-only (carry scope binds, no addressing) | `@each`, `@enrich-with` |
|
|
36
|
+
|
|
37
|
+
`SeqAccessStep` is the important one for async correctness: it stores the
|
|
38
|
+
field *names* `seqField` and `keyField`, and resolves the key from the
|
|
39
|
+
live data each time it runs — see *Key resolution & async races* below.
|
|
40
|
+
|
|
41
|
+
### Two derived paths
|
|
42
|
+
|
|
43
|
+
The reconstructed path is transformed two ways depending on use:
|
|
44
|
+
|
|
45
|
+
- **`compact()` → the dispatch path.** Drops frame-only steps, keeps one
|
|
46
|
+
step per crossed component (including `DynStep`s). `popStep()` over it
|
|
47
|
+
walks every component. Used to drive `ctx.send` / `ctx.intent` and to
|
|
48
|
+
locate handlers.
|
|
49
|
+
- **`toTransactionPath()` → the transaction path.** Teleports every
|
|
50
|
+
`DynStep` (drops the steps interior to its producer..consumer span and
|
|
51
|
+
splices in the producer's own steps) so a mutation lands on the data's
|
|
52
|
+
real location. A path with no `DynStep` is returned unchanged. Used by
|
|
53
|
+
`lookup` / `setValue` to read and write state.
|
|
54
|
+
|
|
55
|
+
## Reconstructing a path from the DOM
|
|
56
|
+
|
|
57
|
+
The DOM is the only thing that survives between render and click, so the
|
|
58
|
+
renderer leaves breadcrumbs: `data-cid` / `data-nid` / `data-eid` on
|
|
59
|
+
elements, and `§…§` comment "metas" adjacent to component boundaries,
|
|
60
|
+
iteration entries, and scope boundaries (loop-less `@enrich-with`, so
|
|
61
|
+
their custom binds can be replayed). On an event, `Path.fromNodeAndEventName` walks from the
|
|
62
|
+
target up to the root, reads the breadcrumbs, and rebuilds the path. Along
|
|
63
|
+
the way it resolves the handler: normally on the **leaf** component, but
|
|
64
|
+
for DOM-bubbling events it can resolve on an
|
|
65
|
+
**ancestor**, in which case the descending steps below that ancestor are
|
|
66
|
+
dropped so the path resolves to the ancestor's value.
|
|
67
|
+
|
|
68
|
+
## The transaction lifecycle
|
|
69
|
+
|
|
70
|
+
Each dispatch is a `Transaction`. The `Transactor` holds a FIFO queue;
|
|
71
|
+
`App` drains it in time-budgeted batches on a `setTimeout(…, 0)` (see
|
|
72
|
+
`src/app.js`), so transactions complete **asynchronously and interleaved**
|
|
73
|
+
— which is exactly why an intent's answer can land after other
|
|
74
|
+
transactions have rebuilt the root.
|
|
75
|
+
|
|
76
|
+
The core of applying one is `Transaction.updateRootValue`:
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
const txnPath = this.getTransactionPath(); // toTransactionPath(), or a pinned path
|
|
80
|
+
const curLeaf = txnPath.lookup(curRoot); // read the addressed value NOW
|
|
81
|
+
const newLeaf = this.callHandler(curRoot, curLeaf, comps); // old self → new self
|
|
82
|
+
return curLeaf !== newLeaf ? txnPath.setValue(curRoot, newLeaf) : curRoot;
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The root swap is atomic and identity-cheap: unchanged subtrees keep their
|
|
86
|
+
references, so re-render is incremental. Per-dispatch completion is tracked
|
|
87
|
+
by `Completion` (counter-based): `whenSettled()` resolves once a
|
|
88
|
+
transaction's own work finishes, `whenSubtreeSettled()` once the subtree it
|
|
89
|
+
spawned (requests, follow-on sends) settles too.
|
|
90
|
+
|
|
91
|
+
## Dispatch channels, semantically
|
|
92
|
+
|
|
93
|
+
The authoring API (`ctx.send` / `ctx.intent`, the handler blocks) is in
|
|
94
|
+
[messages-and-intents.md](./messages-and-intents.md). Underneath there are two
|
|
95
|
+
channels, and one question separates them: **does the sender know who handles
|
|
96
|
+
this?**
|
|
97
|
+
|
|
98
|
+
| Channel | Transaction | Notes |
|
|
99
|
+
| --- | --- | --- |
|
|
100
|
+
| DOM event → `receive` | `InputEvent` | transacted **synchronously** (`transactInputNow`), not queued; resolves its handler from the compiled view |
|
|
101
|
+
| `ctx.send` → `receive` | `SendEvent` | queued; addressed at one component and stops there |
|
|
102
|
+
| an intent's answer → `receive` | `SendEvent` | queued; named `<name>Ok` / `<name>Error` / `<name>Unhandled`, and indistinguishable from any other message |
|
|
103
|
+
| `ctx.intent` → `intent` | `IntentEvent` per `dyn` hop; no transaction for a `lex` hop | queued; the walk state lives in `IntentWalk`, shared **by reference** across hops |
|
|
104
|
+
|
|
105
|
+
The `dyn` leg is walking up the dispatch path one `popStep` at a time, starting
|
|
106
|
+
at the sender's **parent**. `targetPath` (the originator's path) stays fixed as
|
|
107
|
+
`path` shortens, so a hop can reach the originator via
|
|
108
|
+
`ctx.sendAtPath(ctx.targetPath, …)`.
|
|
109
|
+
|
|
110
|
+
The walk object is shared across hops on purpose: that is what makes the
|
|
111
|
+
one-shot **per intent** rather than per hop, so a second `ctx.reply` — from this
|
|
112
|
+
handler or one three hops up — finds the walk already ended.
|
|
113
|
+
|
|
114
|
+
## Dynamic-var teleporting
|
|
115
|
+
|
|
116
|
+
A component rendered through `<x render="*sel">` *physically lives* at the
|
|
117
|
+
producer that declared `provide: { sel: … }`, not under the consumer that
|
|
118
|
+
wrote the render. The reconstructed dispatch path keeps every intermediate
|
|
119
|
+
component (so a walk visits them), but `toTransactionPath()` teleports
|
|
120
|
+
the `DynStep`: it pops the steps tagged with the marker's `interiorCids`
|
|
121
|
+
and splices in the producer's own steps (`DynStep.teleportSteps()`). The
|
|
122
|
+
mutation therefore lands on the producer's data, and the consumer's view
|
|
123
|
+
of it updates in lock-step. Authoring view: *Teleporting* in
|
|
124
|
+
[advanced.md](./advanced.md).
|
|
125
|
+
|
|
126
|
+
When the producer's `provide` value is a seq-access (`.sheets[.selId]`),
|
|
127
|
+
the teleported steps include a `SeqAccessStep` — which is where async key
|
|
128
|
+
races come from.
|
|
129
|
+
|
|
130
|
+
## Key resolution & async races
|
|
131
|
+
|
|
132
|
+
A `SeqAccessStep` resolves `keyField` from the live root **every time it
|
|
133
|
+
runs**. For synchronous dispatch this is invisible — the key cannot change
|
|
134
|
+
mid-transaction. For an async intent it is the whole problem: between
|
|
135
|
+
raising the intent and applying its answer, the key may move (e.g. the
|
|
136
|
+
user switches the selected tab, so `.selId` changes), and a naive
|
|
137
|
+
re-resolution would deliver the answer to **whatever item is selected
|
|
138
|
+
now**, not the one that raised the intent.
|
|
139
|
+
|
|
140
|
+
**Key pinning is the default.** `pushIntent` snapshots the resolved key
|
|
141
|
+
at dispatch time by running `Path.pinKeys(curRoot)` over the transaction
|
|
142
|
+
path — each `SeqAccessStep(seq, keyField)` becomes a literal
|
|
143
|
+
`SeqStep(seq, resolvedKey)`. The pinned path is stored on the
|
|
144
|
+
`IntentWalk`, so the answer updates the item that raised the intent
|
|
145
|
+
regardless of later key changes. (Pinning runs on the transaction path,
|
|
146
|
+
after teleporting, because the `SeqAccessStep` may have come from a
|
|
147
|
+
`DynStep`.)
|
|
148
|
+
|
|
149
|
+
**Opt out per intent with `livePath: true`:**
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
ctx.intent("save", [payload], { route: ["lex"], livePath: true }); // re-resolve live
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
With `livePath`, the answer re-evaluates the key at apply time — the old
|
|
156
|
+
"follow the latest selection" behavior. Use it only when the answer is
|
|
157
|
+
*meant* to follow wherever the key now points.
|
|
158
|
+
|
|
159
|
+
Edge cases:
|
|
160
|
+
|
|
161
|
+
- **Pinned target deleted before the answer arrives** — the pinned
|
|
162
|
+
`SeqStep` resolves to nothing, the handler runs against a null leaf, and
|
|
163
|
+
the result equals the input → a safe no-op (root unchanged). With
|
|
164
|
+
`livePath` it would instead hit the current item.
|
|
165
|
+
- **The `EventContext` path stays live (un-pinned).** An answer arm that
|
|
166
|
+
itself re-dispatches via `ctx.send` / `ctx.intent` re-resolves
|
|
167
|
+
against current state — pinning covers the *update*, not nested
|
|
168
|
+
re-dispatch.
|
|
169
|
+
|
|
170
|
+
## What "positional delivery" guarantees
|
|
171
|
+
|
|
172
|
+
Because a path is a position, an async response survives intervening
|
|
173
|
+
transactions that rebuild the root — but "the right slot" means different
|
|
174
|
+
things per step kind:
|
|
175
|
+
|
|
176
|
+
- **`SeqAccessStep` (`.seq[.key]`)** — the key is **pinned by default**, so
|
|
177
|
+
the response reaches the entry that issued the request even if the key
|
|
178
|
+
field moved. Opt out with `livePath: true`.
|
|
179
|
+
- **`SeqStep` with a list index (`.items[3]`)** — the index is literal and
|
|
180
|
+
**not** pinned to identity: if the list re-sorted or an item was inserted
|
|
181
|
+
ahead of it, index 3 is now a different item and the response lands
|
|
182
|
+
there. Anchor on **map keys**, not list indices, when an async result
|
|
183
|
+
must reach a specific item.
|
|
184
|
+
- **`FieldStep`** — a named field is stable; no ambiguity.
|
|
185
|
+
|
|
186
|
+
## See also
|
|
187
|
+
|
|
188
|
+
- [core.md](./core.md) — *Mental model* and *Paths, not references* (the
|
|
189
|
+
high-level invariants this file expands on), `view` directives, handler
|
|
190
|
+
blocks.
|
|
191
|
+
- [messages-and-intents.md](./messages-and-intents.md) — the dispatch **API**:
|
|
192
|
+
addressed `send`-`receive` vs routed `intent`, `ctx.at`, `$unknown`,
|
|
193
|
+
request-handler registration, and the `livePath` request option.
|
|
194
|
+
- [advanced.md](./advanced.md) — dynamic bindings (`*x`) and the authoring
|
|
195
|
+
view of teleporting.
|