@zerotal/arch 1.7.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.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
@@ -0,0 +1,846 @@
1
+ ---
2
+ title: Flow Built-in Components
3
+ description: The component library that ships with Flow — forms, overlays, tables, and feedback.
4
+ ---
5
+
6
+ # Built-in Components
7
+
8
+ Flow ships a library of ready-made native components and unstyled headless primitives. All are imported from `@zerotal/flow`.
9
+
10
+ > **Tip** — See them live: the example app's component gallery at `/pulse/components` (`apps/example/app/flow/ComponentsPage.tsx`) shows every component, and `/pulse/users` (`UsersPage.tsx`) is a realistic admin screen composing them together.
11
+
12
+ ## Navigation
13
+
14
+ ### Link
15
+
16
+ SPA navigation — prevents a full reload, swaps the page over the WebSocket, and updates the URL. The bridge automatically adds `data-current` to the link matching the current URL, so you style the active state with Tailwind's `data-[current]:` variant or a CSS attribute selector:
17
+
18
+ ```tsx
19
+ import { Link } from "@zerotal/flow";
20
+
21
+ <Link href="/posts" class="data-[current]:font-bold">
22
+ Posts
23
+ </Link>;
24
+
25
+ {
26
+ /* Prefetch the target page after ~60ms hover */
27
+ }
28
+ <Link href="/posts" hover>
29
+ Posts
30
+ </Link>;
31
+
32
+ {
33
+ /* Disable automatic data-current (e.g. always-active home links) */
34
+ }
35
+ <Link href="/" current={false}>
36
+ Home
37
+ </Link>;
38
+ ```
39
+
40
+ `data-current` matches by **prefix** — a link to `/posts` stays active on `/posts/42` — which is what you want for a section parent. For an index link that should be active only on its own exact URL (an "Overview" tab that shouldn't light up on the section's sub-pages), add `exact`:
41
+
42
+ ```tsx
43
+ <Link href="/dashboard" exact class="data-[current]:font-bold">
44
+ Overview
45
+ </Link>
46
+ ```
47
+
48
+ Any extra props (`class`, `target`, `rel`, …) pass through to the rendered `<a>`.
49
+
50
+ #### Scroll position
51
+
52
+ Following a link lands at the top of the new page, exactly as a full navigation
53
+ would — or at the fragment, if the href names one (`/docs#install`). Going Back
54
+ returns you to where you were on the page you left, and Forward does the same.
55
+
56
+ Some links aren't really going anywhere, though: a sort header, a filter chip, a
57
+ tab strip partway down a long page. Jumping to the top for those loses the
58
+ control the user was just looking at. `preserveScroll` leaves the viewport alone:
59
+
60
+ ```tsx
61
+ <Link href={this.currentUrl({ query: { sort: "title" } })} preserveScroll>
62
+ Title
63
+ </Link>
64
+ ```
65
+
66
+ The same applies to `this.navigateCurrent()`, which takes `preserveScroll` as an
67
+ option — see [Routing](/docs/flow/routing).
68
+
69
+ ## Head management
70
+
71
+ ### Head
72
+
73
+ Injects content into the document `<head>`. Author it anywhere in `render()`; the client hoists it into `<head>` on load and replaces it on every `navigate` visit:
74
+
75
+ ```tsx
76
+ import { Head } from "@zerotal/flow";
77
+
78
+ <Head>
79
+ <title>Dashboard — My App</title>
80
+ <meta name="description" content="Your personal dashboard." />
81
+ <link rel="canonical" href="https://example.com/dashboard" />
82
+ </Head>;
83
+ ```
84
+
85
+ ### Title
86
+
87
+ Shorthand for `<Head><title>…</title></Head>`. Supports interpolation:
88
+
89
+ ```tsx
90
+ import { Title } from "@zerotal/flow";
91
+
92
+ <Title>{`${this.unreadCount} unread — Inbox`}</Title>;
93
+ ```
94
+
95
+ ## Persistence
96
+
97
+ ### Persist
98
+
99
+ Preserve an element — and its live state (a playing `<audio>`, an embedded widget) — across `navigate` page visits. Give it a stable `name`; on navigation the bridge re-uses the existing DOM node instead of replacing it. Best placed in your layout:
100
+
101
+ ```tsx
102
+ import { Persist } from "@zerotal/flow";
103
+
104
+ {
105
+ /* In AppLayout: */
106
+ }
107
+ <Persist name="audio-player">
108
+ <audio src={this.currentTrack} controls autoPlay />
109
+ </Persist>;
110
+ ```
111
+
112
+ Elements inside `<Persist>` also survive server-patch morphs (they carry `flow:ignore`) so their live state isn't disrupted by unrelated updates.
113
+
114
+ ## Overlays
115
+
116
+ ### Modal
117
+
118
+ A dialog that bundles reactive visibility, a backdrop, a panel, a transition, a close button, and Escape-to-close — all wired to one boolean prop. Clicking the backdrop, the × button, or pressing Escape sets that prop back to `false`:
119
+
120
+ ```tsx
121
+ import { Modal } from "@zerotal/flow";
122
+
123
+ export class ContactsPage extends Component {
124
+ @expose open = false;
125
+ @expose editingId: number | null = null;
126
+
127
+ @expose async openEdit(id: number): Promise<void> {
128
+ this.editingId = id;
129
+ this.open = true;
130
+ }
131
+
132
+ override async render() {
133
+ return (
134
+ <div>
135
+ <button onClick={() => this.openEdit(contact.id)}>Edit</button>
136
+
137
+ <Modal show={this.open} title="Edit contact">
138
+ <Field label="Name" error={this.errors.name}>
139
+ <input value={this.form.name} class="input" />
140
+ </Field>
141
+ <button onClick={this.saveContact} loadingAttr="disabled">
142
+ Save
143
+ </button>
144
+ </Modal>
145
+ </div>
146
+ );
147
+ }
148
+ }
149
+ ```
150
+
151
+ | Prop | Type | Description |
152
+ | ---------- | ------------------- | ---------------------------------------------- |
153
+ | `show` | `boolean @expose` | Bound boolean that controls visibility |
154
+ | `title` | `string` | Dialog title |
155
+ | `onClose` | method ref or arrow | Override the default close (sets `show=false`) |
156
+ | `closable` | `boolean` | Hide the × button when `false` |
157
+ | `class` | `string` | Extra classes on the panel element |
158
+
159
+ ### Drawer
160
+
161
+ A slide-over panel — the edge-anchored sibling of `<Modal>`. Same binding and close model (backdrop, × and Escape all close it client-side with no round-trip; focus-trapped), but slides in from an edge:
162
+
163
+ ```tsx
164
+ import { Drawer } from "@zerotal/flow";
165
+
166
+ <button onClick={() => (this.cartOpen = true)}>Cart ({this.cartCount})</button>
167
+
168
+ <Drawer show={this.cartOpen} side="right" title="Your cart" class="w-96">
169
+ {this.cartItems.map((item) => (
170
+ <div key={String(item.id)} class="flex justify-between py-2">
171
+ <span>{item.name}</span>
172
+ <span>${item.price}</span>
173
+ </div>
174
+ ))}
175
+ <button onClick={this.checkout} class="btn-primary w-full mt-4">Checkout</button>
176
+ </Drawer>
177
+ ```
178
+
179
+ `side`: `"right"` (default) | `"left"` | `"top"` | `"bottom"`.
180
+
181
+ ## Feedback
182
+
183
+ ### Flash
184
+
185
+ A self-contained toast container. Server-side `this.flash(message, level)` dispatches a `flow:flash` event; `<Flash>` listens and renders an auto-dismissing toast. Drop **one** in your layout so toasts work app-wide:
186
+
187
+ ```tsx
188
+ import { Flash } from "@zerotal/flow";
189
+
190
+ {
191
+ /* In AppLayout: */
192
+ }
193
+ <Flash position="bottom-right" duration={4000} />;
194
+ ```
195
+
196
+ | Prop | Type | Description |
197
+ | ---------- | -------- | -------------------------------------------------------------------------------------------- |
198
+ | `position` | string | `"top-left"` `"top-center"` `"top-right"` `"bottom-left"` `"bottom-center"` `"bottom-right"` |
199
+ | `duration` | `number` | Auto-dismiss delay in ms (default: `4000`) |
200
+
201
+ Levels `success` / `error` / `warning` / `info` map to distinct colors. Click a toast to dismiss it early.
202
+
203
+ ### Alert
204
+
205
+ A dismissible inline alert. `variant` sets the palette and ARIA role (error/warning announce assertively). Dismissal is client-only — no round-trip:
206
+
207
+ ```tsx
208
+ import { Alert } from "@zerotal/flow";
209
+
210
+ <Alert variant="success" dismissible>
211
+ Your changes have been saved.
212
+ </Alert>
213
+
214
+ <Alert variant="error">
215
+ Failed to connect to the database.
216
+ </Alert>
217
+ ```
218
+
219
+ `variant`: `"info"` | `"success"` | `"warning"` | `"error"`.
220
+
221
+ ### Loading
222
+
223
+ Shows its children only while a server action is in flight — and, so a fast action never flashes a spinner, loading **indicators wait out a short delay** (~200ms) by default: an action that finishes inside that window shows nothing at all. This applies to the whole loading family — `<Loading>`, `showOnLoading`, `hideOnLoading`, and `loadingClass`. Only `loadingAttr` (e.g. `loadingAttr="disabled"`) is applied immediately, so a submit button still guards against a double-click even on a sub-100ms action. The `delay` prop is now the default behaviour and kept only for clarity/back-compat:
224
+
225
+ ```tsx
226
+ import { Loading } from "@zerotal/flow";
227
+
228
+ <button onClick={this.save}>Save</button>
229
+ <Loading target="save" delay>Saving…</Loading>
230
+
231
+ {/* Inverted — show when NOT loading */}
232
+ <Loading hide>Ready</Loading>
233
+
234
+ {/* Scope to multiple actions */}
235
+ <Loading target={["save", "publish"]}>Working…</Loading>
236
+ ```
237
+
238
+ | Prop | Description |
239
+ | -------- | ----------------------------------------------------------------- |
240
+ | `target` | Action name(s) to scope to; omit to react to any in-flight action |
241
+ | `delay` | Wait briefly before showing (prevents flicker) |
242
+ | `hide` | Inverts the logic — shown when NOT loading |
243
+
244
+ ## Errors
245
+
246
+ ### The Errors component
247
+
248
+ Renders the component's entire validation error bag as a list. Hidden when there are none:
249
+
250
+ ```tsx
251
+ import { Errors } from "@zerotal/flow";
252
+
253
+ <Errors /> {/* every current error */}
254
+ <Errors only={["email", "password"]} /> {/* just these fields */}
255
+ ```
256
+
257
+ ### ErrorMessage
258
+
259
+ A single field's first error message as a self-hiding `<span>`. Equivalent to `<span error={this.errors.field} />`:
260
+
261
+ ```tsx
262
+ import { ErrorMessage } from "@zerotal/flow";
263
+
264
+ <input value={this.form.email} />
265
+ <ErrorMessage for={this.errors.email} class="text-sm text-red-500" />
266
+ ```
267
+
268
+ ### ErrorBoundary
269
+
270
+ Contains a failure in a nested component so it costs that component rather than the page. Without
271
+ one, a child that throws while mounting or rendering takes the whole response with it — one broken
272
+ widget blanks the dashboard.
273
+
274
+ ```tsx
275
+ import { ErrorBoundary } from "@zerotal/flow";
276
+
277
+ <ErrorBoundary fallback={<p class="text-sm text-red-600">Sales data unavailable.</p>}>
278
+ <SalesReport />
279
+ </ErrorBoundary>;
280
+ ```
281
+
282
+ `fallback` may be a function, which receives the thrown error. `onError` reports it (for logging or
283
+ an error tracker) without changing what renders:
284
+
285
+ ```tsx
286
+ <ErrorBoundary fallback={(e) => <p>{(e as Error).message}</p>} onError={(e) => Log.error(e)}>
287
+ <RiskyWidget />
288
+ </ErrorBoundary>
289
+ ```
290
+
291
+ Boundaries nest, and the innermost one wins. Siblings are independent: one failing widget does not
292
+ affect the other.
293
+
294
+ > **What it covers is child components.** Inline JSX in the same `render()` is evaluated before the
295
+ > boundary is called, so a throw there cannot be intercepted — move the risky work into a child
296
+ > component. Containment is also opt-in: a child _outside_ any boundary still fails the page, so
297
+ > real bugs surface instead of rendering as blank space forever.
298
+
299
+ ## Data display
300
+
301
+ ### Table
302
+
303
+ A data table with URL-driven sortable headers. Clicking a sortable header navigates to `?sortBy=key&sortDir=asc|desc`. Pair with `@url sortBy`/`@url sortDir` and sort the rows server-side in `render()`:
304
+
305
+ ```tsx
306
+ import { Table, Pager } from "@zerotal/flow";
307
+
308
+ export class UsersPage extends Component.using(Pagination) {
309
+ @url sortBy: string = "name";
310
+ @url sortDir: string = "asc";
311
+ @locked users: User[] = [];
312
+
313
+ override async onMount() {
314
+ this.users = await User.query().orderBy(this.sortBy, this.sortDir).get();
315
+ }
316
+
317
+ override async render() {
318
+ const users = await User.paginate(20);
319
+
320
+ return (
321
+ <div>
322
+ <Table
323
+ columns={[
324
+ { key: "name", label: "Name", sortable: true },
325
+ { key: "email", label: "Email", sortable: true },
326
+ { key: "role", label: "Role" },
327
+ {
328
+ key: "actions",
329
+ label: "",
330
+ render: (row) => <button onClick={() => this.edit(row.id)}>Edit</button>,
331
+ },
332
+ ]}
333
+ rows={p.data}
334
+ sortBy={this.sortBy}
335
+ sortDir={this.sortDir}
336
+ params={{ q: this.search }} // preserve query state in sort links
337
+ hover
338
+ />
339
+ <Pager paginator={p} params={{ sortBy: this.sortBy, sortDir: this.sortDir }} />
340
+ </div>
341
+ );
342
+ }
343
+ }
344
+ ```
345
+
346
+ ### Pager
347
+
348
+ Renders a Prev / numbered / Next pager from either paginator — `Model.paginate()` or the in-memory `paginate()` helper. Links are `navigate` anchors to `?page=N`, so they pair with `@url page` automatically. (The `Pagination` export is the page-state mixin; `<Pager>` is the links UI.)
349
+
350
+ ```tsx
351
+ import { Pager } from "@zerotal/flow";
352
+
353
+ const users = await User.paginate(this.perPage);
354
+ <Pager paginator={p} params={{ q: this.query, perPage: this.perPage }} hover />;
355
+ ```
356
+
357
+ `hover` prefetches the next page on hover. `params` keeps other query state in the pager links.
358
+
359
+ ### InfiniteScroll
360
+
361
+ A sentinel element that calls a server action when it scrolls into view. Pass `show` to stop loading when there are no more items:
362
+
363
+ ```tsx
364
+ import { InfiniteScroll } from "@zerotal/flow";
365
+
366
+ @expose async loadMore(): Promise<void> {
367
+ const nextBatch = await Post.query().offset(this.posts.length).limit(20).get();
368
+ this.posts = [...this.posts, ...nextBatch];
369
+ }
370
+
371
+ // In render():
372
+ <ul>{this.posts.map((p) => <li key={String(p.id)}>{p.title}</li>)}</ul>
373
+ <InfiniteScroll show={this.posts.length < this.total} onMore={this.loadMore} />
374
+ ```
375
+
376
+ ### Virtualize
377
+
378
+ A scrolling window over a collection too large to put in the DOM. Only the visible rows exist as
379
+ elements; spacers above and below hold the scrollbar at the size the full collection implies.
380
+
381
+ As the viewport moves, `onWindow` is called with `(start, count)` and your action loads that slice.
382
+ The collection never has to reach the client in full.
383
+
384
+ ```tsx
385
+ import { Virtualize } from "@zerotal/flow";
386
+
387
+ @expose rows: Row[] = [];
388
+ @expose windowStart = 0;
389
+ @expose total = 0;
390
+
391
+ override async onMount(): Promise<void> {
392
+ this.total = await Row.query().count();
393
+ await this.loadWindow(0, 30);
394
+ }
395
+
396
+ @expose async loadWindow(start: number, count: number): Promise<void> {
397
+ this.rows = await Row.query().offset(start).limit(count).get();
398
+ this.windowStart = start;
399
+ }
400
+
401
+ // In render():
402
+ <Virtualize
403
+ items={this.rows}
404
+ start={this.windowStart}
405
+ total={this.total}
406
+ itemHeight={36}
407
+ height={480}
408
+ onWindow={this.loadWindow}
409
+ >
410
+ {(row) => <div class="h-9 px-3 leading-9">{row.name}</div>}
411
+ </Virtualize>
412
+ ```
413
+
414
+ Rows must all be `itemHeight` pixels tall — that is what lets a scroll offset become an index
415
+ without measuring anything. `overscan` (default 6) renders extra rows beyond the viewport to hide
416
+ fetch latency.
417
+
418
+ > **Virtualize or InfiniteScroll?** `InfiniteScroll` appends and grows the DOM without bound, which
419
+ > is right for a feed someone scrolls a few screens of. Reach for `Virtualize` when _keeping_ every
420
+ > rendered row is the problem.
421
+
422
+ ## Navigation menus
423
+
424
+ ### Dropdown
425
+
426
+ A click-to-open menu, entirely client-side (no round-trip). Fully keyboard-navigable: Down/Up/Enter opens (focusing first/last item), arrow keys + Home/End move between items, Escape closes and returns focus to the trigger, click-outside dismisses:
427
+
428
+ ```tsx
429
+ import { Dropdown } from "@zerotal/flow";
430
+
431
+ <Dropdown label="Options" align="right">
432
+ <button class="dropdown-item">Profile</button>
433
+ <button class="dropdown-item">Settings</button>
434
+ <hr class="my-1" />
435
+ <button class="dropdown-item text-red-600" onClick={this.logout}>
436
+ Sign out
437
+ </button>
438
+ </Dropdown>;
439
+ ```
440
+
441
+ ### Tabs
442
+
443
+ Client-side tabbed panels. Pass `items`, each with a `label` and the `content` to show when selected:
444
+
445
+ ```tsx
446
+ import { Tabs } from "@zerotal/flow";
447
+
448
+ <Tabs
449
+ items={[
450
+ { label: "Overview", content: <OverviewPanel /> },
451
+ { label: "Activity", content: <ActivityPanel /> },
452
+ { label: "Settings", content: <SettingsPanel /> },
453
+ ]}
454
+ />;
455
+ ```
456
+
457
+ `<Tabs>` emits `role="tablist"` / `"tab"` / `"tabpanel"` with roving arrow-key navigation.
458
+
459
+ ## File upload component
460
+
461
+ ### FileUpload + FileUploads mixin
462
+
463
+ A dropzone bound to an `@expose` property. Choosing a file POSTs the bytes to `/__flow/upload` over HTTP, shows live upload progress, and resolves to a signed `TemporaryUploadedFile` reference. Compose the `FileUploads` mixin for the `removeUpload` action:
464
+
465
+ ```tsx
466
+ import { Component, expose, FileUpload, FileUploads, TemporaryUploadedFile } from "@zerotal/flow";
467
+
468
+ export class AvatarPage extends Component.using(FileUploads) {
469
+ @expose photo: TemporaryUploadedFile | null = null;
470
+ @locked photoUrl: string = "";
471
+
472
+ override async onMount() {
473
+ const user = await User.find(this.userId);
474
+ this.photoUrl = user?.avatarUrl ?? "";
475
+ }
476
+
477
+ @expose async save(): Promise<void> {
478
+ if (!this.photo) return;
479
+ const path = await this.photo.store("avatars"); // moves to permanent storage
480
+ await User.query().where("id", this.userId).update({ avatarUrl: path });
481
+ this.flash("Avatar updated.", "success");
482
+ }
483
+
484
+ override async render() {
485
+ return (
486
+ <div class="space-y-4">
487
+ {this.photoUrl && <img src={this.photoUrl} class="h-24 w-24 rounded-full" />}
488
+
489
+ <FileUpload bind={this.photo} accept="image/*" maxSize="5mb" />
490
+
491
+ {this.photo && (
492
+ <div class="flex items-center gap-2">
493
+ <span>{this.photo.name}</span>
494
+ <button onClick={() => this.removeUpload("photo")}>✕</button>
495
+ </div>
496
+ )}
497
+
498
+ <button onClick={this.save} loadingAttr="disabled" class="btn-primary">
499
+ Save avatar
500
+ </button>
501
+ </div>
502
+ );
503
+ }
504
+ }
505
+ ```
506
+
507
+ For multiple files, use `multiple`:
508
+
509
+ ```tsx
510
+ @expose photos: TemporaryUploadedFile[] = [];
511
+
512
+ <FileUpload bind={this.photos} multiple accept="image/*" />
513
+
514
+ {/* Remove a specific file from the array: */}
515
+ <button onClick={() => this.removeUpload("photos", index)}>Remove</button>
516
+ ```
517
+
518
+ ## Alpine UI plugins
519
+
520
+ Common Alpine plugins are bundled and exposed as props:
521
+
522
+ ```tsx
523
+ {/* Input masking */}
524
+ <input mask="(999) 999-9999" value={this.phone} live />
525
+ <input mask="9999 9999 9999 9999" value={this.cardNumber} live />
526
+
527
+ {/* Focus trapping (modals, dialogs) */}
528
+ <div trap="$flow.modalOpen" class="modal">…</div>
529
+
530
+ {/* Height animation (pair with native x-show) */}
531
+ <button onClick={() => this.expanded = !this.expanded}>Toggle</button>
532
+ <div x-show="$flow.expanded" x-collapse>…</div>
533
+
534
+ {/* Floating positioning */}
535
+ <button x-ref="trigger">Options</button>
536
+ <div anchor="$refs.trigger" anchor.bottom class="dropdown">…</div>
537
+ ```
538
+
539
+ | Prop | Backed by | Effect |
540
+ | ------------------- | -------------------- | ------------------------------------------------------ |
541
+ | `mask="(999) …"` | `@alpinejs/mask` | Format an input as the user types |
542
+ | `trap="$flow.open"` | `@alpinejs/focus` | Trap focus while truthy; `$focus` magic also available |
543
+ | `collapse` | `@alpinejs/collapse` | Animate height — pair with native `x-show` |
544
+ | `anchor="$refs.x"` | `@alpinejs/anchor` | Float relative to another element |
545
+
546
+ **Persisted client state** — `@alpinejs/persist` is bundled, so `$persist` works in any Alpine scope:
547
+
548
+ ```tsx
549
+ <div x-data="{ sidebarCollapsed: $persist(false) }">…</div>
550
+ ```
551
+
552
+ ## Headless primitives
553
+
554
+ Unstyled, fully accessible interactive primitives that expose state through `data-*` attributes so you style them yourself with Tailwind variants or plain CSS.
555
+
556
+ ### Switch
557
+
558
+ An accessible on/off toggle (`role="switch"`, keyboard-operable). Style the on-state with `data-[checked]:…`:
559
+
560
+ ```tsx
561
+ import { Switch } from "@zerotal/flow";
562
+
563
+ <Switch
564
+ bind={this.notifications}
565
+ class="relative h-6 w-11 rounded-full bg-gray-700 transition data-[checked]:bg-indigo-600"
566
+ >
567
+ <span class="absolute h-4 w-4 rounded-full bg-white top-1 left-1 transition group-data-[checked]:translate-x-5" />
568
+ </Switch>;
569
+ ```
570
+
571
+ The Switch button is a Tailwind `group`, so the inner knob reacts with `group-data-[checked]:…`.
572
+
573
+ ### Checkbox bind
574
+
575
+ An accessible checkbox (`role="checkbox"`) bound to a boolean. Style the checked state with `data-[checked]:…`:
576
+
577
+ ```tsx
578
+ import { Checkbox } from "@zerotal/flow";
579
+
580
+ <Checkbox bind={this.agree} class="h-5 w-5 rounded border border-gray-700 data-[checked]:bg-indigo-600 data-[checked]:border-indigo-600">
581
+ <svg class="hidden data-[checked]:block w-3 h-3 text-white" viewBox="0 0 12 12">
582
+ <path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" fill="none" />
583
+ </svg>
584
+ </Checkbox>
585
+ <label>I agree to the terms</label>
586
+ ```
587
+
588
+ ### Select bind options
589
+
590
+ A styled native `<select>`. Fully accessible out of the box. Use `<Listbox>` only when you need custom option markup:
591
+
592
+ ```tsx
593
+ import { Select } from "@zerotal/flow";
594
+
595
+ <Select
596
+ bind={this.country}
597
+ placeholder="Select a country"
598
+ options={[
599
+ { label: "Canada", value: "ca" },
600
+ { label: "United States", value: "us" },
601
+ { label: "United Kingdom", value: "uk" },
602
+ ]}
603
+ class="select"
604
+ />;
605
+ ```
606
+
607
+ ### RadioGroup bind options
608
+
609
+ `role="radiogroup"` with arrow-key roving. Style the selected option with `data-[checked]:…`:
610
+
611
+ ```tsx
612
+ import { RadioGroup } from "@zerotal/flow";
613
+
614
+ <RadioGroup
615
+ bind={this.plan}
616
+ options={[
617
+ { label: "Starter — $9/mo", value: "starter" },
618
+ { label: "Pro — $29/mo", value: "pro" },
619
+ { label: "Team — $99/mo", value: "team" },
620
+ ]}
621
+ optionClass="flex items-center gap-2 px-4 py-3 rounded-lg border cursor-pointer data-[checked]:border-indigo-500 data-[checked]:bg-indigo-50"
622
+ />;
623
+ ```
624
+
625
+ ### Listbox bind options
626
+
627
+ A fully keyboard-navigable custom select. Arrow keys, Home/End, Enter/Escape, `aria-activedescendant`. `multiple` makes the value an array:
628
+
629
+ ```tsx
630
+ import { Listbox } from "@zerotal/flow";
631
+
632
+ <Listbox
633
+ bind={this.assignee}
634
+ placeholder="Unassigned"
635
+ options={users.map((u) => ({ label: u.name, value: u.id }))}
636
+ optionClass="flex items-center gap-2 px-3 py-2 data-[active]:bg-gray-800 data-[selected]:text-indigo-400"
637
+ />;
638
+ ```
639
+
640
+ States: `data-[selected]`, `data-[active]`, `data-[open]`.
641
+
642
+ ### Combobox bind options
643
+
644
+ An autocomplete input + filtered dropdown list. Two modes:
645
+
646
+ ```tsx
647
+ import { Combobox } from "@zerotal/flow";
648
+
649
+ {
650
+ /* Client filter — options rendered once, filtered locally as you type */
651
+ }
652
+ <Combobox bind={this.assigneeId} options={people} placeholder="Search teammates…" />;
653
+
654
+ {
655
+ /* Server filter — query syncs to an @expose prop and re-renders on each keystroke */
656
+ }
657
+ <Combobox
658
+ name="cityId"
659
+ queryName="citySearch"
660
+ bind={this.cityId}
661
+ query={this.citySearch}
662
+ options={this.citySuggestions}
663
+ placeholder="Search cities…"
664
+ />;
665
+ ```
666
+
667
+ States: `data-[active]`, `data-[selected]`.
668
+
669
+ ### Disclosure
670
+
671
+ A single collapsible section with proper `aria-expanded` / `aria-controls`. `data-open` is exposed on trigger and panel:
672
+
673
+ ```tsx
674
+ import { Disclosure } from "@zerotal/flow";
675
+
676
+ <Disclosure
677
+ label="Refund policy"
678
+ defaultOpen={false}
679
+ buttonClass="flex w-full justify-between px-4 py-3 font-medium"
680
+ panelClass="px-4 pb-4 text-gray-600"
681
+ >
682
+ Full refund within 30 days. No questions asked.
683
+ </Disclosure>;
684
+ ```
685
+
686
+ ### Accordion
687
+
688
+ A group of disclosures — single-open by default, or `multiple` to allow several expanded at once:
689
+
690
+ ```tsx
691
+ import { Accordion } from "@zerotal/flow";
692
+
693
+ <Accordion
694
+ items={[
695
+ { label: "Shipping", content: "Ships in 1–2 business days." },
696
+ { label: "Returns", content: "Free returns within 30 days." },
697
+ { label: "Warranty", content: "2-year manufacturer warranty." },
698
+ ]}
699
+ multiple
700
+ />;
701
+ ```
702
+
703
+ ### Popover
704
+
705
+ An anchored panel that opens on click and closes on click-outside or Escape. `data-open` is exposed for styling:
706
+
707
+ ```tsx
708
+ import { Popover } from "@zerotal/flow";
709
+
710
+ <Popover
711
+ label="Solutions ▾"
712
+ class="relative inline-block"
713
+ panelClass="absolute z-10 mt-2 w-48 bg-white shadow-lg rounded-lg"
714
+ >
715
+ <a href="/analytics" class="block px-4 py-2">
716
+ Analytics
717
+ </a>
718
+ <a href="/reports" class="block px-4 py-2">
719
+ Reports
720
+ </a>
721
+ </Popover>;
722
+ ```
723
+
724
+ ### Field / Label / Description
725
+
726
+ Accessibility glue around a single control. `<Field>` wires `for` / `id` / `aria-describedby` between the label, the control, the description, and the error — keeping screen-reader semantics correct without hand-wiring IDs:
727
+
728
+ ```tsx
729
+ import { Field } from "@zerotal/flow";
730
+
731
+ <Field label="Email" description="We'll never share it." error={this.errors.email}>
732
+ <input value={this.form.email} type="email" class="input" />
733
+ </Field>;
734
+ ```
735
+
736
+ All three props (`label`, `description`, `error`) are optional. You can also compose `<Label>` and `<Description>` as children for more control over layout.
737
+
738
+ ### Fieldset / Legend
739
+
740
+ Group related fields. A native `<fieldset disabled>` cascades the disabled state to every control inside:
741
+
742
+ ```tsx
743
+ import { Fieldset } from "@zerotal/flow";
744
+
745
+ <Fieldset legend="Billing address" disabled={this.saving}>
746
+ <Field label="Street">
747
+ <input value={this.form.street} class="input" />
748
+ </Field>
749
+ <Field label="City">
750
+ <input value={this.form.city} class="input" />
751
+ </Field>
752
+ </Fieldset>;
753
+ ```
754
+
755
+ ### Tooltip
756
+
757
+ Shows `content` on hover/focus of its children, wired with `aria-describedby` and `role="tooltip"`. Client-only:
758
+
759
+ ```tsx
760
+ import { Tooltip } from "@zerotal/flow";
761
+
762
+ <Tooltip content="Copy link to clipboard" placement="top">
763
+ <button onClick={this.copyLink}>🔗</button>
764
+ </Tooltip>;
765
+ ```
766
+
767
+ ## Why a page renders through the runtime
768
+
769
+ Most pages compile ahead of time to string concatenation. A page the compiler can't
770
+ handle renders through the runtime instead — slower, but identical output, and the
771
+ usual outcome for anything built from components. The boot line counts them:
772
+
773
+ ```text
774
+ [Flow] Compiled 4 page(s), 2 from cache, 3 bind-injected, 8 using runtime (76ms)
775
+ ```
776
+
777
+ To find out which pages those are and what stopped each one, set an env flag:
778
+
779
+ ```env
780
+ # .env
781
+ ZT_FLOW_COMPILE_LOG=1
782
+ ```
783
+
784
+ Every fallback then names itself, with the exact spot to look at:
785
+
786
+ ```text
787
+ [Flow] ListsPage renders through the runtime.
788
+ What stops it compiling:
789
+ app/showcase/flow/lists.tsx:83:10 `<Demo>` is a component, not an HTML element
790
+ → inline its markup here, or let this page render through the runtime
791
+ ```
792
+
793
+ It stays off by default because falling back is normal, not a defect. Turn it on
794
+ when you're chasing compilation for a hot page, or when a page reads `$flow` in a
795
+ value position — that combination is an error rather than a fallback, since the
796
+ runtime renderer evaluates JSX on the server where `$flow` doesn't exist.
797
+
798
+ ## CSP-safe mode
799
+
800
+ For environments with a strict `Content-Security-Policy` that omits `'unsafe-eval'`, enable CSP-safe mode with an env flag:
801
+
802
+ ```env
803
+ # .env
804
+ ZT_FLOW_CSP_SAFE=true
805
+ ```
806
+
807
+ When on, the client runtime swaps Alpine's evaluator for an eval-free interpreter, the bridge avoids `new Function`, and the AOT compiler emits only CSP-safe expressions:
808
+
809
+ ```tsx
810
+ <button onClick={() => this.count++}>+</button>
811
+ // standard mode → flow:click="() => $flow.count++"
812
+ // CSP mode → flow:click="$flow.count++"
813
+ ```
814
+
815
+ In CSP mode, **every page must AOT-compile** — an unsupported expression fails the build loudly rather than degrading silently.
816
+
817
+ **Supported in CSP mode:** `onClick={this.save}`, `onClick={() => this.count++}`, `value={this.x}`, `show={this.flag}`, member access (`$flow.user.name`), method calls with args, comparisons, arithmetic, ternaries, `&&`/`||`, string concat, array/object literals.
818
+
819
+ **Compile-time error (move to a server action):** arrow handlers that use the event (`(e) => …`), block-body arrows, template literals (`` `${x}` `` — use `a + b`), `.filter(i => …)`, computed access (`obj[key]`), spread.
820
+
821
+ ### Recommended CSP header
822
+
823
+ ```http
824
+ Content-Security-Policy:
825
+ default-src 'self';
826
+ script-src 'nonce-<random>' 'strict-dynamic';
827
+ style-src 'self' 'unsafe-inline';
828
+ ```
829
+
830
+ ### View Transitions
831
+
832
+ `navigate` links automatically wrap the page swap in the browser's [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) when supported, giving smooth cross-page animations for free. Style them with the standard `::view-transition-*` CSS:
833
+
834
+ ```css
835
+ ::view-transition-old(root) {
836
+ animation: fade-out 150ms ease;
837
+ }
838
+ ::view-transition-new(root) {
839
+ animation: fade-in 150ms ease;
840
+ }
841
+ ```
842
+
843
+ ## Next steps
844
+
845
+ - [Flow overview](/docs/flow) — the guide's front page and the rest of the sections.
846
+ - [Reference](/docs/flow/references) — every decorator, prop, and directive in one table.