@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/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 — if
15
- the schema module is also imported somewhere on the client, that is the one to
16
- reach for.
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 }, … } plain data
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 — an empty text field as `''`, an unchecked checkbox as `false` once
114
- parsed. The attribute is emitted only when the schema rejects that empty
115
- submission, so the two sides always agree: a plain `z.boolean()` checkbox is
116
- not required, while `z.literal(true, '…')` — a consent box — is, and shows
117
- zod's own wording.
118
-
119
- **Nothing is dropped in silence.** Checks HTML cannot express — `refine`,
120
- cross-field rules, an exclusive bound on a float, a regex whose flags or
121
- anchoring the `pattern` attribute would silently reinterpret, a
122
- `z.iso.datetime()` no `datetime-local` control can ever satisfy — are returned
123
- in `dropped`. They still run on the server; you are told they do not run on
124
- the client. Outside production `formFields` also logs the list once per schema
125
- with `console.warn`, so it is seen without anyone remembering to read it.
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 one exception is an enum field: a radio group
136
- with nothing selected submits no entry at all — a state the person can reach —
137
- so it comes back as that field's validation error instead.
138
-
139
- **Native validation survives without JavaScript.** `noValidate` is applied
140
- from JavaScript on mount, never rendered into the markup. With scripts
141
- disabled or not yet loaded, the browser's own checks stay on; once the hook is
142
- live, it takes over the message path and the server stays the arbiter.
143
-
144
- **Secrets are never echoed.** `parseForm` returns the submitted values so a
145
- retry without JavaScript keeps the input. Fields marked as passwords are
146
- excluded automatically, and typed as `password` in the markup:
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 is not available on `zod/mini`;
154
- the registry route works with either.
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; it is a compile error and a derive-time throw.
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 key={option} type="checkbox" value={option} {...tags.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. On a no-JS retry the group echoes as an array, so restoring is:
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
- ```tsx
236
- defaultChecked={Array.isArray(state.values?.tags) && state.values.tags.includes(option)}
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 is
242
- a build error rather than something you find by clicking. Rules are typed
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
- Available: `sameAs`, `minChecked`, `requiredWhen`. Anything else stays a
280
- `.refine()`, runs on the server, and is listed in `dropped`.
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. A blur that leaves the value unchanged does
288
- not ask again, and emptying the field clears the last answer with it.
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
- This is all `Controller` does in react-hook-form, and it is the one place that
312
- binding silently breaks there. Here the value is in the form, so it submits
313
- whether or not anything else works. `HiddenValue` is a component rather than a
314
- props helper because React updates a controlled value without any DOM event —
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
- <div hidden={step !== 1} ref={stepOne}>
588
+ const subscribe = () => () => {};
589
+ const hydrated = useSyncExternalStore(subscribe, () => true, () => false);
590
+
591
+ <fieldset hidden={hydrated && step !== 0} ref={stepOne}>
325
592
  {/* … */}
326
- </div>
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`. Attributes go to the input;
350
- `FormControl` gets only what it uses, and generates the `id` and `aria-*`
351
- links itself.
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
+ ```