@k8ordo/form 0.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 ADDED
@@ -0,0 +1,362 @@
1
+ # @k8ordo/form
2
+
3
+ One zod schema produces the HTML constraint attributes, the messages, and the
4
+ server-side validation. The DOM holds the values; React state holds only what
5
+ the DOM cannot express.
6
+
7
+ Like every k8ordo package it assumes React 19 and Server Components, uses only
8
+ what has reached Baseline newly available, and ships no polyfills or legacy
9
+ fallbacks.
10
+
11
+ ## zod, or zod/mini
12
+
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
+ ```
22
+
23
+ Nothing in this package notices which one you chose. Note that under this
24
+ design the schema is imported only by server code — the Server Component that
25
+ derives the fields, and the Server Action that parses the submission — so zod
26
+ does not reach the browser either way. Reach for `zod/mini` when something on
27
+ the client imports the schema module anyway.
28
+
29
+ ## The shape of it
30
+
31
+ ```
32
+ server formFields(schema) → { title: { input, messages }, … } plain data
33
+ ↓ props (zod does not cross)
34
+ client useForm(fields, state) → attributes to spread, message to show
35
+ ↓ submit
36
+ server parseForm(schema, formData) → typed data, or per-field errors
37
+ ```
38
+
39
+ `formFields` runs on the server — in a Server Component or at module scope. Its
40
+ result is JSON, so it crosses the RSC boundary as props and zod never enters the
41
+ client bundle.
42
+
43
+ ## Writing a form
44
+
45
+ ```ts
46
+ // schema.ts
47
+ export const talkSchema = z.object({
48
+ title: z.string().min(1).max(120),
49
+ eventUrl: z.url(),
50
+ blogId: z.coerce.number().int().positive(),
51
+ });
52
+ ```
53
+
54
+ Type conversion stays in the schema. `parseForm` handles what only it can —
55
+ an unchecked checkbox arrives as a missing key, a repeated name arrives as
56
+ several entries, everything is a string — and `z.coerce` handles `'42'` → `42`.
57
+ That way the schema keeps describing what it actually validates.
58
+
59
+ ```tsx
60
+ // page.tsx — Server Component
61
+ const talkFields = formFields(talkSchema); // module scope: derived once
62
+
63
+ export default function Page() {
64
+ return <TalkForm action={createTalk} fields={talkFields} />;
65
+ }
66
+ ```
67
+
68
+ `useForm` takes the whole result — fields, arrays, rules, and the `dropped`
69
+ report travel together.
70
+
71
+ ```tsx
72
+ // talk-form.tsx
73
+ 'use client';
74
+
75
+ export const TalkForm = ({ action, fields }: TalkFormProps) => {
76
+ const [state, formAction] = useActionState(action, {});
77
+ const form = useForm(fields, state);
78
+ const title = form.field('title');
79
+
80
+ return (
81
+ <form {...form.props} action={formAction}>
82
+ <input {...title.input} />
83
+ {title.error !== undefined && <p>{title.error}</p>}
84
+ </form>
85
+ );
86
+ };
87
+ ```
88
+
89
+ `form.props` attaches to the `<form>` and nowhere else. There is no per-field
90
+ registration to forget.
91
+
92
+ ```ts
93
+ // actions.ts
94
+ 'use server';
95
+
96
+ export async function createTalk(_prev: FormState, formData: FormData) {
97
+ const parsed = parseForm(talkSchema, formData);
98
+ if (!parsed.success) return parsed.state;
99
+
100
+ await insertTalk(parsed.data); // typed
101
+ redirect('/talks');
102
+ }
103
+ ```
104
+
105
+ ## What it guarantees
106
+
107
+ **The wording cannot drift.** Client messages are obtained by running the
108
+ 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.
110
+
111
+ **`required` means the same thing on both sides.** JSON Schema's `required`
112
+ 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.
125
+
126
+ **A schema this package cannot express fails at derive time.** `z.record`,
127
+ tuples, a repeat nested inside a repeat, nullable objects, keys containing
128
+ `.` or brackets — `formFields` throws with the reason, instead of deriving a
129
+ form that would silently misparse what the person typed.
130
+
131
+ **A missing name is loud.** If the schema has a field that never arrived in the
132
+ FormData, `parseForm` throws instead of reporting it as a validation failure.
133
+ Forgetting to spread `input` is a wiring mistake, not something the person
134
+ filling in the form did. The one exception is an enum field: a radio group
135
+ with nothing selected submits no entry at all — a state the person can reach —
136
+ so it comes back as that field's validation error instead.
137
+
138
+ **Native validation survives without JavaScript.** `noValidate` is applied
139
+ from JavaScript on mount, never rendered into the markup. With scripts
140
+ disabled or not yet loaded, the browser's own checks stay on; once the hook is
141
+ live, it takes over the message path and the server stays the arbiter.
142
+
143
+ **Secrets are never echoed.** `parseForm` returns the submitted values so a
144
+ retry without JavaScript keeps the input. Fields marked as passwords are
145
+ excluded automatically, and typed as `password` in the markup:
146
+
147
+ ```ts
148
+ z.string().min(8).meta({ input: 'password' }); // zod
149
+ globalRegistry.add(password, { input: 'password' }); // zod or zod/mini
150
+ ```
151
+
152
+ `.meta()` is shorthand for the registry and is not available on `zod/mini`;
153
+ the registry route works with either.
154
+
155
+ **`isDirty` costs one boolean.** `form.isDirty` compares each control — text,
156
+ checkbox, `<select>`, a `HiddenValue` — with the value it was rendered with,
157
+ read straight from the DOM; adding or removing a row counts too. It flips at
158
+ most twice, so it never becomes a per-keystroke re-render.
159
+
160
+ ## Nested objects and repeated rows
161
+
162
+ A nested field is addressed by its dotted path, which is also the `name` the
163
+ browser submits.
164
+
165
+ ```tsx
166
+ const email = form.field('user.email'); // name="user.email"
167
+ ```
168
+
169
+ A repeated group is reached through `array()`. React state holds only the
170
+ identity of each row — the values stay in the DOM, so adding or removing a row
171
+ never copies anything into React.
172
+
173
+ ```tsx
174
+ const items = form.array('items');
175
+
176
+ {
177
+ items.rows.map((row) => (
178
+ <div key={row.key}>
179
+ <input {...row.field('name').input} /> {/* name="items[0].name" */}
180
+ {items.canRemove && (
181
+ <button onClick={row.remove} type="button">
182
+ 削除
183
+ </button>
184
+ )}
185
+ </div>
186
+ ));
187
+ }
188
+ {
189
+ items.canAdd && (
190
+ <button onClick={items.add} type="button">
191
+ 追加
192
+ </button>
193
+ );
194
+ }
195
+ ```
196
+
197
+ `canAdd` and `canRemove` come from the schema's `.min()` / `.max()`, so the
198
+ buttons disappear at exactly the bounds the server enforces. For an array of
199
+ scalars (`z.array(z.string())`) the item has a single unnamed field:
200
+ `row.field()`.
201
+
202
+ `parseForm` reports how many rows arrived in `state.rows`, so a retry without
203
+ JavaScript rebuilds the same number of rows. Row counts are read from the
204
+ submitted names but never trusted beyond the schema's `.max()` (or a hard
205
+ ceiling), so a forged index cannot make the server allocate.
206
+
207
+ A nested object behind `.optional()` / `.default()` keeps its fields — the
208
+ wrapper is peeled the same way on both sides. A repeat nested inside a repeat
209
+ has no unambiguous name; it is a compile error and a derive-time throw.
210
+
211
+ ## Checkbox groups
212
+
213
+ An array of enums is a fixed option set the person picks several of — one
214
+ name shared by every box, not repeated rows. It derives as a single field:
215
+
216
+ ```tsx
217
+ const prefs = z.object({ tags: z.array(z.enum(['a', 'b', 'c'])).min(2) });
218
+
219
+ const tags = form.field('tags');
220
+
221
+ {
222
+ ['a', 'b', 'c'].map((option) => (
223
+ <input key={option} type="checkbox" value={option} {...tags.input} />
224
+ ));
225
+ }
226
+ ```
227
+
228
+ `parseForm` reads every checked box under the shared name — none checked is
229
+ `[]`, never a wiring error. The `.min(2)` cannot become an HTML attribute (on
230
+ a group, `required` would mean "check every box"), so it is reported in
231
+ `dropped`; declare `minChecked('tags', 2, '…')` to run the same bound in the
232
+ browser. On a no-JS retry the group echoes as an array, so restoring is:
233
+
234
+ ```tsx
235
+ defaultChecked={Array.isArray(state.values?.tags) && state.values.tags.includes(option)}
236
+ ```
237
+
238
+ ## Paths are checked at compile time
239
+
240
+ `formFields` derives the set of valid paths from the schema type, so a typo is
241
+ a build error rather than something you find by clicking. Rules are typed
242
+ against the same paths.
243
+
244
+ ```tsx
245
+ form.field('titel'); // error: not a field in the schema
246
+ form.field('items'); // error: an array of objects, reached through array()
247
+ form.array('user'); // error: an object, not an array
248
+ form.array('tags'); // error: a checkbox group is a field
249
+ defineForm(schema, [sameAs('confrim', 'password', '…')]); // error: typo
250
+ ```
251
+
252
+ ## Checks HTML has no attribute for
253
+
254
+ Password confirmation, "tick at least two", "required only when rejected" —
255
+ none of these map to a constraint attribute, and a `.refine()` cannot cross to
256
+ the client because it is a function. Declare them next to the schema instead:
257
+
258
+ ```ts
259
+ export const signup = defineForm(
260
+ z.object({
261
+ password: z.string().min(8),
262
+ confirm: z.string(),
263
+ }),
264
+ [sameAs('confirm', 'password', 'パスワードが一致しません')],
265
+ );
266
+ ```
267
+
268
+ Pass the definition where you passed the schema — `formFields(signup)` and
269
+ `parseForm(signup, formData)`. The rules are plain data, so they travel with
270
+ the fields, and **both sides run the same evaluator**: the browser against the
271
+ live form, the server against the submission. There is no second
272
+ implementation to drift from the first.
273
+
274
+ On the client a breach is applied with `setCustomValidity`, so it is
275
+ indistinguishable from a built-in check — `:user-invalid` matches and the
276
+ message arrives through the same path as every other one.
277
+
278
+ Available: `sameAs`, `minChecked`, `requiredWhen`. Anything else stays a
279
+ `.refine()`, runs on the server, and is listed in `dropped`.
280
+
281
+ ## Asking the server about one field
282
+
283
+ Whether a name is already taken is something only the server knows. `useAsyncCheck`
284
+ runs on blur, keeps the newest answer when replies arrive out of order, and
285
+ applies the result with `setCustomValidity` — so the answer shows up through the
286
+ same path as every other message. A blur that leaves the value unchanged does
287
+ not ask again, and emptying the field clears the last answer with it.
288
+
289
+ ```tsx
290
+ const slug = form.field('slug');
291
+ const taken = useAsyncCheck(checkSlugAvailable); // a Server Action
292
+
293
+ <input {...slug.input} {...taken.props} />;
294
+ <button disabled={taken.isChecking} type="submit">
295
+ 保存
296
+ </button>;
297
+ ```
298
+
299
+ ## Components that submit nothing
300
+
301
+ A rich text editor or a third-party combobox renders no `<input name>`, so
302
+ FormData never sees it. Park its value in a hidden input rather than pulling it
303
+ into React state and pushing it back out at submit:
304
+
305
+ ```tsx
306
+ <Editor onChange={setBody} value={body} />
307
+ <HiddenValue name="body" value={body} />
308
+ ```
309
+
310
+ This is all `Controller` does in react-hook-form, and it is the one place that
311
+ binding silently breaks there. Here the value is in the form, so it submits
312
+ whether or not anything else works. `HiddenValue` is a component rather than a
313
+ props helper because React updates a controlled value without any DOM event —
314
+ the component announces each change with one, so cross-field rules and
315
+ `isDirty` hear it like any keystroke.
316
+
317
+ ## Multi-step forms
318
+
319
+ Keep every step mounted and hide the ones you are not on. The values stay in
320
+ the DOM, so moving between steps costs nothing and losing a step is impossible:
321
+
322
+ ```tsx
323
+ <div hidden={step !== 1} ref={stepOne}>
324
+ {/* … */}
325
+ </div>
326
+ ```
327
+
328
+ Validate one step before advancing by checking only the controls inside that
329
+ step's container — the hidden steps are not filled in yet, so checking the
330
+ whole form would always fail:
331
+
332
+ ```tsx
333
+ const stepIsValid = [
334
+ ...(stepOne.current?.querySelectorAll('input, select, textarea') ?? []),
335
+ ].every((control) => (control as HTMLInputElement).checkValidity());
336
+ ```
337
+
338
+ Without JavaScript this degrades to one long form that submits in a single
339
+ request — which is the correct behaviour, not a broken one.
340
+
341
+ ## What it does not do yet
342
+
343
+ - Input masking. Rewriting `el.value` on input works with the DOM as the source
344
+ of truth, but managing the caret is a separate problem from wiring a form.
345
+
346
+ ## Working with @k8ordo/ui
347
+
348
+ `@k8ordo/form` does not depend on `@k8ordo/ui`. Attributes go to the input;
349
+ `FormControl` gets only what it uses, and generates the `id` and `aria-*`
350
+ links itself.
351
+
352
+ ```tsx
353
+ const title = form.field('title');
354
+
355
+ <FormControl
356
+ errorText={title.error}
357
+ invalid={title.invalid}
358
+ label="タイトル"
359
+ required={title.required}
360
+ renderInput={(props) => <TextField {...props} {...title.input} />}
361
+ />;
362
+ ```
package/docs/llms.txt ADDED
@@ -0,0 +1,32 @@
1
+ # @k8ordo/form
2
+
3
+ > Derive HTML constraint attributes, error messages, and server-side validation
4
+ > from one zod schema. The DOM holds the values; React state holds only what the
5
+ > DOM cannot express. Forms work with JavaScript disabled or not yet loaded.
6
+ >
7
+ > Like every k8ordo package it assumes React 19 and Server Components, uses only
8
+ > what has reached Baseline newly available, and ships no polyfills or legacy
9
+ > fallbacks.
10
+
11
+ When installed via npm, these docs live under `node_modules/@k8ordo/form/docs/`.
12
+
13
+ ## Guide
14
+
15
+ - [Design Guide](GUIDE.md): the server/client split, writing a form end to end,
16
+ what the package guarantees, what it does not do yet, and how it pairs with
17
+ `@k8ordo/ui` without depending on it.
18
+
19
+ ## Entry points
20
+
21
+ - `@k8ordo/form` — client. `useForm(fields, state)` takes the whole
22
+ `formFields` result and returns props for the `<form>` element, a
23
+ `field(path)` accessor giving input attributes, the current error, and
24
+ `invalid` / `required`, an `array(path)` accessor for repeated rows, and
25
+ `isDirty`. Also `useAsyncCheck` for server-answered per-field checks and
26
+ `HiddenValue` for components that render no input of their own.
27
+ - `@k8ordo/form/server` — server. `formFields(schema)` derives serializable
28
+ fields plus a `dropped` list of checks HTML cannot express, and throws on a
29
+ schema no form can represent; `parseForm(schema, formData)` validates a
30
+ submission and returns typed data or per-field errors with the submitted
31
+ values. `defineForm(schema, rules)` attaches cross-field rules (`sameAs`,
32
+ `minChecked`, `requiredWhen`) typed against the schema's paths.
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@k8ordo/form",
3
+ "version": "0.1.0",
4
+ "description": "Derive HTML constraint attributes and per-field errors from one zod schema. Works without JavaScript.",
5
+ "keywords": [
6
+ "constraint-validation",
7
+ "form",
8
+ "progressive-enhancement",
9
+ "react",
10
+ "server-actions",
11
+ "zod"
12
+ ],
13
+ "homepage": "https://ordo.k8o.me",
14
+ "license": "MIT",
15
+ "author": "k8o <kosakanoki@gmail.com>",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/k35o/k8ordo.git"
19
+ },
20
+ "files": [
21
+ "dist/**",
22
+ "docs/**"
23
+ ],
24
+ "type": "module",
25
+ "sideEffects": false,
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.mts",
29
+ "default": "./dist/index.mjs"
30
+ },
31
+ "./server": {
32
+ "types": "./dist/server.d.mts",
33
+ "default": "./dist/server.mjs"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org"
40
+ },
41
+ "devDependencies": {
42
+ "@types/react": "19.2.18",
43
+ "@vitejs/plugin-react": "6.0.5",
44
+ "@vitest/browser-playwright": "4.1.10",
45
+ "react": "19.2.8",
46
+ "react-dom": "19.2.8",
47
+ "typescript": "7.0.2",
48
+ "vite": "8.2.1",
49
+ "vite-plus": "0.2.9",
50
+ "vitest": "4.1.10",
51
+ "vitest-browser-react": "2.2.0",
52
+ "zod": "4.4.3"
53
+ },
54
+ "peerDependencies": {
55
+ "@types/react": ">=19.2.18",
56
+ "react": ">=19.2.6",
57
+ "react-dom": ">=19.2.6",
58
+ "typescript": ">=7.0.2",
59
+ "zod": "^4.4.3"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "@types/react": {
63
+ "optional": true
64
+ },
65
+ "typescript": {
66
+ "optional": true
67
+ }
68
+ },
69
+ "scripts": {
70
+ "build": "vp pack",
71
+ "test": "vp test",
72
+ "typecheck": "tsc --noEmit",
73
+ "check": "vp check",
74
+ "check:write": "vp check --fix"
75
+ }
76
+ }