tutuca 0.9.86 → 0.9.88

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.
@@ -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", [{ key: "sectionFilter", value }]);
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", [{ key: "sectionFilter", value: "" }]);
62
+ ctx.request("persistState", [this.toUrlState({ sectionFilter: "" }), this, false]);
46
63
  return this.resetFilter();
47
64
  },
48
65
  onFocusClose(ctx) {
49
- ctx.request("persistState", [{ key: "sectionId", value: "" }]);
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", [{ key: "section", value: section.id }]);
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", [{ key: "sectionId", value: sectionId }]);
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({ id, title = "???", description = "", items = [] }) {
119
- id ??= slugify(title);
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.request("persistState", [{ key: "exampleFilter", value }]);
176
+ ctx.bubble("exampleFilterChanged", [value]);
136
177
  return this.setFilter(value);
137
178
  },
138
179
  onClearFilter(ctx) {
139
- ctx.request("persistState", [{ key: "exampleFilter", value: "" }]);
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.map((m) => Section.Class.fromData(m.getExamples())).sort((a, b) => a.title.localeCompare(b.title));
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.86",
3
+ "version": "0.9.88",
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.
@@ -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