@zerotal/arch 1.7.3 → 1.7.5
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 +34 -2
- package/README.md +4 -2
- package/api-surface.md +4 -4
- package/docs/about.md +16 -16
- package/docs/admin/actions.md +9 -9
- package/docs/admin/auth.md +2 -2
- package/docs/admin/dashboard.md +5 -5
- package/docs/admin/extending-ui.md +12 -12
- package/docs/admin/extending.md +16 -7
- package/docs/admin/forms.md +4 -4
- package/docs/admin/index.md +2 -2
- package/docs/admin/operations.md +9 -9
- package/docs/admin/resources.md +3 -3
- package/docs/admin/structure.md +6 -6
- package/docs/admin/tables.md +8 -8
- package/docs/admin/testing.md +4 -4
- package/docs/ai.md +118 -11
- package/docs/application.md +12 -12
- package/docs/arch.md +25 -3
- package/docs/assets.md +11 -11
- package/docs/audit.md +13 -13
- package/docs/authentication.md +39 -39
- package/docs/authorization.md +13 -13
- package/docs/broadcasting/channels.md +2 -2
- package/docs/broadcasting/client.md +2 -2
- package/docs/broadcasting/events.md +9 -10
- package/docs/broadcasting/index.md +1 -1
- package/docs/broadcasting/testing.md +5 -5
- package/docs/cache.md +15 -13
- package/docs/carbon.md +35 -35
- package/docs/changelog.md +165 -1
- package/docs/client/index.md +277 -70
- package/docs/commands.md +4 -4
- package/docs/components.md +116 -115
- package/docs/config-system.md +4 -4
- package/docs/container.md +27 -27
- package/docs/context.md +27 -27
- package/docs/contributing.md +46 -0
- package/docs/controllers.md +10 -10
- package/docs/conventions.md +23 -15
- package/docs/cookies.md +3 -3
- package/docs/csrf.md +6 -6
- package/docs/database.md +22 -17
- package/docs/deployment.md +2 -2
- package/docs/devtools.md +16 -16
- package/docs/email-verification.md +8 -8
- package/docs/encryption.md +7 -7
- package/docs/errors.md +6 -6
- package/docs/events.md +12 -11
- package/docs/flow/components.md +32 -32
- package/docs/flow/decorators.md +18 -18
- package/docs/flow/events.md +16 -16
- package/docs/flow/forms.md +17 -17
- package/docs/flow/icons.md +199 -0
- package/docs/flow/index.md +20 -20
- package/docs/flow/layouts.md +35 -35
- package/docs/flow/lifecycle.md +16 -16
- package/docs/flow/models.md +9 -9
- package/docs/flow/pagination.md +8 -8
- package/docs/flow/performance.md +7 -7
- package/docs/flow/references.md +3 -3
- package/docs/flow/routing.md +26 -26
- package/docs/flow/testing.md +20 -20
- package/docs/getting-started.md +18 -13
- package/docs/health.md +17 -8
- package/docs/helpers.md +17 -17
- package/docs/i18n.md +10 -10
- package/docs/inertia/devtools.md +4 -4
- package/docs/inertia/index.md +12 -7
- package/docs/inertia/props.md +22 -22
- package/docs/inertia/rendering.md +86 -7
- package/docs/inertia/ssr.md +2 -2
- package/docs/lifecycle.md +2 -2
- package/docs/lock.md +11 -11
- package/docs/logger.md +12 -12
- package/docs/media.md +22 -22
- package/docs/middleware.md +17 -17
- package/docs/migrations.md +13 -13
- package/docs/monitor.md +6 -6
- package/docs/notifications.md +24 -24
- package/docs/orm/casts.md +14 -14
- package/docs/orm/factories.md +8 -8
- package/docs/orm/index.md +22 -20
- package/docs/orm/lifecycle.md +13 -13
- package/docs/orm/queries.md +28 -28
- package/docs/orm/relationships.md +16 -16
- package/docs/orm/serialization.md +11 -11
- package/docs/package-development.md +10 -10
- package/docs/pagination.md +14 -14
- package/docs/password-reset.md +6 -6
- package/docs/providers.md +11 -11
- package/docs/query-builder.md +18 -18
- package/docs/queue.md +14 -12
- package/docs/rate-limiting.md +6 -6
- package/docs/responses.md +14 -14
- package/docs/roles-and-2fa.md +9 -9
- package/docs/routing.md +44 -44
- package/docs/scaffolding.md +1 -1
- package/docs/scheduler.md +10 -10
- package/docs/seeding.md +6 -6
- package/docs/session.md +16 -14
- package/docs/social.md +14 -14
- package/docs/storage.md +25 -13
- package/docs/structure.md +2 -2
- package/docs/support-policy.md +36 -8
- package/docs/telemetry.md +12 -12
- package/docs/tenancy.md +34 -40
- package/docs/testing/browser.md +6 -6
- package/docs/testing/console.md +3 -3
- package/docs/testing/database.md +10 -10
- package/docs/testing/flow-browser.md +6 -6
- package/docs/testing/http.md +12 -12
- package/docs/testing/index.md +3 -3
- package/docs/testing/mocking.md +8 -8
- package/docs/upgrade.md +1 -1
- package/docs/validator.md +19 -19
- package/docs/view.md +14 -14
- package/package.json +4 -4
- package/src/index.ts +25 -1
- package/src/tools/searchDocs.ts +7 -0
- package/docs/client/auth.md +0 -113
- package/docs/client/errors.md +0 -139
- package/docs/client/files.md +0 -118
- package/docs/client/references.md +0 -58
- package/docs/client/requests.md +0 -131
- package/docs/client/resilience.md +0 -141
- package/docs/client/testing.md +0 -146
package/docs/flow/events.md
CHANGED
|
@@ -11,7 +11,7 @@ Flow components communicate by dispatching named events. Listeners registered wi
|
|
|
11
11
|
|
|
12
12
|
Call `this.dispatch()` inside any `@expose`d action. The event is delivered to all matching `@on` listeners on the page after the action completes:
|
|
13
13
|
|
|
14
|
-
```typescript
|
|
14
|
+
```typescript fragment
|
|
15
15
|
@expose async save(): Promise<void> {
|
|
16
16
|
const post = await Post.create({
|
|
17
17
|
title: this.title,
|
|
@@ -27,7 +27,7 @@ Call `this.dispatch()` inside any `@expose`d action. The event is delivered to a
|
|
|
27
27
|
|
|
28
28
|
The second argument is the payload — any JSON-serialisable value. Omit it for events that carry no data:
|
|
29
29
|
|
|
30
|
-
```typescript
|
|
30
|
+
```typescript fragment
|
|
31
31
|
this.dispatch("cart-cleared");
|
|
32
32
|
```
|
|
33
33
|
|
|
@@ -35,7 +35,7 @@ this.dispatch("cart-cleared");
|
|
|
35
35
|
|
|
36
36
|
By default, `dispatch()` notifies every `@on` listener on the page. Use `dispatchTo` or `dispatchSelf` to narrow the target:
|
|
37
37
|
|
|
38
|
-
```typescript
|
|
38
|
+
```typescript fragment
|
|
39
39
|
// Default: all @on("post-created") listeners on the page
|
|
40
40
|
this.dispatch("post-created", { id });
|
|
41
41
|
|
|
@@ -52,7 +52,7 @@ this.dispatchSelf("refresh");
|
|
|
52
52
|
|
|
53
53
|
Register a method as an event listener with `@on`. The method is **implicitly exposed** — no `@expose` needed:
|
|
54
54
|
|
|
55
|
-
```typescript
|
|
55
|
+
```typescript fragment
|
|
56
56
|
import { on } from "@zerotal/flow";
|
|
57
57
|
|
|
58
58
|
export class PostList extends Component {
|
|
@@ -95,7 +95,7 @@ A component can have as many `@on` listeners as it needs. Each fires independent
|
|
|
95
95
|
|
|
96
96
|
Several components on the same page can all listen for the same event. Each component is updated independently — Flow sends a separate patch frame to each listener:
|
|
97
97
|
|
|
98
|
-
```typescript
|
|
98
|
+
```typescript fragment
|
|
99
99
|
// PostList.tsx
|
|
100
100
|
@on("post-created")
|
|
101
101
|
async onPostCreated(data: { id: number }) {
|
|
@@ -121,7 +121,7 @@ All three components update after a single `this.dispatch("post-created", ...)`
|
|
|
121
121
|
|
|
122
122
|
The same `dispatch`, `dispatchTo`, and `dispatchSelf` methods work inside **client expressions** — no server round-trip needed to start the dispatch. The `@on` listeners still run server-side when they're notified:
|
|
123
123
|
|
|
124
|
-
```tsx
|
|
124
|
+
```tsx fragment
|
|
125
125
|
{
|
|
126
126
|
/* Notify all listeners without a preceding server action */
|
|
127
127
|
}
|
|
@@ -158,7 +158,7 @@ declare module "@zerotal/flow" {
|
|
|
158
158
|
|
|
159
159
|
Every `dispatch` / `dispatchTo` / `dispatchSelf` site is now checked against it — in server actions **and** client expressions, since they call the same typed method:
|
|
160
160
|
|
|
161
|
-
```ts
|
|
161
|
+
```ts fragment
|
|
162
162
|
this.dispatch("post-created", { id: post.id, title: post.title }); // ✓
|
|
163
163
|
this.dispatch("post-created", { id: post.id }); // ✗ missing `title`
|
|
164
164
|
this.dispatch("post-created"); // ✗ payload required
|
|
@@ -168,7 +168,7 @@ this.dispatch("cart-cleared", { anything: 1 }); // ✗ void takes no payload
|
|
|
168
168
|
|
|
169
169
|
On the listener side, `@on` autocompletes to the known event names, and you annotate the handler's parameter with `EventPayload<K>` to type the payload against the same contract:
|
|
170
170
|
|
|
171
|
-
```ts
|
|
171
|
+
```ts fragment
|
|
172
172
|
import { on } from "@zerotal/flow";
|
|
173
173
|
import type { EventPayload } from "@zerotal/flow";
|
|
174
174
|
|
|
@@ -235,7 +235,7 @@ Listen for server-broadcast events over WebSockets with `@on("socket:…")`. Whe
|
|
|
235
235
|
|
|
236
236
|
The socket client is bundled into the Flow runtime and created the first time a page declares one of these listeners, so there is no script to add and nothing to publish on `window`. An app that needs a configured client — a different host, its own auth endpoint — assigns `window.Socket` before the runtime loads and that one is used instead. Pages with no such listener open no broadcast connection at all.
|
|
237
237
|
|
|
238
|
-
```typescript
|
|
238
|
+
```typescript fragment
|
|
239
239
|
export class OrderDashboard extends Component {
|
|
240
240
|
@locked orderCount: number = 0;
|
|
241
241
|
@locked recentOrders: Order[] = [];
|
|
@@ -299,7 +299,7 @@ Pass a resolver instead. It is called with the component when the snapshot is bu
|
|
|
299
299
|
[`@presence`](#presence--whos-here-multiplayer) and [`@shared`](#shared-state--everyone-converges-multiplayer)
|
|
300
300
|
resolve theirs:
|
|
301
301
|
|
|
302
|
-
```typescript
|
|
302
|
+
```typescript fragment
|
|
303
303
|
export class IssuePage extends Component {
|
|
304
304
|
@locked issue!: Issue;
|
|
305
305
|
@locked comments: Comment[] = [];
|
|
@@ -326,7 +326,7 @@ Broadcasting requires a global `window.Socket` client configured by your applica
|
|
|
326
326
|
|
|
327
327
|
If `window.Socket` is not present, all `socket:` listeners are silently inert — no errors, no subscriptions attempted.
|
|
328
328
|
|
|
329
|
-
```typescript
|
|
329
|
+
```typescript fragment
|
|
330
330
|
// In your frontend bootstrap (app.ts or similar):
|
|
331
331
|
import { Socket } from "@zerotal/client";
|
|
332
332
|
|
|
@@ -339,7 +339,7 @@ window.Socket = new Socket();
|
|
|
339
339
|
|
|
340
340
|
`@presence` binds a property to a broadcast **presence channel** and keeps it filled with the live member list — the framework joins the channel, seeds the list, and refreshes it as people join and leave. No event classes, no manual `@on` wiring:
|
|
341
341
|
|
|
342
|
-
```tsx
|
|
342
|
+
```tsx fragment
|
|
343
343
|
import { Component, presence } from "@zerotal/flow";
|
|
344
344
|
import type { PresenceMember } from "@zerotal/flow";
|
|
345
345
|
|
|
@@ -362,7 +362,7 @@ export class Board extends Component {
|
|
|
362
362
|
|
|
363
363
|
The channel is resolved on the server from the component (so it can't be forged from the client) and carried, signed, in the snapshot. `who` is server-controlled (like `@locked`): it lives in the snapshot and the client can't write it. Authorize the channel — and shape the member data — in `routes/channels.ts`:
|
|
364
364
|
|
|
365
|
-
```ts
|
|
365
|
+
```ts fragment
|
|
366
366
|
Broadcast.channel("board.[boardId]", (user, boardId) =>
|
|
367
367
|
user.canView(boardId) ? { id: user.id, name: user.name } : null,
|
|
368
368
|
);
|
|
@@ -385,7 +385,7 @@ Like all `socket:` features, presence needs a `window.Socket` client configured
|
|
|
385
385
|
|
|
386
386
|
Where `@presence` answers _who's here_, `@shared` answers _what do we all see_. It binds a property to convergent, **server-authoritative** state on a channel: mutate it in an action and the framework writes it to a per-channel **room store** and broadcasts to the channel, so every other subscriber re-reads and converges. No store to wire, no events, no dispatch:
|
|
387
387
|
|
|
388
|
-
```tsx
|
|
388
|
+
```tsx fragment
|
|
389
389
|
import { Component, presence, shared, expose } from "@zerotal/flow";
|
|
390
390
|
|
|
391
391
|
export class Board extends Component {
|
|
@@ -416,7 +416,7 @@ Broadcasting is an **optional peer**. With `window.Socket` and `BroadcastProvide
|
|
|
416
416
|
|
|
417
417
|
Calling `this.refresh()` inside an action inserts `onMount()` back into the WebSocket round-trip cycle:
|
|
418
418
|
|
|
419
|
-
```typescript
|
|
419
|
+
```typescript fragment
|
|
420
420
|
@expose async syncOrders(): Promise<void> {
|
|
421
421
|
await OrderSync.run();
|
|
422
422
|
this.refresh(); // onMount() re-runs → this.orders is freshly loaded
|
|
@@ -434,7 +434,7 @@ Calling `this.refresh()` inside an action inserts `onMount()` back into the WebS
|
|
|
434
434
|
|
|
435
435
|
In the browser, `this.refresh()` sends a lightweight re-render request with no data change — useful for polling or a manual reload button:
|
|
436
436
|
|
|
437
|
-
```tsx
|
|
437
|
+
```tsx fragment
|
|
438
438
|
{
|
|
439
439
|
/* Manual reload button */
|
|
440
440
|
}
|
package/docs/flow/forms.md
CHANGED
|
@@ -11,7 +11,7 @@ Validate input, bind form fields to component state with two-way binding, encaps
|
|
|
11
11
|
|
|
12
12
|
Attach rules with `@validate` on individual fields, or pass them explicitly to `this.validate()`:
|
|
13
13
|
|
|
14
|
-
```typescript
|
|
14
|
+
```typescript fragment
|
|
15
15
|
import { expose, validate } from "@zerotal/flow";
|
|
16
16
|
|
|
17
17
|
@expose @validate((rule) => rule.required().email()) email: string = "";
|
|
@@ -29,7 +29,7 @@ builder (`rule.number()`, …). The same `@validate` rule also powers
|
|
|
29
29
|
|
|
30
30
|
Call `this.validate()` at the start of your action. It reads the `@validate` rules and throws a `ValidationError` if any fail — Flow catches it, populates `this.errors`, and re-renders:
|
|
31
31
|
|
|
32
|
-
```typescript
|
|
32
|
+
```typescript fragment
|
|
33
33
|
@expose async register(): Promise<void> {
|
|
34
34
|
await this.validate(); // uses @validate rules declared on the class
|
|
35
35
|
|
|
@@ -46,7 +46,7 @@ Call `this.validate()` at the start of your action. It reads the `@validate` rul
|
|
|
46
46
|
|
|
47
47
|
Pass explicit rules to override or extend `@validate`:
|
|
48
48
|
|
|
49
|
-
```typescript
|
|
49
|
+
```typescript fragment
|
|
50
50
|
@expose async update(): Promise<void> {
|
|
51
51
|
await this.validate({
|
|
52
52
|
email: (rule) => rule.required().email(),
|
|
@@ -83,7 +83,7 @@ Pass explicit rules to override or extend `@validate`:
|
|
|
83
83
|
|
|
84
84
|
Pass a field off `this.errors` to the `error` prop. It renders the first message for that field and hides itself when the field is valid — no manual show/hide logic:
|
|
85
85
|
|
|
86
|
-
```tsx
|
|
86
|
+
```tsx fragment
|
|
87
87
|
<input value={this.email} />
|
|
88
88
|
<span error={this.errors.email} class="text-sm text-red-500" />
|
|
89
89
|
|
|
@@ -93,7 +93,7 @@ Pass a field off `this.errors` to the `error` prop. It renders the first message
|
|
|
93
93
|
|
|
94
94
|
Use the `<Field>` component for accessible label + error wiring:
|
|
95
95
|
|
|
96
|
-
```tsx
|
|
96
|
+
```tsx fragment
|
|
97
97
|
import { Field } from "@zerotal/flow";
|
|
98
98
|
|
|
99
99
|
<Field label="Email" error={this.errors.email}>
|
|
@@ -107,7 +107,7 @@ import { Field } from "@zerotal/flow";
|
|
|
107
107
|
|
|
108
108
|
Show all errors at once with `<Errors>`:
|
|
109
109
|
|
|
110
|
-
```tsx
|
|
110
|
+
```tsx fragment
|
|
111
111
|
import { Errors } from "@zerotal/flow";
|
|
112
112
|
|
|
113
113
|
<Errors /> {/* all current errors */}
|
|
@@ -116,7 +116,7 @@ import { Errors } from "@zerotal/flow";
|
|
|
116
116
|
|
|
117
117
|
### Checking errors in server code
|
|
118
118
|
|
|
119
|
-
```typescript
|
|
119
|
+
```typescript fragment
|
|
120
120
|
// Check if any errors exist
|
|
121
121
|
if (this.errors.any()) {
|
|
122
122
|
return;
|
|
@@ -131,7 +131,7 @@ if (this.errors.has("email")) {
|
|
|
131
131
|
|
|
132
132
|
### Manual errors
|
|
133
133
|
|
|
134
|
-
```typescript
|
|
134
|
+
```typescript fragment
|
|
135
135
|
this.addError("email", "That email address is already taken.");
|
|
136
136
|
this.addError("username", "Username must be unique.");
|
|
137
137
|
|
|
@@ -143,7 +143,7 @@ this.resetValidation("email"); // clear one field
|
|
|
143
143
|
|
|
144
144
|
Pass state to `value` (or `checked`). Flow makes it two-way for `@expose` properties and read-only for `@locked` ones — no helper needed:
|
|
145
145
|
|
|
146
|
-
```tsx
|
|
146
|
+
```tsx fragment
|
|
147
147
|
{/* Text inputs */}
|
|
148
148
|
<input value={this.name} />
|
|
149
149
|
<input value={this.email} />
|
|
@@ -194,7 +194,7 @@ export class LoginForm extends Form {
|
|
|
194
194
|
|
|
195
195
|
Mount the form on a component:
|
|
196
196
|
|
|
197
|
-
```typescript
|
|
197
|
+
```typescript fragment
|
|
198
198
|
export class LoginPage extends Component {
|
|
199
199
|
@expose form = new LoginForm();
|
|
200
200
|
|
|
@@ -219,7 +219,7 @@ export class LoginPage extends Component {
|
|
|
219
219
|
|
|
220
220
|
Bind fields with nested `value={this.form.email}`:
|
|
221
221
|
|
|
222
|
-
```tsx
|
|
222
|
+
```tsx fragment
|
|
223
223
|
<form onSubmit={this.login} class="space-y-4">
|
|
224
224
|
<Field label="Email" error={this.errors.email}>
|
|
225
225
|
<input value={this.form.email} type="email" live class="input" />
|
|
@@ -242,7 +242,7 @@ Bind fields with nested `value={this.form.email}`:
|
|
|
242
242
|
|
|
243
243
|
### Form helpers
|
|
244
244
|
|
|
245
|
-
```typescript
|
|
245
|
+
```typescript fragment
|
|
246
246
|
// Get all field values as a plain object
|
|
247
247
|
const data = this.form.data();
|
|
248
248
|
// { email: "alice@example.com", password: "…", remember: true }
|
|
@@ -273,7 +273,7 @@ See [Pagination](/docs/flow/pagination) for the full guide — `paginate()`, `Pa
|
|
|
273
273
|
|
|
274
274
|
Quick reference for in-memory pagination:
|
|
275
275
|
|
|
276
|
-
```typescript
|
|
276
|
+
```typescript fragment
|
|
277
277
|
import { paginate } from "@zerotal/flow";
|
|
278
278
|
|
|
279
279
|
export class PostsPage extends Component {
|
|
@@ -342,7 +342,7 @@ export class PostsPage extends Component {
|
|
|
342
342
|
|
|
343
343
|
Compose `Pagination` to get the page state and navigation methods automatically — no boilerplate:
|
|
344
344
|
|
|
345
|
-
```tsx
|
|
345
|
+
```tsx fragment
|
|
346
346
|
import { Component, Pagination } from "@zerotal/flow";
|
|
347
347
|
|
|
348
348
|
export class PostsPage extends Component.using(Pagination) {
|
|
@@ -404,7 +404,7 @@ export class PostsPage extends Component.using(Pagination) {
|
|
|
404
404
|
|
|
405
405
|
Reset the page when a filter changes to avoid showing an empty page:
|
|
406
406
|
|
|
407
|
-
```typescript
|
|
407
|
+
```typescript fragment
|
|
408
408
|
@expose async applyFilter(status: string): Promise<void> {
|
|
409
409
|
this.filter = status;
|
|
410
410
|
this.resetPage(); // go back to page 1
|
|
@@ -413,7 +413,7 @@ Reset the page when a filter changes to avoid showing an empty page:
|
|
|
413
413
|
|
|
414
414
|
Compose with other mixins:
|
|
415
415
|
|
|
416
|
-
```typescript
|
|
416
|
+
```typescript fragment
|
|
417
417
|
// `Sorting` here is a mixin you author yourself (see Layouts & Composition);
|
|
418
418
|
// `Pagination` is the one shipped by Flow.
|
|
419
419
|
export class PostsPage extends Component.using(Sorting, Pagination) {
|
|
@@ -425,7 +425,7 @@ export class PostsPage extends Component.using(Sorting, Pagination) {
|
|
|
425
425
|
|
|
426
426
|
For database queries, skip the in-memory `paginate()` and use the ORM query builder directly. Combine with `@url page`:
|
|
427
427
|
|
|
428
|
-
```typescript
|
|
428
|
+
```typescript fragment
|
|
429
429
|
export class PostsPage extends Component {
|
|
430
430
|
@url page = 1;
|
|
431
431
|
@locked posts: Post[] = [];
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Icons
|
|
3
|
+
description: 2,060 icons bundled with Flow's component library — typed by name, rendered on the server, nothing to install.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Icons
|
|
7
|
+
|
|
8
|
+
`<Icon>` draws an icon by name. The set ships inside `@zerotal/flow-ui`, so this
|
|
9
|
+
works in a new app with nothing installed and nothing configured:
|
|
10
|
+
|
|
11
|
+
```tsx fragment
|
|
12
|
+
import { Icon } from "@zerotal/flow-ui";
|
|
13
|
+
|
|
14
|
+
<Icon name="inbox" />
|
|
15
|
+
<Icon name="chevron-right" />
|
|
16
|
+
<Icon name="trash-2" class="size-5 text-red-600" />
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The name is a union of every bundled icon, so a typo is a compile error rather
|
|
20
|
+
than a blank space nobody notices until it is in front of a user:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
Type '"inbxo"' is not assignable to type 'IconName'. Did you mean '"inbox"'?
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
That works on install — there is no generator to run first. The icons belong to
|
|
27
|
+
the framework, so the names are known before your app exists.
|
|
28
|
+
|
|
29
|
+
## Props
|
|
30
|
+
|
|
31
|
+
`IconProps` — anything else you pass lands on the rendered `<svg>`.
|
|
32
|
+
|
|
33
|
+
| Prop | Type | Description |
|
|
34
|
+
| ------- | ---------- | ------------------------------------------------------------------------------ |
|
|
35
|
+
| `name` | `IconName` | Which icon. Checked at compile time against the bundled and registered names. |
|
|
36
|
+
| `label` | `string` | Accessible name. Omit for decoration — the icon is hidden from screen readers. |
|
|
37
|
+
| `class` | `string` | Merged with the defaults rather than replacing them. |
|
|
38
|
+
|
|
39
|
+
## Sizing and colour
|
|
40
|
+
|
|
41
|
+
An icon is `1em` square and painted in `currentColor`, so by default it matches
|
|
42
|
+
the text it sits beside — size, weight of colour, and all. Override with classes
|
|
43
|
+
rather than attributes:
|
|
44
|
+
|
|
45
|
+
```tsx fragment
|
|
46
|
+
<p class="text-sm text-slate-600">
|
|
47
|
+
<Icon name="info" /> Saved a moment ago
|
|
48
|
+
</p>
|
|
49
|
+
|
|
50
|
+
<Icon name="triangle-alert" class="size-8 text-amber-500" />
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Sizing through CSS is what lets an icon line up with a label without either being
|
|
54
|
+
measured. `class="size-5"` sets both dimensions; `text-red-600` on the icon — or
|
|
55
|
+
on anything above it — colours it.
|
|
56
|
+
|
|
57
|
+
## Labelling
|
|
58
|
+
|
|
59
|
+
An icon is decoration by default and hidden from screen readers, which is right
|
|
60
|
+
when it sits next to text that already says the same thing. Announcing it there
|
|
61
|
+
would read the meaning out twice.
|
|
62
|
+
|
|
63
|
+
An icon that is the **only** content of a control is not decoration. Without a
|
|
64
|
+
label, that button has no accessible name at all:
|
|
65
|
+
|
|
66
|
+
```tsx fragment
|
|
67
|
+
<button onClick={this.remove}>
|
|
68
|
+
<Icon name="trash-2" label="Delete order" />
|
|
69
|
+
</button>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## A name that isn't known until runtime
|
|
73
|
+
|
|
74
|
+
A name from a database column or a URL segment is not a literal, so it does not
|
|
75
|
+
satisfy the union. `isIconName()` narrows it:
|
|
76
|
+
|
|
77
|
+
```tsx fragment
|
|
78
|
+
import { Icon, isIconName } from "@zerotal/flow-ui";
|
|
79
|
+
|
|
80
|
+
override async render() {
|
|
81
|
+
const glyph = this.status.icon; // string, from a row
|
|
82
|
+
return isIconName(glyph) ? <Icon name={glyph} /> : <Icon name="circle-help" />;
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
It is a shape check, not an existence check — it says the string could name an
|
|
87
|
+
icon, not that anything answers to it. An icon that resolves to nothing renders
|
|
88
|
+
nothing rather than throwing, because taking a page down over a missing glyph is
|
|
89
|
+
the worse failure.
|
|
90
|
+
|
|
91
|
+
## Drawn for the gaps
|
|
92
|
+
|
|
93
|
+
Four names are drawn here rather than coming from the set, because the flows they
|
|
94
|
+
label are ones Zerotal ships and the set has no icon for as a concept:
|
|
95
|
+
|
|
96
|
+
| Name | For |
|
|
97
|
+
| ------------ | -------------------------------------------------------------- |
|
|
98
|
+
| `passkey` | WebAuthn sign-in — a fingerprint that ends in a key |
|
|
99
|
+
| `two-factor` | TOTP — a second device that has to agree |
|
|
100
|
+
| `otp` | An emailed one-time code — the separate slots it is typed into |
|
|
101
|
+
| `magic-link` | Passwordless sign-in by link |
|
|
102
|
+
|
|
103
|
+
The set has `key-round`, `fingerprint` and `shield-check` — the parts — and a login
|
|
104
|
+
page needs the whole. They are drawn on the same 24×24 stroke grid, so they sit
|
|
105
|
+
beside the other 2,060 without announcing themselves.
|
|
106
|
+
|
|
107
|
+
Nearly everything else that looked missing was there under a name that reads
|
|
108
|
+
differently: `git-branch` not `branch`, `file-json` not `json`, `paperclip` not
|
|
109
|
+
`attachment`, `venetian-mask` for impersonation. Search before you draw.
|
|
110
|
+
|
|
111
|
+
## Brand marks
|
|
112
|
+
|
|
113
|
+
Three sign-in providers ship as brand marks, because `@zerotal/auth` has a code
|
|
114
|
+
path for each and a sign-in button wants the provider's actual logo:
|
|
115
|
+
|
|
116
|
+
```tsx fragment
|
|
117
|
+
<button><Icon name="brand-google" /> Continue with Google</button>
|
|
118
|
+
<button><Icon name="brand-github" /> Continue with GitHub</button>
|
|
119
|
+
<button><Icon name="brand-apple" /> Continue with Apple</button>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
They come from [Simple Icons](https://simpleicons.org) (**CC0-1.0**, public
|
|
123
|
+
domain), so the paths are the real ones rather than approximations — an
|
|
124
|
+
approximated logo reads as a forgery, not as an icon.
|
|
125
|
+
|
|
126
|
+
The `brand-` prefix is deliberate: the bundled set has its own stroke-style
|
|
127
|
+
`github` and `apple`, and prefixing means neither silently shadows the other, so a
|
|
128
|
+
page picks a style rather than inheriting one. There is no plain `google` — the
|
|
129
|
+
set never had one, which is what made this worth doing.
|
|
130
|
+
|
|
131
|
+
Unlike the rest, brand marks are **solid**: each body carries its own
|
|
132
|
+
`fill="currentColor"`, so it still takes its colour from the text around it.
|
|
133
|
+
|
|
134
|
+
> **CC0 covers copyright, not trademark.** The marks belong to their owners.
|
|
135
|
+
> Labelling a sign-in button with one is nominative use and what brand guidelines
|
|
136
|
+
> contemplate; using one as your own logo is not. For a provider not listed here,
|
|
137
|
+
> `registerIcons()` keeps that decision — and its licence — yours.
|
|
138
|
+
|
|
139
|
+
## Your own icons
|
|
140
|
+
|
|
141
|
+
A wordmark, a product glyph, a shape nobody has drawn: register it once, from a
|
|
142
|
+
provider's `register()`, and it is available everywhere `<Icon>` is.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { registerIcons } from "@zerotal/flow-ui";
|
|
146
|
+
|
|
147
|
+
registerIcons({
|
|
148
|
+
"acme-wordmark": {
|
|
149
|
+
body: '<path fill="currentColor" d="M4 4h16v16H4z"/>',
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Each entry is an `IconBody` — the markup that goes **inside** the `<svg>`, plus an
|
|
155
|
+
optional `width`/`height` when it was drawn against a box other than 24×24. A name
|
|
156
|
+
you register shadows a bundled one, which is how you substitute your own drawing
|
|
157
|
+
without renaming every call site.
|
|
158
|
+
|
|
159
|
+
Registering supplies the body; the compiler needs telling separately. Declare the
|
|
160
|
+
names on `CustomIconRegistry` and they join the same union as the bundled ones —
|
|
161
|
+
`IconName` widens, and `CustomIconName` is the set you added:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
declare module "@zerotal/flow-ui" {
|
|
165
|
+
interface CustomIconRegistry {
|
|
166
|
+
"acme-wordmark": true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
> **The body is inserted as markup, not text.** Register only SVG you control.
|
|
172
|
+
> A body built from user input is the same hole as any other unescaped HTML.
|
|
173
|
+
|
|
174
|
+
### Matching the set
|
|
175
|
+
|
|
176
|
+
Icons drawn to a different grid look wrong beside ones that aren't. The bundled
|
|
177
|
+
set is 24×24 **stroke**: no fills, `stroke="currentColor"`, `stroke-width="2"`,
|
|
178
|
+
round caps and joins. Copy the shape of an existing icon rather than exporting
|
|
179
|
+
from a design tool, which will hand you absolute fills on a half-pixel grid.
|
|
180
|
+
|
|
181
|
+
## What ships, and why it can
|
|
182
|
+
|
|
183
|
+
The bundled set is [Lucide](https://lucide.dev), which is ISC-licensed — the
|
|
184
|
+
reason it can be shipped inside the package at all. Redistributing it carries a
|
|
185
|
+
notice (`LICENSE-ICONS.md` in `@zerotal/flow-ui`) and asks nothing of your
|
|
186
|
+
application's UI.
|
|
187
|
+
|
|
188
|
+
Most sets are not so simple. Font Awesome Free is CC BY 4.0 — usable, and only
|
|
189
|
+
with attribution _you_ would have to display — and Font Awesome Pro may not be
|
|
190
|
+
redistributed at any price. Bundling either would relicense someone else's artwork
|
|
191
|
+
on behalf of every app that installed Flow. If you are entitled to a set we cannot
|
|
192
|
+
ship, `registerIcons()` is how you bring it: your artwork, your licence.
|
|
193
|
+
|
|
194
|
+
## Cost
|
|
195
|
+
|
|
196
|
+
None on the client. Flow renders on the server, so an icon reaches the browser as
|
|
197
|
+
markup that is already in the page — no icon font, no sprite sheet, no request per
|
|
198
|
+
glyph, and nothing for a strict [Content Security Policy](/docs/flow/performance)
|
|
199
|
+
to block. The set is read once per process and never sent.
|
package/docs/flow/index.md
CHANGED
|
@@ -93,7 +93,7 @@ middleware is a `_middleware.ts` in the page tree. It stacks from the root down,
|
|
|
93
93
|
covers every page beneath it, and — being route middleware — re-runs on every
|
|
94
94
|
action with nothing else to configure:
|
|
95
95
|
|
|
96
|
-
```typescript
|
|
96
|
+
```typescript fragment
|
|
97
97
|
// app/flow/pages/_middleware.ts
|
|
98
98
|
import { TenantMiddleware } from "../../middleware/TenantMiddleware.ts";
|
|
99
99
|
|
|
@@ -107,7 +107,7 @@ guards the pages inside it without changing their URLs. See
|
|
|
107
107
|
Reach for `persistentMiddleware` only when the middleware is genuinely **global** —
|
|
108
108
|
registered app-wide with `Application.use()` because non-Flow routes need it too:
|
|
109
109
|
|
|
110
|
-
```typescript
|
|
110
|
+
```typescript fragment
|
|
111
111
|
// app/providers/AppServiceProvider.ts — inside onRegister():
|
|
112
112
|
FlowProvider.persistMiddleware(TenantMiddleware);
|
|
113
113
|
```
|
|
@@ -172,7 +172,7 @@ The distinction is the syntax: a named method reference is always a server actio
|
|
|
172
172
|
|
|
173
173
|
Register the route:
|
|
174
174
|
|
|
175
|
-
```typescript
|
|
175
|
+
```typescript fragment
|
|
176
176
|
// routes/web.ts
|
|
177
177
|
import { Router } from "zerotal";
|
|
178
178
|
import { CounterPage } from "./components/CounterPage.tsx";
|
|
@@ -239,7 +239,7 @@ State that the server should never see — a hover flag, which accordion section
|
|
|
239
239
|
|
|
240
240
|
A list with per-row actions needs to tell the server _which_ row. Write the call as you would expect, and Flow compiles the arguments into the markup:
|
|
241
241
|
|
|
242
|
-
```tsx
|
|
242
|
+
```tsx fragment
|
|
243
243
|
{
|
|
244
244
|
this.enquiries.map((row) => (
|
|
245
245
|
<tr>
|
|
@@ -254,7 +254,7 @@ A list with per-row actions needs to tell the server _which_ row. Write the call
|
|
|
254
254
|
|
|
255
255
|
The arguments are evaluated **on the server, during the render** — where `row` exists — and travel with the action as `data-args`. Your action receives them as ordinary parameters:
|
|
256
256
|
|
|
257
|
-
```ts
|
|
257
|
+
```ts fragment
|
|
258
258
|
@expose async archive(id: number) {
|
|
259
259
|
await Enquiry.findOrFail(id).archive();
|
|
260
260
|
}
|
|
@@ -262,7 +262,7 @@ The arguments are evaluated **on the server, during the render** — where `row`
|
|
|
262
262
|
|
|
263
263
|
You can also write `data-args` yourself, which is useful when the handler is built dynamically:
|
|
264
264
|
|
|
265
|
-
```tsx
|
|
265
|
+
```tsx fragment
|
|
266
266
|
<button onClick={this.archive} data-args={JSON.stringify([row.id])}>
|
|
267
267
|
Archive
|
|
268
268
|
</button>
|
|
@@ -280,7 +280,7 @@ By contrast, a handler pointing at a method you forgot to `@expose` **is** a har
|
|
|
280
280
|
|
|
281
281
|
Bind an input by passing state straight to `value` (or `checked`). Flow wires up two-way binding when the property is `@expose`, and read-only reflection when it's `@locked`:
|
|
282
282
|
|
|
283
|
-
```tsx
|
|
283
|
+
```tsx fragment
|
|
284
284
|
<input value={this.name} /> {/* @expose → two-way */}
|
|
285
285
|
<input type="checkbox" checked={this.agree} /> {/* @expose → two-way */}
|
|
286
286
|
<input value={this.ownerName} /> {/* @locked → read-only */}
|
|
@@ -288,7 +288,7 @@ Bind an input by passing state straight to `value` (or `checked`). Flow wires up
|
|
|
288
288
|
|
|
289
289
|
A **radio group** is bound as a unit rather than per input, because every option writes the same property. Pass the option's own value as a second argument to `bind()`:
|
|
290
290
|
|
|
291
|
-
```tsx
|
|
291
|
+
```tsx fragment
|
|
292
292
|
{
|
|
293
293
|
["CUSTOM", "ROUTE", "TEAMS"].map((t) => (
|
|
294
294
|
<label>
|
|
@@ -302,7 +302,7 @@ Each option renders with the shared `flow:model="type"`, its own `value`, and `c
|
|
|
302
302
|
|
|
303
303
|
By default the value stays **local** — it updates the DOM instantly and is flushed to the server with your next action. Add `live` to sync to the server as you type, or `blur` to sync when the input loses focus:
|
|
304
304
|
|
|
305
|
-
```tsx
|
|
305
|
+
```tsx fragment
|
|
306
306
|
<input value={this.draft} /> {/* local; flushed with the next action */}
|
|
307
307
|
<input value={this.search} live /> {/* syncs to the server as you type */}
|
|
308
308
|
<input value={this.title} blur /> {/* syncs to the server on blur */}
|
|
@@ -312,20 +312,20 @@ By default the value stays **local** — it updates the DOM instantly and is flu
|
|
|
312
312
|
|
|
313
313
|
Two more modifiers clean the value at the edge, so the server never sees a numeric string or stray whitespace and you write no coercion in `onUpdated`:
|
|
314
314
|
|
|
315
|
-
```tsx
|
|
315
|
+
```tsx fragment
|
|
316
316
|
<input type="number" value={this.age} number /> {/* the bound value is a real number, not "42" */}
|
|
317
317
|
<input value={this.name} trim /> {/* whitespace stripped before it syncs */}
|
|
318
318
|
```
|
|
319
319
|
|
|
320
320
|
Add `draft="key"` to keep an unsubmitted value across a reload or crash — it mirrors to `localStorage` and restores on mount (only when the field is empty, so server content always wins), then clears itself once the server empties the field after a successful submit. It's a client-side safety net; the server snapshot stays the authority:
|
|
321
321
|
|
|
322
|
-
```tsx
|
|
322
|
+
```tsx fragment
|
|
323
323
|
<textarea value={this.body} draft="post-body" /> {/* survives a refresh until you submit */}
|
|
324
324
|
```
|
|
325
325
|
|
|
326
326
|
And two focus helpers close the loop after a re-render, where focus is easily lost:
|
|
327
327
|
|
|
328
|
-
```tsx
|
|
328
|
+
```tsx fragment
|
|
329
329
|
<input value={this.email} autoFocus /> {/* focus on mount (won't steal focus you've placed) */}
|
|
330
330
|
<input value={this.email} focusOnError /> {/* after a failed submit, focus jumps to the first invalid field */}
|
|
331
331
|
```
|
|
@@ -336,7 +336,7 @@ And two focus helpers close the loop after a re-render, where focus is easily lo
|
|
|
336
336
|
|
|
337
337
|
Pass a field off `this.errors` to the `error` prop and Flow renders that field's first validation message reactively — it appears when the field is invalid and clears when it's fixed:
|
|
338
338
|
|
|
339
|
-
```tsx
|
|
339
|
+
```tsx fragment
|
|
340
340
|
<input value={this.email} />
|
|
341
341
|
<span error={this.errors.email} class="text-red-500" />
|
|
342
342
|
```
|
|
@@ -347,7 +347,7 @@ No `errors.has(...)` checks, no manual show/hide.
|
|
|
347
347
|
|
|
348
348
|
A `className`/`class` (or `style`, `href`, …) that depends on `@expose` or `@locked` state compiles to a reactive client binding, so it updates without a round-trip:
|
|
349
349
|
|
|
350
|
-
```tsx
|
|
350
|
+
```tsx fragment
|
|
351
351
|
<span className={this.count > 10 ? "text-emerald-400" : "text-white"}>
|
|
352
352
|
{this.count}
|
|
353
353
|
</span>
|
|
@@ -361,7 +361,7 @@ A `className`/`class` (or `style`, `href`, …) that depends on `@expose` or `@l
|
|
|
361
361
|
|
|
362
362
|
Use normal TypeScript control flow in `render()` — `.map()`, ternaries, and `&&` are all supported:
|
|
363
363
|
|
|
364
|
-
```tsx
|
|
364
|
+
```tsx fragment
|
|
365
365
|
override async render() {
|
|
366
366
|
return (
|
|
367
367
|
<div>
|
|
@@ -389,7 +389,7 @@ Always provide a `key` when mapping over items — the morph algorithm uses it t
|
|
|
389
389
|
|
|
390
390
|
Common interaction states are first-class props:
|
|
391
391
|
|
|
392
|
-
```tsx
|
|
392
|
+
```tsx fragment
|
|
393
393
|
{/* Disable the button while the action is in flight */}
|
|
394
394
|
<button onClick={this.save} loadingAttr="disabled">Save</button>
|
|
395
395
|
|
|
@@ -420,7 +420,7 @@ Common interaction states are first-class props:
|
|
|
420
420
|
|
|
421
421
|
Add `transition` to animate the show/hide instead of an instant flip — a single prop covers **both** enter and leave (the leave half plain `show=` can't do, since the element would otherwise vanish before any animation runs):
|
|
422
422
|
|
|
423
|
-
```tsx
|
|
423
|
+
```tsx fragment
|
|
424
424
|
<div show={this.modal} transition class="modal">…</div> {/* default: fade */}
|
|
425
425
|
<div show={this.menu} transition="scale">…</div> {/* preset */}
|
|
426
426
|
<aside show={this.drawer} transition="slide-right">…</aside> {/* directional */}
|
|
@@ -446,7 +446,7 @@ The `flow:*` directives (`flow:click`, `flow:model`, `flow:show`, …) that the
|
|
|
446
446
|
|
|
447
447
|
A handful of client-only helpers — writing a prop and syncing it, toggling a boolean, calling a parent action, optimistic list mutations — don't belong to any one component and never run on the server. Rather than crowd the component class (and reserve common names like `set`, `on`, `watch`, `parent` that you might want for your own methods), they all live on a single global object, `$flow`. Framework helpers wear a `$`; the bare names stay yours.
|
|
448
448
|
|
|
449
|
-
```tsx
|
|
449
|
+
```tsx fragment
|
|
450
450
|
<button onClick={() => $flow.set("open", true)}>Open</button> {/* write + sync an @expose prop */}
|
|
451
451
|
<button onClick={() => $flow.toggle("open")}>Toggle</button>
|
|
452
452
|
<button onClick={() => $flow.parent.save()}>Save</button> {/* call a parent action */}
|
|
@@ -508,7 +508,7 @@ A page that _reads_ `$flow.store` in a binding must be AOT-compilable — the co
|
|
|
508
508
|
|
|
509
509
|
### Full page example
|
|
510
510
|
|
|
511
|
-
```tsx
|
|
511
|
+
```tsx fragment
|
|
512
512
|
import { Component, expose, locked, validate, url } from "@zerotal/flow";
|
|
513
513
|
import type { Post } from "#app/models/Post.ts";
|
|
514
514
|
|
|
@@ -576,4 +576,4 @@ Flow is a large surface. Each section below is its own page.
|
|
|
576
576
|
- [Session](/docs/session) — the store behind `@session` and `SessionMiddleware`.
|
|
577
577
|
- [Broadcasting](/docs/broadcasting) — drive `@on("socket:…")` real-time updates from the server.
|
|
578
578
|
- [Storage](/docs/storage) — configure the disks that file uploads write to.
|
|
579
|
-
- [Testing](/docs/testing
|
|
579
|
+
- [Testing](/docs/testing) — patterns for the `FlowTest` harness and the rest of the suite.
|