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,399 @@
|
|
|
1
|
+
# Tutuca — Messages & Intents
|
|
2
|
+
|
|
3
|
+
The two dispatch channels: **messages** (`ctx.send` / `ctx.at.….send` /
|
|
4
|
+
`app.sendAtRoot`, and a view's own `@on.*` → a `receive` handler on one
|
|
5
|
+
addressed component) and **intents** (`ctx.intent` → a walk along a
|
|
6
|
+
*route* until something answers). Read this file when writing `receive`
|
|
7
|
+
/ `intent` handlers, calling `ctx.send` / `ctx.intent` / `ctx.reply` /
|
|
8
|
+
`ctx.fail` / `ctx.forward` / `ctx.stop`, or registering intent handlers
|
|
9
|
+
with `registerIntentHandlers`. General authoring lives in
|
|
10
|
+
[core.md](./core.md); testing these handlers is in
|
|
11
|
+
[testing.md](./testing.md).
|
|
12
|
+
|
|
13
|
+
## The two channels
|
|
14
|
+
|
|
15
|
+
One question separates them: **does the sender know who handles this?**
|
|
16
|
+
|
|
17
|
+
- If yes, it **sends a message**. It goes to one component and stops.
|
|
18
|
+
- If no, it **raises an intent**. It walks a route until something answers.
|
|
19
|
+
|
|
20
|
+
| Triggered by | handler bucket |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| DOM event (`@on.click`, `@on.input`, …) | `receive` |
|
|
23
|
+
| `ctx.send(name, args)` / `ctx.at.….send(…)` | `receive` |
|
|
24
|
+
| `app.sendAtRoot(name, args)` | `receive` |
|
|
25
|
+
| an answer to an intent this component raised | `receive` |
|
|
26
|
+
| `ctx.intent(name, args, opts)` — walks a route | `intent` |
|
|
27
|
+
|
|
28
|
+
The first four rows are one bucket, and there is **no way to tell them
|
|
29
|
+
apart**. That is deliberate: a component that answered its own click
|
|
30
|
+
differently from the identical `ctx.send` from its parent could be driven
|
|
31
|
+
neither from a test nor from a parent.
|
|
32
|
+
|
|
33
|
+
Every dispatched handler is called as `handler(draft, ...args, ctx)`. Mutate
|
|
34
|
+
`draft` and return nothing to commit; `this` remains the immutable current
|
|
35
|
+
instance. Returning another value swaps it into the dispatch path. `ctx` (an
|
|
36
|
+
`EventContext`) is always the trailing argument.
|
|
37
|
+
|
|
38
|
+
`alter` is a third block, but it isn't dispatched — the renderer invokes alter
|
|
39
|
+
handlers to produce binds, not to update state. See *Mental model* in
|
|
40
|
+
[core.md](./core.md) and *Scope Enrichment* in [iteration.md](./iteration.md).
|
|
41
|
+
|
|
42
|
+
## Messages — `ctx.send`, `receive`
|
|
43
|
+
|
|
44
|
+
`ctx.send(name, args)` delivers a message to the **current** component;
|
|
45
|
+
`ctx.at.<place>.send(name, args)` delivers it to an addressed one. The
|
|
46
|
+
target's `receive.<name>(...args, ctx)` handler runs. There is **no built-in
|
|
47
|
+
lifecycle** — `receive.init` is just a convention; the host must dispatch it
|
|
48
|
+
(typically after `app.start()`) for it to run.
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
receive: {
|
|
52
|
+
init(_draft, ctx) { ctx.at.field("status").send("flash", ["Ready"]); },
|
|
53
|
+
flash(draft, text) { draft.text = text; },
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Dispatch from anywhere:
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
app.sendAtRoot("init"); // host code, top-level
|
|
61
|
+
ctx.at.field("personalSite").send("init"); // child by field name
|
|
62
|
+
ctx.at.index("items", 3).send("startEditing"); // list element at index 3
|
|
63
|
+
ctx.at.key("byKey", "k1").send("ping"); // map entry by key
|
|
64
|
+
ctx.at.field("a").field("b").index("xs", 0).send("ping"); // chain freely
|
|
65
|
+
ctx.send("loadData"); // self
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`ctx.at` returns a `PathBuilder` with `.field(name)`, `.index(name, i)`, and
|
|
69
|
+
`.key(name, k)`. Each call appends a step before `.send(...)` / `.intent(...)`
|
|
70
|
+
fires; the handler runs inside the child instance with `this` bound to it.
|
|
71
|
+
Paths are positional, not references — see *Positional delivery* below.
|
|
72
|
+
|
|
73
|
+
**When to send.** Send when *one specific component* must be told something: a
|
|
74
|
+
form telling its email field to focus after a failed submit, a list telling
|
|
75
|
+
item 3 to enter edit mode, a "Reload" button reusing the `receive.loadData`
|
|
76
|
+
body that `receive.init` also calls. Don't `send` to self when a direct method
|
|
77
|
+
call would do — and don't send when you don't know who should answer. That is
|
|
78
|
+
what an intent is for.
|
|
79
|
+
|
|
80
|
+
## Intents — routes and legs
|
|
81
|
+
|
|
82
|
+
`ctx.intent(name, args, opts)` raises a job the sender does not address. The
|
|
83
|
+
runtime walks a **route**, offering the intent to each hop in turn.
|
|
84
|
+
|
|
85
|
+
A route is a list of **legs**, and there are two:
|
|
86
|
+
|
|
87
|
+
| leg | walks |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| `"dyn"` | the **dispatch path** — the sender's *parent*, then its parent, up to the root |
|
|
90
|
+
| `"lex"` | the **registration scope chain** — the handlers `registerIntentHandlers` put on the scope, then the scopes above it |
|
|
91
|
+
|
|
92
|
+
With no `opts.route`, an intent takes the default route `["dyn", "lex"]`: try
|
|
93
|
+
the ancestors, then the registered handlers. That default is written down in
|
|
94
|
+
exactly one place (`DEFAULT_ROUTE` in `src/transactor.js`), so "what does a
|
|
95
|
+
bare `ctx.intent` do" has one answer and no second copy.
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
receive: {
|
|
99
|
+
go(ctx) {
|
|
100
|
+
ctx.intent("saveDraft", [this.name]); // dyn, then lex
|
|
101
|
+
ctx.intent("picked", [this.page], { route: ["dyn"] }); // ancestors only
|
|
102
|
+
ctx.intent("loadRows", [], { route: ["lex"] }); // the scope only
|
|
103
|
+
ctx.intent("saveDraft", [this.name], { route: ["lex", "dyn"] }); // in the order written
|
|
104
|
+
return this;
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The `dyn` leg starts at the sender's **parent**, not at the sender: an intent
|
|
110
|
+
is never offered to the component that raised it. (One that wanted to handle it
|
|
111
|
+
itself would have written the body inline.)
|
|
112
|
+
|
|
113
|
+
Walks are depth-bounded — 64 hops, after which the runtime ends the walk as an
|
|
114
|
+
exhaustion rather than looping, so the sender still hears something.
|
|
115
|
+
|
|
116
|
+
## Answering an intent
|
|
117
|
+
|
|
118
|
+
A component answers with an `intent.<name>` handler. Inside it:
|
|
119
|
+
|
|
120
|
+
- `ctx.reply(value)` — answer with a result. **Ends the walk.**
|
|
121
|
+
- `ctx.fail(error)` — answer with an error. **Ends the walk.**
|
|
122
|
+
- `ctx.forward(opts)` — hand the intent to the next hop (see below).
|
|
123
|
+
- `ctx.stop()` — end the walk **answering nothing**.
|
|
124
|
+
- ...or none of the above: the body runs, returns new state, and the walk goes
|
|
125
|
+
on. A handler that does not reply is an **observer**.
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
intent: {
|
|
129
|
+
// Answered where it arrives.
|
|
130
|
+
saveDraft(draft, text, ctx) { draft.count++; ctx.reply(draft.count); },
|
|
131
|
+
// An observer: it records the intent and lets it keep walking.
|
|
132
|
+
picked(draft, k) { draft.page = k; },
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The one rule to hold on to: **a reply ends the walk; running does not.** An
|
|
137
|
+
observer and an answerer are the same construct with and without a `reply`,
|
|
138
|
+
which is why no separate "listener" bucket exists.
|
|
139
|
+
|
|
140
|
+
Two consequences worth knowing:
|
|
141
|
+
|
|
142
|
+
- The one-shot is **per intent, across hops** — not per body. A body may call
|
|
143
|
+
`ctx.reply` twice; the first wins and the second is refused.
|
|
144
|
+
- A hop whose handler throws never completes its transition, so it did not
|
|
145
|
+
answer, and the walk continues past it as if it had declined.
|
|
146
|
+
|
|
147
|
+
## The three outcomes
|
|
148
|
+
|
|
149
|
+
An intent has exactly three ends, each with its own name and its own payload
|
|
150
|
+
shape:
|
|
151
|
+
|
|
152
|
+
| outcome | dispatched name | payload |
|
|
153
|
+
| --- | --- | --- |
|
|
154
|
+
| a hop replied | `<name>Ok` | the replied value |
|
|
155
|
+
| a hop failed | `<name>Error` | the error value |
|
|
156
|
+
| the route ran out | `<name>Unhandled` | the intent's own arguments |
|
|
157
|
+
|
|
158
|
+
They arrive back at the sender as **ordinary messages, in the `receive`
|
|
159
|
+
bucket**. A handler cannot tell an answer from a message a parent sent, and
|
|
160
|
+
does not need to.
|
|
161
|
+
|
|
162
|
+
**Declaring the arms is what makes an intent a request rather than a
|
|
163
|
+
notification** — a sender expects an answer if and only if it declares one.
|
|
164
|
+
Nobody writes that down twice: at the moment a walk ends, the runtime looks the
|
|
165
|
+
derived names up in the sender's own `receive` bucket.
|
|
166
|
+
|
|
167
|
+
```js
|
|
168
|
+
receive: {
|
|
169
|
+
init(draft, ctx) { draft.isLoading = true; ctx.intent("loadData", [], { route: ["lex"] }); },
|
|
170
|
+
|
|
171
|
+
// The three ANSWERS. Declaring them is what wires `loadData` up.
|
|
172
|
+
loadDataOk(draft, res) { draft.isLoading = false; draft.items = res; },
|
|
173
|
+
loadDataError(draft, err) { draft.isLoading = false; draft.error = String(err); },
|
|
174
|
+
loadDataUnhandled(draft) { draft.isLoading = false; draft.error = "nothing answers loadData"; },
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
There is no arm that can be handed both a result and an error, so none can read
|
|
179
|
+
the wrong one. (The old combined `(res, err)` shape is gone, and so is the bug
|
|
180
|
+
it caused.)
|
|
181
|
+
|
|
182
|
+
`<name>Unhandled` is what a route running out means, and it carries **the
|
|
183
|
+
intent's own arguments** so the sender can degrade or retry without keeping a
|
|
184
|
+
copy. What the sender hears when a route runs out depends only on what it
|
|
185
|
+
declares:
|
|
186
|
+
|
|
187
|
+
1. it declares `<name>Unhandled` → that, with the intent's own args;
|
|
188
|
+
2. else it declares `<name>Error` → that, with `"noHandler"`;
|
|
189
|
+
3. else it declares only `<name>Ok` → a console warning, because an answer was
|
|
190
|
+
expected and none came; an answer never disappears in silence;
|
|
191
|
+
4. else nothing at all → silence. That is a **notification**, and it is the
|
|
192
|
+
idiomatic fire-and-forget shape.
|
|
193
|
+
|
|
194
|
+
A handler that must answer has no way to say "not mine" — it can only invent an
|
|
195
|
+
error — which is why declining is a separate answer from failing. "Nothing
|
|
196
|
+
claimed it" and "a handler refused it" are different sentences, so they have
|
|
197
|
+
different names.
|
|
198
|
+
|
|
199
|
+
## `ctx.forward` — one word, two sides
|
|
200
|
+
|
|
201
|
+
`ctx.forward(opts)` is the same word from both ends of a walk, and which one you
|
|
202
|
+
get depends on which bucket you are in:
|
|
203
|
+
|
|
204
|
+
- **In an `intent` handler** it *amends the hop*: the walk goes on, optionally
|
|
205
|
+
with new `args` or a narrowed `route`. It does not push a hop itself — a walk
|
|
206
|
+
advances on its own.
|
|
207
|
+
- **In a `receive` handler** it *starts a walk*: the message that arrived
|
|
208
|
+
becomes an intent, keeping its name and payload.
|
|
209
|
+
|
|
210
|
+
```js
|
|
211
|
+
receive: {
|
|
212
|
+
saveDraft(_draft, text, ctx) { ctx.forward(); }, // default route
|
|
213
|
+
picked(_draft, k, ctx) { ctx.forward({ route: ["dyn"] }); }, // ancestors only
|
|
214
|
+
logThenPass(draft, t, ctx) { draft.count++; ctx.forward(); },
|
|
215
|
+
},
|
|
216
|
+
intent: {
|
|
217
|
+
picked(draft, k, ctx) { draft.page = k; ctx.forward({ args: [k, "seen"] }); },
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
This is what lets a view's name **leave** the component. A view says
|
|
222
|
+
`@on.click="saveDraft .text"` — what the user asked for, not who answers it.
|
|
223
|
+
The component may answer it today and an ancestor may answer it tomorrow, and
|
|
224
|
+
the view never changes.
|
|
225
|
+
|
|
226
|
+
## Registering intent handlers — the `lex` leg
|
|
227
|
+
|
|
228
|
+
The `lex` leg walks handlers registered on the **scope**, not on components.
|
|
229
|
+
They are plain functions — usually `async` — registered as a **list per name**,
|
|
230
|
+
because the leg walks: a declining handler hands the intent to the next one.
|
|
231
|
+
|
|
232
|
+
| the handler | meaning |
|
|
233
|
+
| --- | --- |
|
|
234
|
+
| resolves a value | answered; the sender hears `<name>Ok` |
|
|
235
|
+
| throws / rejects | failed; the sender hears `<name>Error` |
|
|
236
|
+
| returns `PASS` | **declines**; the walk goes on to the next hop |
|
|
237
|
+
|
|
238
|
+
```js
|
|
239
|
+
import { PASS } from "tutuca";
|
|
240
|
+
|
|
241
|
+
export function getIntentHandlers() {
|
|
242
|
+
return {
|
|
243
|
+
async loadData() {
|
|
244
|
+
const r = await fetch("https://example.com/data.json");
|
|
245
|
+
return await r.json();
|
|
246
|
+
},
|
|
247
|
+
// A list when more than one handler can answer a name.
|
|
248
|
+
persistState: [
|
|
249
|
+
async (state, instance, push) => (push ? PASS : save(state)),
|
|
250
|
+
async (state) => pushHistory(state),
|
|
251
|
+
],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// register at the same scope where you registerComponents
|
|
256
|
+
const scope = app.registerComponents([Comp]);
|
|
257
|
+
scope.registerIntentHandlers(getIntentHandlers());
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`PASS` is the handler's half of "running is not answering", and it is what
|
|
261
|
+
makes `<name>Unhandled` reachable. An intent name that **nothing** is
|
|
262
|
+
registered for is not a crash and not an error: the route runs out and the
|
|
263
|
+
sender hears `<name>Unhandled`. A typo surfaces there.
|
|
264
|
+
|
|
265
|
+
### The handler contract
|
|
266
|
+
|
|
267
|
+
Registered handlers run with **no `this`** (they are invoked as
|
|
268
|
+
`fn.apply(null, [...args, ctx])`), so they cannot read component state — pass
|
|
269
|
+
everything they need through `args`. Aggregate handlers from sub-modules with
|
|
270
|
+
spread:
|
|
271
|
+
|
|
272
|
+
```js
|
|
273
|
+
export function getIntentHandlers() {
|
|
274
|
+
return { ...getIntentHandlersA(), ...getIntentHandlersB() };
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
The handler also receives an intent context as its **final argument** —
|
|
279
|
+
usually ignored, but available when needed. Like every ctx it exposes
|
|
280
|
+
`ctx.walkPath(callback)`, which walks the component instances on the issuing
|
|
281
|
+
path **leaf→root**, calling `callback(Component, instance)` (return `false` to
|
|
282
|
+
stop early). It captures the immutable dispatch root/path, so it may be called
|
|
283
|
+
before or after an `await`. (The storybook uses this to let an example mock the
|
|
284
|
+
intent handlers its component raises — per example, in isolation.)
|
|
285
|
+
|
|
286
|
+
### Chaining from an answer arm
|
|
287
|
+
|
|
288
|
+
An answer arm gets the full `ctx`, so it can raise further intents or send
|
|
289
|
+
messages:
|
|
290
|
+
|
|
291
|
+
```js
|
|
292
|
+
receive: {
|
|
293
|
+
loadUserOk(draft, user, ctx) { draft.user = user; ctx.intent("loadUserDetails", [user.id], { route: ["lex"] }); },
|
|
294
|
+
loadUserDetailsOk(draft, details) { draft.userDetails = details; },
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## Integrating with the outside world
|
|
299
|
+
|
|
300
|
+
A tutuca app talks to the outside world in two directions, and both go through
|
|
301
|
+
handlers — never around them.
|
|
302
|
+
|
|
303
|
+
- **Outbound** — the app reaches out (fetch, timers, IndexedDB, external SDKs).
|
|
304
|
+
`ctx.intent(name, args, { route: ["lex"] })`; the scope-registered handler
|
|
305
|
+
does the async work and the answer lands back in component state as
|
|
306
|
+
`<name>Ok` / `<name>Error`.
|
|
307
|
+
- **Inbound** — the outside world pushes an event in (a WebSocket message, a
|
|
308
|
+
`postMessage`, a timer, a third-party callback). Use
|
|
309
|
+
`app.sendAtRoot(name, args)` from the host / glue code. It dispatches a
|
|
310
|
+
message to the **root component**, running its
|
|
311
|
+
`receive.<name>(draft, ...args, ctx)` handler under the same draft-first
|
|
312
|
+
transaction contract as every other handler.
|
|
313
|
+
|
|
314
|
+
```js
|
|
315
|
+
// host / glue code, outside the component tree
|
|
316
|
+
ws.onmessage = (e) => app.sendAtRoot("serverPushed", [JSON.parse(e.data)]);
|
|
317
|
+
|
|
318
|
+
// root component
|
|
319
|
+
receive: {
|
|
320
|
+
serverPushed(msg) { return this.prependEvent(msg); },
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
⚠️ **Do not** reach into `app.state` and call the raw `State.set(val)` /
|
|
325
|
+
`State.update(fn)` methods to inject external data. That bypasses the component
|
|
326
|
+
handler model, the draft-first transaction discipline, scope enrichment,
|
|
327
|
+
and the transactor's batching — state mutated that way is invisible to the
|
|
328
|
+
components that own it and easily clobbered by the next transaction. Route
|
|
329
|
+
every inbound event through `app.sendAtRoot` instead.
|
|
330
|
+
|
|
331
|
+
`sendAtRoot` only targets the root (`Path([])`). To land an inbound event on
|
|
332
|
+
nested state, let the root's `receive` handler forward it with
|
|
333
|
+
`ctx.at.field(...).send(...)` — one entry point, still reaching deep.
|
|
334
|
+
|
|
335
|
+
## Fire-and-forget
|
|
336
|
+
|
|
337
|
+
An intent whose answer you don't need declares no answer arms, so the outcome
|
|
338
|
+
is dropped. Idiomatic for side-effect-only work like persisting state:
|
|
339
|
+
|
|
340
|
+
```js
|
|
341
|
+
receive: {
|
|
342
|
+
applyFilter(draft, value, ctx) {
|
|
343
|
+
draft.filter = value;
|
|
344
|
+
ctx.intent("persistState", [{ key: "sectionFilter", value }], { route: ["lex"] });
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Fire several in one handler when needed — they go out in the order written.
|
|
350
|
+
|
|
351
|
+
## `livePath` — pinning vs following a moving key
|
|
352
|
+
|
|
353
|
+
`opts` takes `livePath`. It controls where the answer lands when the sender's
|
|
354
|
+
path addresses a seq-access entry (`.sheets[.selId]`): by **default** the
|
|
355
|
+
resolved key is *pinned* at dispatch time, so the answer updates the item that
|
|
356
|
+
raised the intent even if `.selId` moved while the walk was in flight (e.g. the
|
|
357
|
+
user switched tabs). Set `livePath: true` to opt out and re-resolve the key
|
|
358
|
+
live, delivering to whatever the key now points at:
|
|
359
|
+
|
|
360
|
+
```js
|
|
361
|
+
ctx.intent("save", [payload], { route: ["lex"] }); // pinned
|
|
362
|
+
ctx.intent("refresh", [], { route: ["lex"], livePath: true }); // live
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
The pinning rules per step kind (and why list indices still slide) are in
|
|
366
|
+
[semantics.md](./semantics.md) (*Key resolution & async races*).
|
|
367
|
+
|
|
368
|
+
## `$unknown` fallback
|
|
369
|
+
|
|
370
|
+
`receive` and `intent` share one fallback: when no handler matches the
|
|
371
|
+
dispatched name, the runtime looks for `<block>.$unknown(...args, ctx)` and
|
|
372
|
+
runs that instead; `ctx.name` tells it which name was dispatched. Absent both
|
|
373
|
+
the named handler and `$unknown`, the message is silently dropped (the value
|
|
374
|
+
passes through unchanged). Use `$unknown` for a single catch-all (logging, a
|
|
375
|
+
generic router).
|
|
376
|
+
|
|
377
|
+
An `intent.$unknown` that calls `ctx.reply` would answer **every** intent that
|
|
378
|
+
reaches it and swallow every walk — make it an observer, or `ctx.forward()`.
|
|
379
|
+
|
|
380
|
+
## Positional delivery across async
|
|
381
|
+
|
|
382
|
+
The path a message or an answer is delivered to is **positional** — an array of
|
|
383
|
+
steps from the root, not a captured reference. This is why an answer survives
|
|
384
|
+
intervening transactions that rebuilt the root (see *Mental model* in
|
|
385
|
+
[core.md](./core.md)). Practical rule: anchor on map keys, not list indices,
|
|
386
|
+
when an async answer must reach a specific item — the per-step-kind pinning
|
|
387
|
+
rules are in [semantics.md](./semantics.md).
|
|
388
|
+
|
|
389
|
+
## See also
|
|
390
|
+
|
|
391
|
+
- [core.md](./core.md) — the core mental model, `view` directives, handler
|
|
392
|
+
blocks overview, and *Conventional Module Exports*.
|
|
393
|
+
- [component-design.md](./component-design.md) — which channel to reach for when.
|
|
394
|
+
- [semantics.md](./semantics.md) — the path/transaction model behind these
|
|
395
|
+
channels: path steps, the transaction lifecycle, teleporting, and the
|
|
396
|
+
key-pinning rules `livePath` toggles.
|
|
397
|
+
- [testing.md](./testing.md) — driving message and intent flows from tests.
|
|
398
|
+
- [cli.md](./cli.md) — the full linter rule list, exit codes, and
|
|
399
|
+
`render` / `test` flags.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Tutuca — Patterns
|
|
2
|
+
|
|
3
|
+
Task-oriented recipes: "how do I do X" with a minimal working snippet and the
|
|
4
|
+
one pitfall worth knowing. Each recipe is self-contained and brief; for the
|
|
5
|
+
full directive *semantics* behind a pattern, see [core.md](../core.md) and its
|
|
6
|
+
spokes.
|
|
7
|
+
|
|
8
|
+
New to Tutuca? Read [core.md](../core.md) first, then reach here for a specific
|
|
9
|
+
task.
|
|
10
|
+
|
|
11
|
+
## Iteration & lists
|
|
12
|
+
|
|
13
|
+
- [Iterate a list](iterate-a-list.md) — render one element per item with `@each` / `render-each`.
|
|
14
|
+
- [Filter a list](filter-a-list.md) — keep only matching items with `@when`.
|
|
15
|
+
- [Enrich each item](enrich-each-item.md) — expose derived per-item values as `@`-bindings.
|
|
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).
|
|
18
|
+
|
|
19
|
+
## Conditional content & attributes
|
|
20
|
+
|
|
21
|
+
- [Show or hide content](show-or-hide-content.md) — `@show` / `@hide` and the boolean predicates.
|
|
22
|
+
- [Switch between views](switch-between-views.md) — pick a component's own view with `as=` or `@push-view`.
|
|
23
|
+
- [Conditional attribute value](conditional-attribute-value.md) — set a class/title by condition with `@if` / `@then` / `@else`.
|
|
24
|
+
- [Tabbed interface](tabbed-interface.md) — a `currentView` field + predicates to show the panel and highlight the active tab.
|
|
25
|
+
|
|
26
|
+
## Context & dynamic bindings
|
|
27
|
+
|
|
28
|
+
- [Share state across the tree](share-state-across-the-tree.md) — `provide` / `lookup` and reading `*name`.
|
|
29
|
+
- [Edit through a dynamic target](edit-through-a-dynamic-target.md) — render `*name` and teleport edits back to the owner.
|
|
30
|
+
|
|
31
|
+
## Composition
|
|
32
|
+
|
|
33
|
+
- [Render a child component](render-a-child-component.md) — `<x render=".field">` and multiple views.
|
|
34
|
+
- [Reuse markup with macros](reuse-markup-with-macros.md) — `macro(...)` with parameters and slots.
|
|
35
|
+
|
|
36
|
+
## Data & events
|
|
37
|
+
|
|
38
|
+
- [Bind text and attributes](bind-text-and-attributes.md) — `@text`, `:attr`, `$'…'` templates, scope enrichment.
|
|
39
|
+
- [Handle events](handle-events.md) — `@on.<event>`, handler args, modifiers, custom events.
|
|
40
|
+
- [Read a picked file](file-input.md) — `@on.change="… e.target"` and reading `e.target.files`.
|
|
41
|
+
|
|
42
|
+
## Component communication
|
|
43
|
+
|
|
44
|
+
- [Coordinate components](coordinate-components.md) — addressed `send`/`receive` vs routed `intent` (`dyn` / `lex`).
|
|
45
|
+
|
|
46
|
+
## Stories & catalog
|
|
47
|
+
|
|
48
|
+
- [Add a story for a component](add-a-story.md) — a `*.dev.js` with `getComponents()` + `getExamples()`, optional per-example request mocks.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Add a story for a component
|
|
2
|
+
|
|
3
|
+
**Problem:** show a component (and its states) in the storybook.
|
|
4
|
+
|
|
5
|
+
Create `foo.dev.js` next to `foo.js`:
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { Foo } from "./foo.js";
|
|
9
|
+
|
|
10
|
+
export function getComponents() {
|
|
11
|
+
return [Foo];
|
|
12
|
+
}
|
|
13
|
+
export function getExamples() {
|
|
14
|
+
return { title: "Foo", items: [
|
|
15
|
+
{ title: "Empty", value: Foo.make({}) },
|
|
16
|
+
{ title: "Loaded", value: Foo.make({ isLoading: true }),
|
|
17
|
+
intentHandlers: { async load() { return [{ id: 1 }]; } } },
|
|
18
|
+
] };
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`value` must be a real `Foo.make(...)` instance, not a plain object. Add a
|
|
23
|
+
`intentHandlers` map to an item to mock that example's requests
|
|
24
|
+
(fixture / `throw` / never-resolve) — these are storybook-only. Run
|
|
25
|
+
`tutuca storybook` to view, or `--dry-run --json` to smoke-test. See
|
|
26
|
+
[storybook.md](../storybook.md).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Bind text and attributes
|
|
2
|
+
|
|
3
|
+
**Problem:** display a field as text, bind it to an attribute, or compose a
|
|
4
|
+
string from several values.
|
|
5
|
+
|
|
6
|
+
```html
|
|
7
|
+
<!-- text -->
|
|
8
|
+
<span @text=".str"></span> <!-- into a host element -->
|
|
9
|
+
<x text="$getStrUpper"></x> <!-- $ calls a method; no wrapping element -->
|
|
10
|
+
|
|
11
|
+
<!-- attributes: plain = static, :attr = dynamic -->
|
|
12
|
+
<input :value=".str" @on.input="setStr e.value" />
|
|
13
|
+
<a :href=".url" :title="$'Hi {.name}'">link</a> <!-- $'…' string template -->
|
|
14
|
+
<button :class="$'btn btn-{.kind}'">x</button>
|
|
15
|
+
|
|
16
|
+
<!-- derive values for a subtree without putting them on the component -->
|
|
17
|
+
<div @enrich-with="enrichScope">Len: <x text="@len"></x></div>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
methods: { getStrUpper() { return this.str.toUpperCase(); } },
|
|
22
|
+
alter: { enrichScope() { return { len: this.text.length }; } }, // keys → @len, …
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Value slots take `.field`, `$method`, or `@binding` — never a path
|
|
26
|
+
(`.user.name` fails). Multi-word strings **must** be quoted (`'flex gap-3'`) or
|
|
27
|
+
written as a `$'…'` template (`$'btn {.kind}'`); a bare unquoted string returns
|
|
28
|
+
`null`. Boolean HTML attributes (`disabled`, `checked`, …) are auto-recognized
|
|
29
|
+
— pass a boolean field. Scope `@enrich-with` (no `@each` on the element) is the
|
|
30
|
+
path-free way to expose derived values to a subtree.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Conditional attribute value
|
|
2
|
+
|
|
3
|
+
**Problem:** set an attribute (class, title, …) to one value or another
|
|
4
|
+
depending on a condition.
|
|
5
|
+
|
|
6
|
+
```html
|
|
7
|
+
<button
|
|
8
|
+
@if.class=".isActive"
|
|
9
|
+
@then="'btn btn-success'"
|
|
10
|
+
@else="'btn btn-ghost'"
|
|
11
|
+
@on.click="toggleIsActive"
|
|
12
|
+
>
|
|
13
|
+
toggle
|
|
14
|
+
</button>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`@if.<attr>` takes the condition (a `.field`, a `$method`, or a predicate like
|
|
18
|
+
`equals? .tab 'x'`); `@then`/`@else` are the two values. String literals need
|
|
19
|
+
quotes (`'btn ok'`); a `$'…'` template works too. **Multiple `@if` on one
|
|
20
|
+
element:** every `@then`/`@else` after the first must name its attr
|
|
21
|
+
(`@then.title`, `@else.title`) — HTML forbids duplicate attribute names, so an
|
|
22
|
+
unnamed second `@then` is dropped silently.
|
|
23
|
+
|
|
24
|
+
```html
|
|
25
|
+
<button
|
|
26
|
+
@if.class=".isActive" @then="'on'" @else="'off'"
|
|
27
|
+
@if.title=".isActive" @then.title="'On'" @else.title="'Off'"
|
|
28
|
+
></button>
|
|
29
|
+
```
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Coordinate components
|
|
2
|
+
|
|
3
|
+
**Problem:** move state between components — notify an ancestor, message a
|
|
4
|
+
specific component, or run async work and fold in the result.
|
|
5
|
+
|
|
6
|
+
One question picks the channel: **does the sender know who handles this?**
|
|
7
|
+
|
|
8
|
+
```js
|
|
9
|
+
// send / receive — YES: deliver to one target (self, or ctx.at.<step> for another)
|
|
10
|
+
receive: {
|
|
11
|
+
submit(_draft, ctx) { ctx.at.field("status").send("flash", [this.draft]); },
|
|
12
|
+
flash(draft, message) { draft.message = message; },
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
// intent on the `dyn` leg — NO: walk the ancestors; a handler that replies ends the walk
|
|
16
|
+
receive: { onItemClick(_draft, ctx) { ctx.intent("itemSelected", [this.label], { route: ["dyn"] }); } },
|
|
17
|
+
intent: { itemSelected(draft, label) { draft.log.unshift(label); } },
|
|
18
|
+
|
|
19
|
+
// intent on the `lex` leg — NO: async host work, answered back into state
|
|
20
|
+
receive: {
|
|
21
|
+
init(draft, ctx) { draft.isLoading = true; ctx.intent("loadData", [], { route: ["lex"] }); },
|
|
22
|
+
loadDataOk(draft, items) { draft.isLoading = false; draft.items = items; },
|
|
23
|
+
loadDataError(draft, err) { draft.isLoading = false; draft.error = String(err); },
|
|
24
|
+
loadDataUnhandled(draft) { draft.isLoading = false; },
|
|
25
|
+
},
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**send/receive** addresses one known component (`ctx.at.field("x")` /
|
|
29
|
+
`.index(name, i)` / `.key(name, k)`, default self) and stops there.
|
|
30
|
+
**intent** names a job and walks a *route* until something answers: `["dyn"]`
|
|
31
|
+
up the ancestors (aggregate state — logs, selections), `["lex"]` the handlers
|
|
32
|
+
registered with `scope.registerIntentHandlers({...})` (fetch, timer,
|
|
33
|
+
IndexedDB), or the default `["dyn","lex"]` for both.
|
|
34
|
+
|
|
35
|
+
The verb no longer decides which scope answers — the route does, and it is
|
|
36
|
+
written at the call site where the decision actually is.
|
|
37
|
+
|
|
38
|
+
An intent answers in three named ways, each with **one** payload:
|
|
39
|
+
`<name>Ok`, `<name>Error`, `<name>Unhandled` (the route ran out; it carries the
|
|
40
|
+
intent's own args). They arrive as ordinary `receive` messages, and declaring
|
|
41
|
+
them is what makes an intent a *request* rather than a *notification* — declare
|
|
42
|
+
none and the outcome is dropped, which is the idiomatic fire-and-forget shape.
|
|
43
|
+
|
|
44
|
+
A handler that runs without calling `ctx.reply` is an **observer**: the walk
|
|
45
|
+
goes on. A `ctx.reply` ends it. That one rule is why there is no separate
|
|
46
|
+
listener bucket.
|
|
47
|
+
|
|
48
|
+
`ctx` is always the trailing arg. `receive.init` is a convention, not a
|
|
49
|
+
lifecycle hook — dispatch it with `app.sendAtRoot("init")`.
|
|
50
|
+
|
|
51
|
+
Carry the most granular payload across the channel, not whole objects you
|
|
52
|
+
won't use — `ctx.intent("itemSelected", [item.label], …)` over passing the
|
|
53
|
+
entire component (same reasoning as handler args: [testing.md](../testing.md)
|
|
54
|
+
*Designing handlers so tests stay simple*).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Edit through a dynamic target
|
|
2
|
+
|
|
3
|
+
**Problem:** render a value owned by a distant ancestor *and* let edits made in
|
|
4
|
+
the child land back on the owner — without forwarding events up by hand.
|
|
5
|
+
|
|
6
|
+
```js
|
|
7
|
+
// producer exposes a field (or a seq-access) as a dynamic
|
|
8
|
+
const Workspace = component({
|
|
9
|
+
name: "Workspace",
|
|
10
|
+
fields: { sheet: null },
|
|
11
|
+
provide: { active: ".sheet" }, // or ".items[.selectedKey]"
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// a distant consumer renders it as a target
|
|
15
|
+
const Toolbar = component({
|
|
16
|
+
name: "Toolbar",
|
|
17
|
+
lookup: { active: { for: "Workspace.active", default: ".missing" } },
|
|
18
|
+
view: html`<x render="*active" as="edit"></x>`,
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Because `*active` resolves to a real **path** (not a copied value), the event
|
|
23
|
+
fired inside the rendered child is *teleported*: the mutation skips the
|
|
24
|
+
intermediate components and lands on `Workspace.sheet`, so the owner and any
|
|
25
|
+
other view of the same value update in lock-step. A `provide` can even point at
|
|
26
|
+
a seq-access (`.items[.selectedKey]`) to expose "the selected item". This is
|
|
27
|
+
the **edit** counterpart of the share-state-across-the-tree recipe.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Enrich each item
|
|
2
|
+
|
|
3
|
+
**Problem:** show a value derived from each item (a count, a formatted label)
|
|
4
|
+
without storing it on the data.
|
|
5
|
+
|
|
6
|
+
```html
|
|
7
|
+
<li @each=".items" @enrich-with="enrichItem">
|
|
8
|
+
<x text="@value"></x> (<x text="@count"></x> characters)
|
|
9
|
+
</li>
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
alter: {
|
|
14
|
+
enrichItem(binds, _key, item) {
|
|
15
|
+
binds.count = item.length; // becomes @count in the template
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`@enrich-with` receives a **mutable** `binds` object (seeded with `{ key,
|
|
21
|
+
value }`); every key you set becomes an `@`-prefixed binding for that item's
|
|
22
|
+
subtree. The return value is ignored. Combine freely with `@when` and
|
|
23
|
+
`@loop-with` on the same element. Without an `@each` on the same element,
|
|
24
|
+
`@enrich-with` enriches the whole scope instead (see the bind-text-and-attributes
|
|
25
|
+
recipe).
|