@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.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- 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.
|