@k8ordo/form 0.1.1 → 1.1.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/README.md +31 -23
- package/dist/async-check.d.mts +3 -4
- package/dist/derive/attributes.d.mts +43 -8
- package/dist/derive/attributes.mjs +74 -19
- package/dist/derive/form-fields.d.mts +6 -5
- package/dist/derive/form-fields.mjs +55 -23
- package/dist/derive/messages.d.mts +2 -3
- package/dist/derive/messages.mjs +8 -11
- package/dist/hidden-value.d.mts +4 -8
- package/dist/hidden-value.mjs +8 -9
- package/dist/index.d.mts +4 -4
- package/dist/parse/parse-form.d.mts +3 -4
- package/dist/parse/parse-form.mjs +25 -4
- package/dist/parse/paths.d.mts +4 -4
- package/dist/paths.d.mts +34 -4
- package/dist/rules/define-form.d.mts +4 -5
- package/dist/rules/rules.d.mts +26 -10
- package/dist/rules/rules.mjs +14 -7
- package/dist/schema/object-schema.d.mts +7 -6
- package/dist/schema/walk.d.mts +16 -8
- package/dist/schema/walk.mjs +77 -10
- package/dist/server.d.mts +3 -3
- package/dist/types.d.mts +38 -17
- package/dist/use-form.d.mts +32 -12
- package/dist/use-form.mjs +136 -22
- package/docs/GUIDE.md +469 -71
- package/docs/llms.txt +61 -15
- package/package.json +18 -17
package/docs/GUIDE.md
CHANGED
|
@@ -11,14 +11,10 @@ fallbacks.
|
|
|
11
11
|
## zod, or zod/mini
|
|
12
12
|
|
|
13
13
|
The conversion is taken from zod's shared core, so a schema written with either
|
|
14
|
-
entry works. `zod/mini` is the same validator with a much smaller surface —
|
|
15
|
-
the
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
```
|
|
19
|
-
zod 63 kB gzipped
|
|
20
|
-
zod/mini 10 kB gzipped
|
|
21
|
-
```
|
|
14
|
+
entry works. `zod/mini` is the same validator with a much smaller surface —
|
|
15
|
+
bundled for the browser, the `talkSchema` below comes to about a quarter of the
|
|
16
|
+
size — so if the schema module is also imported somewhere on the client, that
|
|
17
|
+
is the one to reach for.
|
|
22
18
|
|
|
23
19
|
Nothing in this package notices which one you chose. Note that under this
|
|
24
20
|
design the schema is imported only by server code — the Server Component that
|
|
@@ -29,7 +25,8 @@ the client imports the schema module anyway.
|
|
|
29
25
|
## The shape of it
|
|
30
26
|
|
|
31
27
|
```
|
|
32
|
-
server formFields(schema) → { title: { input, messages }, … }
|
|
28
|
+
server formFields(schema) → { fields: { title: { input, messages, secret }, … },
|
|
29
|
+
arrays, rules, dropped } plain data
|
|
33
30
|
↓ props (zod does not cross)
|
|
34
31
|
client useForm(fields, state) → attributes to spread, message to show
|
|
35
32
|
↓ submit
|
|
@@ -38,12 +35,15 @@ server parseForm(schema, formData) → typed data, or per-field errors
|
|
|
38
35
|
|
|
39
36
|
`formFields` runs on the server — in a Server Component or at module scope. Its
|
|
40
37
|
result is JSON, so it crosses the RSC boundary as props and zod never enters the
|
|
41
|
-
client bundle.
|
|
38
|
+
client bundle. The messages are read when it runs — zod's, and a rule's
|
|
39
|
+
[function message](#a-message-that-follows-the-request) — and module scope runs
|
|
40
|
+
once, before any request: when a message follows the request (its locale, say,
|
|
41
|
+
with `@k8ordo/i18n`), call `formFields` during the render instead.
|
|
42
42
|
|
|
43
43
|
## Writing a form
|
|
44
44
|
|
|
45
45
|
```ts
|
|
46
|
-
// schema.ts
|
|
46
|
+
// src/lib/schema.ts
|
|
47
47
|
export const talkSchema = z.object({
|
|
48
48
|
title: z.string().min(1).max(120),
|
|
49
49
|
eventUrl: z.url(),
|
|
@@ -56,8 +56,39 @@ an unchecked checkbox arrives as a missing key, a repeated name arrives as
|
|
|
56
56
|
several entries, everything is a string — and `z.coerce` handles `'42'` → `42`.
|
|
57
57
|
That way the schema keeps describing what it actually validates.
|
|
58
58
|
|
|
59
|
+
Because every submitted value is a string, a plain `z.number()` could not accept
|
|
60
|
+
any submission at all; `formFields` refuses it instead of deriving a form that
|
|
61
|
+
always fails. An empty numeric control is not 0 either — it submits nothing, so
|
|
62
|
+
`z.coerce.number()` is required and `z.coerce.number().optional()` is not. That
|
|
63
|
+
makes an untouched field a type error, which is where its wording comes from:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
z.coerce.number('数値を入力してください');
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Depending on the zod version, that wording also replaces the default message of
|
|
70
|
+
every other check on the field that has none of its own (4.5.4 does this, 4.4.3
|
|
71
|
+
does not), so give `.int()` or `.min()` theirs.
|
|
72
|
+
|
|
73
|
+
Each leaf derives the control that submits what it validates:
|
|
74
|
+
|
|
75
|
+
| Schema | `input` |
|
|
76
|
+
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
|
|
77
|
+
| `z.string()` | `type="text"` |
|
|
78
|
+
| `z.email()` / `z.url()` | `type="email"` / `type="url"` |
|
|
79
|
+
| `z.iso.date()` / `z.iso.time()` | `type="date"` / `type="time"` |
|
|
80
|
+
| `z.iso.datetime({ local: true })` | `type="datetime-local"` |
|
|
81
|
+
| `z.coerce.number()` | `type="number"`; `step` from `.multipleOf()`, else `1` after `.int()` and `any` otherwise |
|
|
82
|
+
| `z.boolean()`, `z.literal(true)` | `type="checkbox"` |
|
|
83
|
+
| `z.stringbool()` | `type="checkbox"` with a `value` ([Checkboxes that submit a string](#checkboxes-that-submit-a-string)) |
|
|
84
|
+
| `z.file()` | `type="file"` |
|
|
85
|
+
| a password ([below](#what-it-guarantees)) | `type="password"` |
|
|
86
|
+
| `z.enum([…])` | no `type`: spread it onto a `<select>`, not a radio ([Radio groups](#radio-groups)) |
|
|
87
|
+
| `z.array(z.enum([…]))` | no `type`: one checkbox per option ([Checkbox groups](#checkbox-groups)) |
|
|
88
|
+
| anything else (`z.uuid()`, `z.coerce.date()`, …) | `type="text"` |
|
|
89
|
+
|
|
59
90
|
```tsx
|
|
60
|
-
// page.tsx — Server Component
|
|
91
|
+
// src/routes/talks/new/page.tsx — Server Component
|
|
61
92
|
const talkFields = formFields(talkSchema); // module scope: derived once
|
|
62
93
|
|
|
63
94
|
export default function Page() {
|
|
@@ -69,7 +100,7 @@ export default function Page() {
|
|
|
69
100
|
report travel together.
|
|
70
101
|
|
|
71
102
|
```tsx
|
|
72
|
-
// talk-form.tsx
|
|
103
|
+
// src/components/talk-form.tsx
|
|
73
104
|
'use client';
|
|
74
105
|
|
|
75
106
|
export const TalkForm = ({ action, fields }: TalkFormProps) => {
|
|
@@ -87,76 +118,180 @@ export const TalkForm = ({ action, fields }: TalkFormProps) => {
|
|
|
87
118
|
```
|
|
88
119
|
|
|
89
120
|
`form.props` attaches to the `<form>` and nowhere else. There is no per-field
|
|
90
|
-
registration to forget.
|
|
121
|
+
registration to forget. Its `onSubmit` checks the form
|
|
122
|
+
([below](#what-it-guarantees)) — an `onSubmit` of your own written after the
|
|
123
|
+
spread replaces it, so call `form.props.onSubmit(event)` from yours — and it
|
|
124
|
+
also hears the form being reset — by a reset
|
|
125
|
+
button, by `form.reset()`, or by React itself after every form action, whatever
|
|
126
|
+
it returned — and forgets what it knew about the old values: the messages,
|
|
127
|
+
which server errors were still current, the rows that were added, and
|
|
128
|
+
`isDirty`.
|
|
129
|
+
|
|
130
|
+
A form with no action behind it — a GET filter, say — calls `useForm(fields)`
|
|
131
|
+
and leaves the state out. It is still checked on submit, so a filter that
|
|
132
|
+
breaks its schema never reaches the URL.
|
|
91
133
|
|
|
92
134
|
```ts
|
|
93
|
-
// actions.ts
|
|
135
|
+
// src/lib/actions.ts
|
|
94
136
|
'use server';
|
|
95
137
|
|
|
138
|
+
import { href } from '@k8ordo/framework';
|
|
139
|
+
import { redirect } from '@k8ordo/framework/server';
|
|
140
|
+
|
|
96
141
|
export async function createTalk(_prev: FormState, formData: FormData) {
|
|
97
142
|
const parsed = parseForm(talkSchema, formData);
|
|
98
143
|
if (!parsed.success) return parsed.state;
|
|
99
144
|
|
|
100
145
|
await insertTalk(parsed.data); // typed
|
|
101
|
-
redirect('/talks');
|
|
146
|
+
redirect(href('/talks'));
|
|
102
147
|
}
|
|
103
148
|
```
|
|
104
149
|
|
|
150
|
+
When a new state arrives, focus moves to the first failure on the page, so the
|
|
151
|
+
failure is announced where it happened. That is the first failed field in
|
|
152
|
+
document order — not the first key of `state.errors`, which follows the schema
|
|
153
|
+
— or a message no field owns, when it comes before every failed field: the
|
|
154
|
+
form-level message below, or an array's own
|
|
155
|
+
([Nested objects and repeated rows](#nested-objects-and-repeated-rows)). A
|
|
156
|
+
server error stays on its field until the person edits that field, and a
|
|
157
|
+
message the browser raises takes precedence over it. Responses are told apart
|
|
158
|
+
by their content plus `state.token`, which `parseForm` sets on every parse — a
|
|
159
|
+
state built by hand needs a fresh `token` too, or a second identical failure
|
|
160
|
+
reads as the same response, and focus does not move.
|
|
161
|
+
|
|
162
|
+
An issue with no path — a `.refine()` on the whole schema that names no
|
|
163
|
+
`path` — lands in `state.formError`, not on a field. No control can take focus
|
|
164
|
+
for it, so `form.formError` hands over the message and the props for the
|
|
165
|
+
element that shows it — an `id` and `tabIndex={-1}`, which lets the script
|
|
166
|
+
focus it without adding it to the Tab order. Without them the message is drawn
|
|
167
|
+
but never focused, and a screen reader says nothing about it. Put it above the
|
|
168
|
+
fields, the way an error summary is placed: it then takes focus even when
|
|
169
|
+
fields failed too, the person hears what went wrong with the submission as a
|
|
170
|
+
whole first, and Tab moves on through the fields.
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
<form {...form.props} action={formAction}>
|
|
174
|
+
{form.formError.message !== undefined && (
|
|
175
|
+
<p {...form.formError.props}>{form.formError.message}</p>
|
|
176
|
+
)}
|
|
177
|
+
{/* … */}
|
|
178
|
+
</form>
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Focus carries the message rather than a live region because focus moves on
|
|
182
|
+
every response: a live region already holding the same text announces nothing
|
|
183
|
+
when the same failure comes back.
|
|
184
|
+
|
|
105
185
|
## What it guarantees
|
|
106
186
|
|
|
107
187
|
**The wording cannot drift.** Client messages are obtained by running the
|
|
108
188
|
schema against a probe value, so the text shown next to the input is the text
|
|
109
|
-
zod itself produces. A custom `min(1, '…')` reaches both sides.
|
|
189
|
+
zod itself produces. A custom `min(1, '…')` reaches both sides. The one
|
|
190
|
+
exception is a range message from `@k8ordo/ui`'s `NumberField` (see
|
|
191
|
+
[Working with @k8ordo/ui](#working-with-k8ordoui)).
|
|
110
192
|
|
|
111
193
|
**`required` means the same thing on both sides.** JSON Schema's `required`
|
|
112
194
|
means "the key is present", but a form always submits something for every
|
|
113
|
-
control
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
not
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
`z.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
195
|
+
control. What that is depends on the control: `''` for a text field, `false` for
|
|
196
|
+
an unchecked checkbox, and nothing at all for a number, a `z.coerce.bigint()`, a
|
|
197
|
+
file, a choice, or an unchecked `z.stringbool()` box — an empty numeric field is
|
|
198
|
+
not 0 (nor 0n), an unfilled file
|
|
199
|
+
input is not the zero-byte file the browser sends, and neither a radio group
|
|
200
|
+
with nothing selected nor a `<select>` left on a `value=""` placeholder has
|
|
201
|
+
chosen anything. The attribute is emitted only when the schema rejects that
|
|
202
|
+
empty submission, and `parseForm` hands the schema exactly what the derivation
|
|
203
|
+
probed, so the two sides always agree: a plain `z.boolean()` checkbox is not
|
|
204
|
+
required, while `z.literal(true, '…')` — a consent box — is, and shows zod's own
|
|
205
|
+
wording; `z.enum([…])` is required, while `z.enum([…]).optional()` can be left
|
|
206
|
+
on its placeholder.
|
|
207
|
+
|
|
208
|
+
**Checks the client skips are reported.** What HTML cannot express — a
|
|
209
|
+
`refine` on the schema as a whole, cross-field rules, an exclusive bound on a
|
|
210
|
+
float, a regex whose flags or anchoring the `pattern` attribute would silently
|
|
211
|
+
reinterpret, several regexes on one string (the attribute holds one), a regex on
|
|
212
|
+
a control that ignores `pattern` such as `type="date"`, a `.mime()` that
|
|
213
|
+
`accept` only suggests to the file picker, a `z.iso.datetime()` no
|
|
214
|
+
`datetime-local` control can ever satisfy,
|
|
215
|
+
a leaf whose constraints could not be read at all because a `.transform()` or a
|
|
216
|
+
`z.custom()` left nothing to read — are returned in `dropped`. They still run
|
|
217
|
+
on the server; you are told they do not run on the client. Outside production
|
|
218
|
+
`formFields` also logs the list once per schema with `console.warn`, so it is
|
|
219
|
+
seen without anyone remembering to read it. One kind is not reported yet: a
|
|
220
|
+
`refine` on a single field, a nested object, or a row (see
|
|
221
|
+
[What it does not do yet](#what-it-does-not-do-yet)).
|
|
222
|
+
|
|
223
|
+
**The control follows the format, whatever is stacked on it.** `z.email()`
|
|
224
|
+
derives `type="email"` and `z.iso.date()` `type="date"`, and a check added on
|
|
225
|
+
top — `.regex()`, `.startsWith()`, `.lowercase()` — does not turn either into a
|
|
226
|
+
text input. The check itself becomes the `pattern` when it is the only regex
|
|
227
|
+
the browser would have to run (`z.url().lowercase()`), and is listed in
|
|
228
|
+
`dropped` otherwise (`z.email().regex(…)`, whose format already carries one).
|
|
126
229
|
|
|
127
230
|
**A schema this package cannot express fails at derive time.** `z.record`,
|
|
128
231
|
tuples, a repeat nested inside a repeat, nullable objects, keys containing
|
|
129
232
|
`.` or brackets — `formFields` throws with the reason, instead of deriving a
|
|
130
|
-
form that would silently misparse what the person typed.
|
|
233
|
+
form that would silently misparse what the person typed. So does a leaf no
|
|
234
|
+
control could ever satisfy, because every value arrives as a string:
|
|
235
|
+
`z.number()` (use `z.coerce.number()`), `z.literal(1)`, `z.date()`,
|
|
236
|
+
`z.bigint()`. So does a checkbox that could never submit `false`:
|
|
237
|
+
`z.stringbool().default(true)`, since an unchecked box submits nothing and the
|
|
238
|
+
default reads nothing as `true`. A form that can never validate, or that
|
|
239
|
+
silently discards what the person did, is a mistake to report, not a check to
|
|
240
|
+
drop. A schema that coerces reads strings and is kept, so
|
|
241
|
+
`z.coerce.date()` and `z.coerce.bigint()` derive as text inputs.
|
|
131
242
|
|
|
132
243
|
**A missing name is loud.** If the schema has a field that never arrived in the
|
|
133
244
|
FormData, `parseForm` throws instead of reporting it as a validation failure.
|
|
134
245
|
Forgetting to spread `input` is a wiring mistake, not something the person
|
|
135
|
-
filling in the form did. The
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
246
|
+
filling in the form did. The exceptions are the controls that submit no entry
|
|
247
|
+
at all when left alone — a state the person can reach: a radio group with
|
|
248
|
+
nothing selected reaches the schema as no value, the same as a `<select>` on
|
|
249
|
+
its placeholder (a validation error unless the enum accepts `undefined`), an
|
|
250
|
+
unchecked checkbox as `false` (or, under `z.stringbool()`, as no value), and a
|
|
251
|
+
checkbox group with nothing checked as `[]`. A forgotten spread on one of those is not caught either.
|
|
252
|
+
|
|
253
|
+
**A failing submission stops in the browser, with JavaScript or without.**
|
|
254
|
+
`noValidate` is applied from JavaScript on mount, never rendered into the
|
|
255
|
+
markup, so with scripts disabled or not yet loaded the browser's own checks
|
|
256
|
+
stay on. Once the hook is live it runs the same check on every submit, in
|
|
257
|
+
zod's wording: every field, the ones nobody touched included, and the
|
|
258
|
+
cross-field [rules](#checks-html-has-no-attribute-for). A failure stops the submission, shows each failed field's message, and
|
|
259
|
+
moves focus to the first failed field on the page. When that field is one the
|
|
260
|
+
hook has no message for — a control outside the schema, say a `required`
|
|
261
|
+
consent box the server never reads — the browser reports it with its own
|
|
262
|
+
bubble (`reportValidity()`), as it would without the hook. A submit button marked
|
|
263
|
+
`formNoValidate` skips the check, as it skips the browser's. The server stays
|
|
264
|
+
the arbiter: what passes still goes to it, and the checks in `dropped` run
|
|
265
|
+
there alone.
|
|
266
|
+
|
|
267
|
+
**A retry keeps what was submitted.** `parseForm` returns the submitted
|
|
268
|
+
values, and they render as the controls' defaults, which is also what React's
|
|
269
|
+
reset after the action restores. A `<select>` — `multiple` too — is no
|
|
270
|
+
exception, although React applies a select's `defaultValue` only when it
|
|
271
|
+
mounts: `useForm` writes each response's echo into the options'
|
|
272
|
+
`defaultSelected` itself, so the select needs no `key` to remount it.
|
|
273
|
+
|
|
274
|
+
**Secrets are never echoed.** Fields marked as passwords are left out of
|
|
275
|
+
those values automatically, and typed as `password` in the markup:
|
|
147
276
|
|
|
148
277
|
```ts
|
|
149
278
|
z.string().min(8).meta({ input: 'password' }); // zod
|
|
279
|
+
z.string().check(z.minLength(8), z.meta({ input: 'password' })); // zod/mini
|
|
150
280
|
globalRegistry.add(password, { input: 'password' }); // zod or zod/mini
|
|
151
281
|
```
|
|
152
282
|
|
|
153
|
-
`.meta()` is shorthand for the registry and
|
|
154
|
-
|
|
283
|
+
`.meta()` is shorthand for the registry, and `zod/mini` has no such method —
|
|
284
|
+
pass `z.meta()` to `.check()` there. The registry route works with either.
|
|
155
285
|
|
|
156
286
|
**`isDirty` costs one boolean.** `form.isDirty` compares each control — text,
|
|
157
287
|
checkbox, `<select>`, a `HiddenValue` — with the value it was rendered with,
|
|
158
288
|
read straight from the DOM; adding or removing a row counts too. It flips at
|
|
159
|
-
most twice, so it never becomes a per-keystroke re-render.
|
|
289
|
+
most twice, so it never becomes a per-keystroke re-render. A reset — React's
|
|
290
|
+
own after every form action included — is read from the DOM once it is
|
|
291
|
+
through, so it takes the flag back to `false` for everything the browser
|
|
292
|
+
restores. A `HiddenValue` is not among those: its value is the caller's
|
|
293
|
+
state, which React writes straight back, so a form holding an edited one
|
|
294
|
+
stays dirty until that state is reset too.
|
|
160
295
|
|
|
161
296
|
## Nested objects and repeated rows
|
|
162
297
|
|
|
@@ -200,6 +335,26 @@ buttons disappear at exactly the bounds the server enforces. For an array of
|
|
|
200
335
|
scalars (`z.array(z.string())`) the item has a single unnamed field:
|
|
201
336
|
`row.field()`.
|
|
202
337
|
|
|
338
|
+
`items.error` is the server's message about the array itself — too few or too
|
|
339
|
+
many rows for its `.min()` / `.max()` — which no row's field carries, so no
|
|
340
|
+
control can take focus for it. Like `form.formError`, it comes with the props
|
|
341
|
+
for the element that shows it: `items.errorProps`, an `id` and
|
|
342
|
+
`tabIndex={-1}`. Without them a submit that failed only on the array moves
|
|
343
|
+
focus nowhere, and a screen reader says nothing about it. Put it above the
|
|
344
|
+
rows: it then takes focus even when a row failed too, and Tab moves on into
|
|
345
|
+
the rows. Below them, the first failed row takes focus instead.
|
|
346
|
+
|
|
347
|
+
```tsx
|
|
348
|
+
<form {...form.props} action={formAction}>
|
|
349
|
+
{items.error !== undefined && <p {...items.errorProps}>{items.error}</p>}
|
|
350
|
+
{items.rows.map((row) => (
|
|
351
|
+
<div key={row.key}>{/* … */}</div>
|
|
352
|
+
))}
|
|
353
|
+
</form>
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
`row.index` is the row's current position.
|
|
357
|
+
|
|
203
358
|
`parseForm` reports how many rows arrived in `state.rows`, so a retry without
|
|
204
359
|
JavaScript rebuilds the same number of rows. Row counts are read from the
|
|
205
360
|
submitted names but never trusted beyond the schema's `.max()` (or a hard
|
|
@@ -207,7 +362,56 @@ ceiling), so a forged index cannot make the server allocate.
|
|
|
207
362
|
|
|
208
363
|
A nested object behind `.optional()` / `.default()` keeps its fields — the
|
|
209
364
|
wrapper is peeled the same way on both sides. A repeat nested inside a repeat
|
|
210
|
-
has no unambiguous name;
|
|
365
|
+
has no unambiguous name; the types let it through, but `formFields` and
|
|
366
|
+
`parseForm` throw on it.
|
|
367
|
+
|
|
368
|
+
## Radio groups
|
|
369
|
+
|
|
370
|
+
Do not spread an enum's `input` onto a hand-written radio: once `state`
|
|
371
|
+
carries `values`, `input` holds the echoed choice as `defaultValue`, which
|
|
372
|
+
collides with each radio's own `value` and restores no selection. Take `name`
|
|
373
|
+
and `required` from it and restore the choice per option (`@k8ordo/ui`'s
|
|
374
|
+
`Radio` and `RadioCard` take the spread instead — see
|
|
375
|
+
[Working with @k8ordo/ui](#working-with-k8ordoui)):
|
|
376
|
+
|
|
377
|
+
```tsx
|
|
378
|
+
const plan = form.field('plan');
|
|
379
|
+
|
|
380
|
+
<input
|
|
381
|
+
defaultChecked={state.values?.plan === option}
|
|
382
|
+
name={plan.input.name}
|
|
383
|
+
required={plan.input.required}
|
|
384
|
+
type="radio"
|
|
385
|
+
value={option}
|
|
386
|
+
/>;
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
## Checkboxes that submit a string
|
|
390
|
+
|
|
391
|
+
`z.boolean()` reads a checkbox as checked or not, whatever it submits.
|
|
392
|
+
`z.stringbool()` reads what it submits: its `value` when checked, and nothing
|
|
393
|
+
when not. `input.value` is the schema's own spelling of `true` — `"true"`, or
|
|
394
|
+
the first of `truthy` when one is given — so the box submits a string the
|
|
395
|
+
schema reads back, never the browser's default `on`.
|
|
396
|
+
|
|
397
|
+
That is the shape a boolean takes in a GET filter form derived from an
|
|
398
|
+
`@k8ordo/state` url schema, where a boolean has to be `z.stringbool()`: the
|
|
399
|
+
box submits the same string `update()` writes for `true`, so the URL the form
|
|
400
|
+
lands on and the one state writes agree. An unchecked box submits nothing,
|
|
401
|
+
which reaches the schema as `undefined`: `.default(false)` or `.optional()`
|
|
402
|
+
accepts it, and a bare `z.stringbool()` makes the box `required`.
|
|
403
|
+
`.default(true)` is refused at derive time — nothing would read as `true`, so
|
|
404
|
+
unchecking the box could never submit `false`.
|
|
405
|
+
|
|
406
|
+
Only these fields carry `value` in their type. `formFields` names their paths
|
|
407
|
+
in the third type argument of `FormFields` (`FormFields<FieldPath, ArrayPath,
|
|
408
|
+
StringCheckboxPath>`), and `field()` types `input` as `StringCheckboxInput` for
|
|
409
|
+
them and as `FieldInput`, with no `value`, for every other field — so the other
|
|
410
|
+
fields spread onto a component that takes `value` only when it is controlled.
|
|
411
|
+
A `FormFields` type written by hand can leave the third argument out: it still
|
|
412
|
+
takes the derived fields, and the box still submits its `value`, but
|
|
413
|
+
`input.value` no longer compiles. The same holds for a box inside repeated
|
|
414
|
+
rows, which `row.field()` never types with `value`.
|
|
211
415
|
|
|
212
416
|
## Checkbox groups
|
|
213
417
|
|
|
@@ -221,7 +425,15 @@ const tags = form.field('tags');
|
|
|
221
425
|
|
|
222
426
|
{
|
|
223
427
|
['a', 'b', 'c'].map((option) => (
|
|
224
|
-
<input
|
|
428
|
+
<input
|
|
429
|
+
defaultChecked={
|
|
430
|
+
Array.isArray(state.values?.tags) && state.values.tags.includes(option)
|
|
431
|
+
}
|
|
432
|
+
key={option}
|
|
433
|
+
name={tags.input.name}
|
|
434
|
+
type="checkbox"
|
|
435
|
+
value={option}
|
|
436
|
+
/>
|
|
225
437
|
));
|
|
226
438
|
}
|
|
227
439
|
```
|
|
@@ -230,17 +442,29 @@ const tags = form.field('tags');
|
|
|
230
442
|
`[]`, never a wiring error. The `.min(2)` cannot become an HTML attribute (on
|
|
231
443
|
a group, `required` would mean "check every box"), so it is reported in
|
|
232
444
|
`dropped`; declare `minChecked('tags', 2, '…')` to run the same bound in the
|
|
233
|
-
browser.
|
|
445
|
+
browser. After a submission `state.values.tags` holds what was checked as an
|
|
446
|
+
array — `['a']` for one box, `[]` for none — and `tags.input` carries only the
|
|
447
|
+
`name`, so each box restores itself from that array.
|
|
234
448
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
449
|
+
## Files
|
|
450
|
+
|
|
451
|
+
`z.file()` derives `type="file"`, and `.mime([…])` becomes `accept`. `accept`
|
|
452
|
+
only decides what the file picker offers first — the person can still pick any
|
|
453
|
+
file, and the browser never checks the type — so the check runs on the server
|
|
454
|
+
and is listed in `dropped`. An unfilled file input still submits — an unnamed,
|
|
455
|
+
zero-byte File, which `z.file()` would otherwise accept as a real upload — so it
|
|
456
|
+
reaches the schema as nothing entered and `required` keeps meaning what it says.
|
|
457
|
+
|
|
458
|
+
Size bounds (`.min()` / `.max()` on a file) are byte counts, and no HTML
|
|
459
|
+
attribute carries them: they run on the server and are reported in `dropped`.
|
|
460
|
+
A file is never echoed into `state.values`, because no browser lets a value be
|
|
461
|
+
put back into a file control.
|
|
238
462
|
|
|
239
463
|
## Paths are checked at compile time
|
|
240
464
|
|
|
241
|
-
`formFields` derives the set of valid paths from the schema type, so a typo
|
|
242
|
-
a build error rather than something you
|
|
243
|
-
against the same paths.
|
|
465
|
+
`formFields` derives the set of valid paths from the schema type, so a typo in
|
|
466
|
+
`form.field()` or `form.array()` is a build error rather than something you
|
|
467
|
+
find by clicking. Rules are typed against the same paths.
|
|
244
468
|
|
|
245
469
|
```tsx
|
|
246
470
|
form.field('titel'); // error: not a field in the schema
|
|
@@ -250,6 +474,9 @@ form.array('tags'); // error: a checkbox group is a field
|
|
|
250
474
|
defineForm(schema, [sameAs('confrim', 'password', '…')]); // error: typo
|
|
251
475
|
```
|
|
252
476
|
|
|
477
|
+
A row's `row.field(key)` is the exception: the key is a plain string, and a
|
|
478
|
+
typo in it throws when the row renders.
|
|
479
|
+
|
|
253
480
|
## Checks HTML has no attribute for
|
|
254
481
|
|
|
255
482
|
Password confirmation, "tick at least two", "required only when rejected" —
|
|
@@ -274,18 +501,52 @@ implementation to drift from the first.
|
|
|
274
501
|
|
|
275
502
|
On the client a breach is applied with `setCustomValidity`, so it is
|
|
276
503
|
indistinguishable from a built-in check — `:user-invalid` matches and the
|
|
277
|
-
message arrives through the same path as every other one.
|
|
504
|
+
message arrives through the same path as every other one. When several rules
|
|
505
|
+
break the same field, both sides show the one declared first.
|
|
506
|
+
|
|
507
|
+
Available: `sameAs(field, other, message)`, `minChecked(field, min, message)`,
|
|
508
|
+
and `requiredWhen(field, when, equals, message)` —
|
|
509
|
+
`requiredWhen('reason', 'status', 'rejected', '…')` requires `reason` while
|
|
510
|
+
`status` is `'rejected'`. Anything else stays a `.refine()` and runs on the
|
|
511
|
+
server only. A `refine` on the schema as a whole is listed in `dropped`; one on
|
|
512
|
+
a single field, a nested object, or a row is not yet.
|
|
513
|
+
|
|
514
|
+
### A message that follows the request
|
|
515
|
+
|
|
516
|
+
`message` takes a function as well as a string, the way zod takes
|
|
517
|
+
`{ error: () => … }`: it is called when the rule is reported, not where the
|
|
518
|
+
rule is declared. `formFields` calls it as it derives the fields — the client
|
|
519
|
+
cannot run a function sent from the server, so the rules cross already
|
|
520
|
+
worded — and `parseForm` calls it when the rule breaks. A definition at module
|
|
521
|
+
scope therefore reports in whichever locale is current in the request that
|
|
522
|
+
reads it:
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
// src/lib/signup-form.ts
|
|
526
|
+
import { mismatch, pickAtLeast } from '../messages/signup'; // @k8ordo/i18n
|
|
278
527
|
|
|
279
|
-
|
|
280
|
-
|
|
528
|
+
export const signup = defineForm(schema, [
|
|
529
|
+
sameAs('confirm', 'password', mismatch),
|
|
530
|
+
minChecked('topics', 2, () => pickAtLeast(2)),
|
|
531
|
+
]);
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Call `formFields` during the render for this, the same as for zod's function
|
|
535
|
+
messages.
|
|
281
536
|
|
|
282
537
|
## Asking the server about one field
|
|
283
538
|
|
|
284
539
|
Whether a name is already taken is something only the server knows. `useAsyncCheck`
|
|
285
540
|
runs on blur, keeps the newest answer when replies arrive out of order, and
|
|
286
541
|
applies the result with `setCustomValidity` — so the answer shows up through the
|
|
287
|
-
same path as every other message.
|
|
288
|
-
not ask again
|
|
542
|
+
same path as every other message. Leaving the field while it still holds the
|
|
543
|
+
value the last answer was about does not ask again — a blur while a check is
|
|
544
|
+
still in flight does — and emptying the field and leaving it clears the last
|
|
545
|
+
answer.
|
|
546
|
+
|
|
547
|
+
`check` receives the field's value and resolves to the message to show, or to
|
|
548
|
+
`undefined` when the value is fine. A rejected promise applies no verdict
|
|
549
|
+
either way; the server still checks the submission.
|
|
289
550
|
|
|
290
551
|
```tsx
|
|
291
552
|
const slug = form.field('slug');
|
|
@@ -308,22 +569,32 @@ into React state and pushing it back out at submit:
|
|
|
308
569
|
<HiddenValue name="body" value={body} />
|
|
309
570
|
```
|
|
310
571
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
the component announces each change with one, so cross-field rules and
|
|
572
|
+
The value is an ordinary form entry, so it goes out with the rest of the
|
|
573
|
+
FormData and needs no submit-time code. `HiddenValue` is a component rather
|
|
574
|
+
than a props helper because React updates a controlled value without any DOM
|
|
575
|
+
event — the component announces each change with one, so cross-field rules and
|
|
316
576
|
`isDirty` hear it like any keystroke.
|
|
317
577
|
|
|
318
578
|
## Multi-step forms
|
|
319
579
|
|
|
320
580
|
Keep every step mounted and hide the ones you are not on. The values stay in
|
|
321
|
-
the DOM, so moving between steps costs nothing and losing a step is impossible
|
|
581
|
+
the DOM, so moving between steps costs nothing and losing a step is impossible.
|
|
582
|
+
Hide steps only once hydrated — a step hidden in the server's HTML is out of
|
|
583
|
+
reach without JavaScript — and, once hydrated, render the submit button only on
|
|
584
|
+
the last step: Enter in a text field can click the form's first submit button
|
|
585
|
+
even when it is hidden, which would send the whole form from an earlier step.
|
|
322
586
|
|
|
323
587
|
```tsx
|
|
324
|
-
|
|
588
|
+
const subscribe = () => () => {};
|
|
589
|
+
const hydrated = useSyncExternalStore(subscribe, () => true, () => false);
|
|
590
|
+
|
|
591
|
+
<fieldset hidden={hydrated && step !== 0} ref={stepOne}>
|
|
325
592
|
{/* … */}
|
|
326
|
-
</
|
|
593
|
+
</fieldset>
|
|
594
|
+
<fieldset hidden={hydrated && step !== 1}>
|
|
595
|
+
{/* … */}
|
|
596
|
+
{(!hydrated || step === 1) && <button type="submit">送信</button>}
|
|
597
|
+
</fieldset>
|
|
327
598
|
```
|
|
328
599
|
|
|
329
600
|
Validate one step before advancing by checking only the controls inside that
|
|
@@ -336,19 +607,45 @@ const stepIsValid = [
|
|
|
336
607
|
].every((control) => (control as HTMLInputElement).checkValidity());
|
|
337
608
|
```
|
|
338
609
|
|
|
610
|
+
After a failed submit, `useForm` moves focus to the first failed field on the
|
|
611
|
+
page, but a control inside a hidden step cannot take focus. When a new state
|
|
612
|
+
arrives, switch during render to the earliest step holding a key of
|
|
613
|
+
`state.errors`, so the field is visible by the time focus moves. The check on
|
|
614
|
+
submit covers the hidden steps too, and stops the submission for a failure
|
|
615
|
+
there without being able to show it — checking each step before advancing is
|
|
616
|
+
what keeps the earlier steps from failing at the end.
|
|
617
|
+
|
|
339
618
|
Without JavaScript this degrades to one long form that submits in a single
|
|
340
619
|
request — which is the correct behaviour, not a broken one.
|
|
341
620
|
|
|
342
621
|
## What it does not do yet
|
|
343
622
|
|
|
623
|
+
- Several files in one control. `z.array(z.file())` derives repeated
|
|
624
|
+
single-file rows rather than one `multiple` input, and `parseForm` reads one
|
|
625
|
+
file per name.
|
|
626
|
+
- Constraints that sit behind a `.transform()`, or a `.pipe()` whose output
|
|
627
|
+
JSON Schema cannot describe. The schema no longer says what the control
|
|
628
|
+
submits, so the field derives as a bare text input and is listed in
|
|
629
|
+
`dropped`; every check still runs on the server.
|
|
630
|
+
- A `z.custom()` that rejects the strings a text control submits. Nothing about
|
|
631
|
+
it is readable, so it cannot be refused at derive time the way `z.date()` is
|
|
632
|
+
— it derives as a text input, is listed in `dropped`, and fails on the server.
|
|
633
|
+
- Reporting a `.refine()` / `.superRefine()` on a single field, on a nested
|
|
634
|
+
object, or on a row. Of `.refine()` / `.superRefine()` checks, `dropped`
|
|
635
|
+
counts only the ones on the schema as a whole; the others run on the server
|
|
636
|
+
without a word on the client.
|
|
637
|
+
- Rules on fields inside repeated rows. Rule field names are typed against the
|
|
638
|
+
schema's field paths, and a row's fields are not among them.
|
|
344
639
|
- Input masking. Rewriting `el.value` on input works with the DOM as the source
|
|
345
640
|
of truth, but managing the caret is a separate problem from wiring a form.
|
|
346
641
|
|
|
347
642
|
## Working with @k8ordo/ui
|
|
348
643
|
|
|
349
|
-
`@k8ordo/form` does not depend on `@k8ordo/ui
|
|
350
|
-
`
|
|
351
|
-
|
|
644
|
+
`@k8ordo/form` does not depend on `@k8ordo/ui`; the fields in `@k8ordo/ui`
|
|
645
|
+
take what `formFields` derives as it is. Spread `input` onto the field after
|
|
646
|
+
`FormControl`'s props. `FormControl` gets only what it uses — the label, the
|
|
647
|
+
error, `invalid`, `required` — and generates the `id` and `aria-*` links
|
|
648
|
+
itself.
|
|
352
649
|
|
|
353
650
|
```tsx
|
|
354
651
|
const title = form.field('title');
|
|
@@ -361,3 +658,104 @@ const title = form.field('title');
|
|
|
361
658
|
renderInput={(props) => <TextField {...props} {...title.input} />}
|
|
362
659
|
/>;
|
|
363
660
|
```
|
|
661
|
+
|
|
662
|
+
Nothing in `input` has to be taken out first: `TextField` renders the date and
|
|
663
|
+
time types as well as the text ones, `PasswordInput` keeps its show/hide toggle
|
|
664
|
+
under a spread `type`, `Textarea` drops the `type` a `<textarea>` does not
|
|
665
|
+
have, and `NumberField` and `Slider` take string bounds, `step="any"`, and the
|
|
666
|
+
echoed `defaultValue`, which is a string whichever field it belongs to.
|
|
667
|
+
Uncontrolled fields keep their value in the DOM, so a reset — React's after
|
|
668
|
+
each action included — restores the echo, `isDirty` reads them, and a value a
|
|
669
|
+
component changes in code — a stepper, a chosen option, a removed file —
|
|
670
|
+
arrives as an `input` event, the way typing does.
|
|
671
|
+
|
|
672
|
+
| Schema | Component | What to pass besides `{...props} {...input}` |
|
|
673
|
+
| ------------------------------------------------- | ---------------------------------------------------- | --------------------------------------------------------- |
|
|
674
|
+
| `z.string()`, `z.email()`, `z.url()`, `z.iso.*()` | `TextField`, `Textarea` | — |
|
|
675
|
+
| a password | `PasswordInput` | — |
|
|
676
|
+
| `z.coerce.number()` | `NumberField`, `Slider` | — |
|
|
677
|
+
| `z.enum([…])` | `Select`, `Radio`, `RadioCard` | `options`; `labelAs="legend"` on `FormControl` for radios |
|
|
678
|
+
| `z.boolean()`, `z.literal(true)` | `Checkbox`, `Switch` | `label`, and no `{...props}`: they need no `FormControl` |
|
|
679
|
+
| `z.array(z.enum([…]))` | `CheckboxGroup.Root`, `CheckboxCard`, `Autocomplete` | `defaultValue` from `state.values` |
|
|
680
|
+
| `z.file()` | `FileField.Root` | a `FileField.Trigger` and `FileField.ItemList` |
|
|
681
|
+
|
|
682
|
+
**Radios take the spread.** The advice under [Radio groups](#radio-groups) is
|
|
683
|
+
about a hand-written `<input type="radio">`. `Radio` and `RadioCard` read an
|
|
684
|
+
enum's `input` the other way round: `defaultValue` is the selected option and
|
|
685
|
+
`required` reaches every radio.
|
|
686
|
+
|
|
687
|
+
**A `z.stringbool()` box is the exception.** A spread `value` does not reach
|
|
688
|
+
`Checkbox`'s input, so a `z.stringbool()` box drawn with it submits the
|
|
689
|
+
browser's `on` — which the default `z.stringbool()` reads as `true`, but a
|
|
690
|
+
custom `truthy`, and the URL state writes, do not share. Render a plain
|
|
691
|
+
`<input {...field.input} />` there.
|
|
692
|
+
|
|
693
|
+
**A `Select` needs a placeholder for `required` to mean anything.** Put
|
|
694
|
+
`{ value: '', label: '…' }` first in `options`; a `<select>` without an empty
|
|
695
|
+
option always has a choice selected.
|
|
696
|
+
|
|
697
|
+
**A checkbox group echoes as an array.** `input` carries only the `name`, so
|
|
698
|
+
pass what was checked back as `defaultValue`:
|
|
699
|
+
|
|
700
|
+
```tsx
|
|
701
|
+
const tags = form.field('tags');
|
|
702
|
+
const checked = state.values?.tags;
|
|
703
|
+
|
|
704
|
+
<FormControl
|
|
705
|
+
errorText={tags.error}
|
|
706
|
+
invalid={tags.invalid}
|
|
707
|
+
label="タグ"
|
|
708
|
+
labelAs="legend"
|
|
709
|
+
renderInput={(props) => (
|
|
710
|
+
<CheckboxCard
|
|
711
|
+
{...props}
|
|
712
|
+
{...tags.input}
|
|
713
|
+
defaultValue={Array.isArray(checked) ? checked : []}
|
|
714
|
+
options={options}
|
|
715
|
+
/>
|
|
716
|
+
)}
|
|
717
|
+
/>;
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
`minChecked` reaches `Autocomplete` as well as the checkboxes: it submits
|
|
721
|
+
through a hidden `<select multiple>` that is present even with nothing
|
|
722
|
+
selected, so the rule has an element to mark, and focus sent to it after a
|
|
723
|
+
failure goes on to the text input.
|
|
724
|
+
|
|
725
|
+
**`NumberField` checks its own range.** It renders `type="text"` so it can
|
|
726
|
+
format and step the value, and the browser does not check `min` and `max` on
|
|
727
|
+
a text input. `NumberField` reports an out-of-range value with
|
|
728
|
+
`setCustomValidity` instead, which `useForm` shows like any other message —
|
|
729
|
+
but in `@k8ordo/ui`'s wording (`numberFieldRangeUnderflow` /
|
|
730
|
+
`numberFieldRangeOverflow`), not zod's, the one place the client's text is not
|
|
731
|
+
zod's own. Leaving the field clamps the value into range, so the message shows
|
|
732
|
+
only while typing. `.int()` and `.multipleOf()` set the precision the field
|
|
733
|
+
rounds to; a plain `z.coerce.number()` derives `step="any"`, which is not
|
|
734
|
+
rounded. Without JavaScript nothing checks the range before the server does;
|
|
735
|
+
a `TextField` given the derived `type="number"` keeps the browser's own
|
|
736
|
+
check, and its wording.
|
|
737
|
+
|
|
738
|
+
**A `Textarea` has no `pattern`.** A regex on a field drawn as a `Textarea`
|
|
739
|
+
reaches the markup, but a `<textarea>` ignores it: the check runs on the server
|
|
740
|
+
only, and `dropped` cannot list it, since the derivation does not know which
|
|
741
|
+
element renders the field.
|
|
742
|
+
|
|
743
|
+
**`FileField`** submits what `FileField.ItemList` shows: removing a file there
|
|
744
|
+
removes it from the input, and a reset empties the list with it.
|
|
745
|
+
|
|
746
|
+
The form-level message fits `Alert`, which passes `id` and `tabIndex` through
|
|
747
|
+
to its element. Its `role="alert"` means a screen reader may read the message
|
|
748
|
+
twice — once when it appears, once when focus lands on it — the same trade an
|
|
749
|
+
error summary with an alert role makes.
|
|
750
|
+
|
|
751
|
+
```tsx
|
|
752
|
+
{
|
|
753
|
+
form.formError.message !== undefined && (
|
|
754
|
+
<Alert
|
|
755
|
+
{...form.formError.props}
|
|
756
|
+
message={form.formError.message}
|
|
757
|
+
tone="error"
|
|
758
|
+
/>
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
```
|