tutuca 0.9.86 → 0.9.87
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-storybook.js
CHANGED
|
@@ -34,20 +34,36 @@ var Storybook = component({
|
|
|
34
34
|
return this;
|
|
35
35
|
}
|
|
36
36
|
return this.setSectionId(sectionId).setExampleId(exampleId).setFocusExample(example.value);
|
|
37
|
+
},
|
|
38
|
+
setSelectedSectionFilter(value) {
|
|
39
|
+
if (this.sections.size === 0)
|
|
40
|
+
return this;
|
|
41
|
+
const i = this.selectedSectionIndex;
|
|
42
|
+
const sections = this.sections.map((s, idx) => idx === i ? s.setFilter(value ?? "") : s);
|
|
43
|
+
return this.setSections(sections);
|
|
44
|
+
},
|
|
45
|
+
toUrlState(overrides = {}) {
|
|
46
|
+
const section = this.sections.get(this.selectedSectionIndex);
|
|
47
|
+
return {
|
|
48
|
+
section: section?.id ?? "",
|
|
49
|
+
example: this.exampleId ?? "",
|
|
50
|
+
sectionFilter: this.filter ?? "",
|
|
51
|
+
exampleFilter: section?.filter ?? "",
|
|
52
|
+
...overrides
|
|
53
|
+
};
|
|
37
54
|
}
|
|
38
55
|
},
|
|
39
56
|
input: {
|
|
40
57
|
onApplyFilter(value, ctx) {
|
|
41
|
-
ctx.request("persistState", [{
|
|
58
|
+
ctx.request("persistState", [this.toUrlState({ sectionFilter: value }), this, false]);
|
|
42
59
|
return this.setFilter(value);
|
|
43
60
|
},
|
|
44
61
|
onClearFilter(ctx) {
|
|
45
|
-
ctx.request("persistState", [{
|
|
62
|
+
ctx.request("persistState", [this.toUrlState({ sectionFilter: "" }), this, false]);
|
|
46
63
|
return this.resetFilter();
|
|
47
64
|
},
|
|
48
65
|
onFocusClose(ctx) {
|
|
49
|
-
ctx.request("persistState", [{
|
|
50
|
-
ctx.request("persistState", [{ key: "exampleId", value: "" }]);
|
|
66
|
+
ctx.request("persistState", [this.toUrlState({ example: "" }), this, true]);
|
|
51
67
|
return this.setSectionId(null).setExampleId(null).setFocusExample(null);
|
|
52
68
|
}
|
|
53
69
|
},
|
|
@@ -59,16 +75,38 @@ var Storybook = component({
|
|
|
59
75
|
bubble: {
|
|
60
76
|
sectionSelected(section, ctx) {
|
|
61
77
|
ctx.stopPropagation();
|
|
62
|
-
ctx.request("persistState", [
|
|
78
|
+
ctx.request("persistState", [
|
|
79
|
+
this.toUrlState({ section: section.id, exampleFilter: section.filter }),
|
|
80
|
+
this,
|
|
81
|
+
true
|
|
82
|
+
]);
|
|
63
83
|
return this.selectSectionAtIndex(this.sections.indexOf(section));
|
|
64
84
|
},
|
|
65
85
|
exampleFocusRequested(example, ctx) {
|
|
66
86
|
ctx.stopPropagation();
|
|
67
87
|
const section = this.sections.get(this.selectedSectionIndex);
|
|
68
88
|
const sectionId = section?.id ?? null;
|
|
69
|
-
ctx.request("persistState", [{
|
|
70
|
-
ctx.request("persistState", [{ key: "exampleId", value: example.id }]);
|
|
89
|
+
ctx.request("persistState", [this.toUrlState({ example: example.id }), this, true]);
|
|
71
90
|
return this.setSectionId(sectionId).setExampleId(example.id).setFocusExample(example.value);
|
|
91
|
+
},
|
|
92
|
+
exampleFilterChanged(value, ctx) {
|
|
93
|
+
ctx.stopPropagation();
|
|
94
|
+
ctx.request("persistState", [this.toUrlState({ exampleFilter: value }), this, false]);
|
|
95
|
+
return this;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
receive: {
|
|
99
|
+
init(ctx) {
|
|
100
|
+
ctx.request("loadState", []);
|
|
101
|
+
return this;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
response: {
|
|
105
|
+
loadState(state, err) {
|
|
106
|
+
if (err || !state)
|
|
107
|
+
return this;
|
|
108
|
+
const next = this.selectSectionWithId(state.section).setFilter(state.sectionFilter ?? "").setSelectedSectionFilter(state.exampleFilter ?? "");
|
|
109
|
+
return state.example ? next.focusExampleByIds(state.section, state.example) : next.setSectionId(null).setExampleId(null).setFocusExample(null);
|
|
72
110
|
}
|
|
73
111
|
},
|
|
74
112
|
view: html`<div>
|
|
@@ -115,10 +153,13 @@ var Section = component({
|
|
|
115
153
|
selected: false
|
|
116
154
|
},
|
|
117
155
|
statics: {
|
|
118
|
-
fromData(
|
|
119
|
-
|
|
156
|
+
fromData(raw) {
|
|
157
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || raw.title == null) {
|
|
158
|
+
throw new Error(`Section.fromData: expected a section object { title, items }, got ${JSON.stringify(raw)}. ` + `getExamples() must return a section object or an array of section objects.`);
|
|
159
|
+
}
|
|
160
|
+
const { id, title, description = "", items = [] } = raw;
|
|
120
161
|
return this.make({
|
|
121
|
-
id,
|
|
162
|
+
id: id ?? slugify(title),
|
|
122
163
|
title,
|
|
123
164
|
description,
|
|
124
165
|
items: items.map((v) => Example.Class.fromData(v))
|
|
@@ -132,11 +173,11 @@ var Section = component({
|
|
|
132
173
|
},
|
|
133
174
|
input: {
|
|
134
175
|
onApplyFilter(value, ctx) {
|
|
135
|
-
ctx.
|
|
176
|
+
ctx.bubble("exampleFilterChanged", [value]);
|
|
136
177
|
return this.setFilter(value);
|
|
137
178
|
},
|
|
138
179
|
onClearFilter(ctx) {
|
|
139
|
-
ctx.
|
|
180
|
+
ctx.bubble("exampleFilterChanged", [""]);
|
|
140
181
|
return this.resetFilter();
|
|
141
182
|
},
|
|
142
183
|
onListItemClick(ctx) {
|
|
@@ -235,7 +276,12 @@ function slugify(str) {
|
|
|
235
276
|
return String(str).normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
236
277
|
}
|
|
237
278
|
function buildStorybook(modules) {
|
|
238
|
-
const sections = modules.
|
|
279
|
+
const sections = modules.flatMap((m) => {
|
|
280
|
+
const raw = m.getExamples?.();
|
|
281
|
+
if (raw == null)
|
|
282
|
+
return [];
|
|
283
|
+
return Array.isArray(raw) ? raw : [raw];
|
|
284
|
+
}).map((s) => Section.Class.fromData(s)).sort((a, b) => a.title.localeCompare(b.title));
|
|
239
285
|
const components = new Set([Storybook, Section, Example]);
|
|
240
286
|
const macros = {};
|
|
241
287
|
const requestHandlers = {};
|
|
@@ -255,18 +301,46 @@ function buildStorybook(modules) {
|
|
|
255
301
|
requestHandlers
|
|
256
302
|
};
|
|
257
303
|
}
|
|
258
|
-
async function mountStorybook(selector, modules, { compileCss, root } = {}) {
|
|
304
|
+
async function mountStorybook(selector, modules, { compileCss, root, persistUrl = true } = {}) {
|
|
259
305
|
const app = tutuca(selector);
|
|
260
306
|
const built = buildStorybook(modules);
|
|
261
307
|
app.state.set(root ?? built.root);
|
|
262
308
|
const scope = app.registerComponents(built.components);
|
|
263
309
|
scope.registerMacros(built.macros);
|
|
264
310
|
scope.registerRequestHandlers(built.requestHandlers);
|
|
311
|
+
if (persistUrl) {
|
|
312
|
+
scope.registerRequestHandlers({ persistState, loadState });
|
|
313
|
+
}
|
|
265
314
|
if (compileCss) {
|
|
266
315
|
injectCss("tutuca-storybook", await compileCss(app));
|
|
267
316
|
}
|
|
268
317
|
app.start();
|
|
318
|
+
if (persistUrl) {
|
|
319
|
+
app.sendAtRoot("init", []);
|
|
320
|
+
window.addEventListener("popstate", () => app.sendAtRoot("init", []));
|
|
321
|
+
}
|
|
269
322
|
return app;
|
|
323
|
+
function persistState(state, instance, push) {
|
|
324
|
+
if (instance !== app.state.val)
|
|
325
|
+
return;
|
|
326
|
+
const url = new URL(window.location.href);
|
|
327
|
+
for (const [k, v] of Object.entries(state)) {
|
|
328
|
+
if (v === "" || v == null)
|
|
329
|
+
url.searchParams.delete(k);
|
|
330
|
+
else
|
|
331
|
+
url.searchParams.set(k, String(v));
|
|
332
|
+
}
|
|
333
|
+
window.history[push ? "pushState" : "replaceState"](null, "", url);
|
|
334
|
+
}
|
|
335
|
+
function loadState() {
|
|
336
|
+
const p = new URLSearchParams(window.location.search);
|
|
337
|
+
return {
|
|
338
|
+
section: p.get("section"),
|
|
339
|
+
example: p.get("example"),
|
|
340
|
+
sectionFilter: p.get("sectionFilter") ?? "",
|
|
341
|
+
exampleFilter: p.get("exampleFilter") ?? ""
|
|
342
|
+
};
|
|
343
|
+
}
|
|
270
344
|
}
|
|
271
345
|
function getComponents() {
|
|
272
346
|
return [Storybook, Section, Example];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tutuca",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.87",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Zero-dependency SPA framework with immutable state and virtual DOM",
|
|
6
6
|
"main": "./dist/tutuca.js",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"lint-fix": "bunx @biomejs/biome lint --write",
|
|
40
40
|
"fix": "bunx @biomejs/biome check --write",
|
|
41
41
|
"tutuca": "bun tools/tutuca.js",
|
|
42
|
+
"storybook:examples": "bun scripts/storybook-examples.js",
|
|
42
43
|
"stresstest": "bun scripts/stresstest.js",
|
|
43
44
|
"smoke-test": "bun tools/tutuca.js lint ./test/todo.js && bun tools/tutuca.js render ./test/todo.js && bun tools/tutuca.js test ./test/todo.js && bun tools/tutuca.js lint ./test/json.js && bun tools/tutuca.js render ./test/json.js"
|
|
44
45
|
},
|
|
@@ -28,6 +28,13 @@ Walk these top-down whenever you add or reshape a component:
|
|
|
28
28
|
|
|
29
29
|
## Communication decision ladder
|
|
30
30
|
|
|
31
|
+
This ladder is about *acting across* a component boundary — reaching up,
|
|
32
|
+
messaging a target, doing async work, or mutating state someone else owns. It is
|
|
33
|
+
**not** about merely *reading* a child's state: an ancestor already holds its
|
|
34
|
+
children as immutable fields and can read them directly in any handler or method
|
|
35
|
+
(`this.items.get(i).done`) — no channel needed. See [core.md](./core.md) "The
|
|
36
|
+
value tree".
|
|
37
|
+
|
|
31
38
|
Reach for the *narrowest* channel that does the job, and only move further down
|
|
32
39
|
the ladder when the one above can't express it:
|
|
33
40
|
|
|
@@ -72,6 +79,16 @@ A compact worked version of the first four (`method`, `bubble`, `send`/`receive`
|
|
|
72
79
|
between** (the React "prop drilling" reflex) — let a child render the field it
|
|
73
80
|
needs from its owner. → [patterns/share-state-across-the-tree.md](./patterns/share-state-across-the-tree.md)
|
|
74
81
|
|
|
82
|
+
- **Do read a child's state directly when an ancestor needs it for an aggregate
|
|
83
|
+
decision.** A parent holds its children as immutable fields, so a handler or
|
|
84
|
+
method can read `this.items.get(i).done` straight off — children don't have to
|
|
85
|
+
`bubble` their state up just to be *read*. **Don't reach for a channel to read
|
|
86
|
+
downward**; `bubble` / `send` are for reaching *up*, messaging a target, or
|
|
87
|
+
mutating — not for inspecting state you already own. (And don't reach in to
|
|
88
|
+
mutate a child around the model — that still goes through the owner returning a
|
|
89
|
+
new self or `ctx.send`.) → [core.md](./core.md) "The value tree" and
|
|
90
|
+
[request-response.md](./request-response.md) "When to bubble"
|
|
91
|
+
|
|
75
92
|
- **Do reach for `provide` / `lookup` (`*name`) last** — only when a deep
|
|
76
93
|
descendant needs a value owned far away and nothing in between should know about
|
|
77
94
|
it. Dynamic bindings couple a consumer to a producer that may not be in scope.
|
package/skill/tutuca/core.md
CHANGED
|
@@ -169,6 +169,23 @@ keys its cache on `===` identity, so unchanged subtrees skip work.
|
|
|
169
169
|
Every value carries a hidden tag back to its component class, so the
|
|
170
170
|
runtime never needs `instanceof` — it asks the value what it is.
|
|
171
171
|
|
|
172
|
+
Because children are just immutable Records held in fields, **handlers
|
|
173
|
+
and methods are ordinary JS with full read access to nested child
|
|
174
|
+
state** — `this.child.count`, `this.items.get(i).done`,
|
|
175
|
+
`this.byKey.get(k).label`. Reading *down* the tree is direct and needs
|
|
176
|
+
no channel: an ancestor that owns a list already holds every child's
|
|
177
|
+
state and can read it for an aggregate decision. The single-level
|
|
178
|
+
`.field` restriction (no `.foo.bar`) is a **view-template** rule, not a
|
|
179
|
+
JS one — it's why a derivation like `userName() { return this.user.name; }`
|
|
180
|
+
is written as a method (see *Methods as Predicates & Computed Values*).
|
|
181
|
+
Reading is free; **mutating** a child still flows through the model —
|
|
182
|
+
the owner returns a new self (`setInItemsAt`, …) or messages the child
|
|
183
|
+
with `ctx.send`. Don't reach in to mutate around the handler discipline,
|
|
184
|
+
and prefer letting a child own and render its own state — reach down to
|
|
185
|
+
read only when the ancestor genuinely needs it. See
|
|
186
|
+
[component-design.md](./component-design.md) and "When to bubble" in
|
|
187
|
+
[request-response.md](./request-response.md).
|
|
188
|
+
|
|
172
189
|
**Stack: frames vs scopes.** As the renderer walks the AST it pushes
|
|
173
190
|
`BindFrame`s. A *frame* is a barrier: name lookups (`@x`) stop at it,
|
|
174
191
|
so a child component view sees a clean namespace. A *scope* is
|
|
@@ -56,7 +56,11 @@ the state needed to respond. Bubble when the action belongs to an
|
|
|
56
56
|
ancestor (a list item's "remove" must reach the list that owns the
|
|
57
57
|
items), or when an ancestor may want to react to or record something
|
|
58
58
|
that happened (selection, logging, analytics). Don't bubble events
|
|
59
|
-
with no consumer
|
|
59
|
+
with no consumer — and don't bubble merely so an ancestor can *read* a
|
|
60
|
+
child's state: the ancestor already holds the child as a field and can
|
|
61
|
+
read it directly in its own handler (`this.items.get(i).done`). Bubble
|
|
62
|
+
when the ancestor must **act on** or **record** the event, not to learn
|
|
63
|
+
state it already owns.
|
|
60
64
|
|
|
61
65
|
## Send / Receive
|
|
62
66
|
|