@k8ordo/form 0.1.1 → 1.0.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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @k8ordo/form
2
2
 
3
3
  Derive HTML constraint attributes, error messages, and server-side validation
4
- from one zod schema — forms whose two sides cannot disagree, because the client
5
- side is derived, never written. Works with JavaScript disabled or not yet
4
+ from one zod schema — the client side is derived, never written, so there is no
5
+ second copy of a constraint to drift. Works with JavaScript disabled or not yet
6
6
  loaded.
7
7
 
8
8
  Like every [k8ordo](https://ordo.k8o.me) package it assumes React 19 and Server
@@ -22,16 +22,20 @@ pnpm add @k8ordo/form zod
22
22
 
23
23
  ## Peer Dependencies
24
24
 
25
- | Package | Version | Needed for |
26
- | -------------- | -------- | ----------------------------- |
27
- | `react` | ≥19.2.6 | `useForm` and Server Actions |
28
- | `react-dom` | ≥19.2.6 | rendering |
29
- | `zod` | ^4.4.3 | the schema (`zod/mini` works) |
30
- | `typescript` | ≥7.0.2 | the shipped type declarations |
31
- | `@types/react` | ≥19.2.18 | the shipped type declarations |
25
+ <!-- peers -->
26
+
27
+ | Package | Version | Required | Needed for |
28
+ | -------------- | ------- | -------- | ----------------------------- |
29
+ | `react` | ≥19.3.0 | yes | `useForm` and Server Actions |
30
+ | `react-dom` | ≥19.3.0 | yes | rendering |
31
+ | `zod` | ^4.4.3 | yes | the schema (`zod/mini` works) |
32
+ | `typescript` | ≥7.0.0 | optional | the shipped type declarations |
33
+ | `@types/react` | ≥19.3.0 | optional | the shipped type declarations |
34
+
35
+ <!-- /peers -->
32
36
 
33
37
  zod never reaches the browser: the schema is read on the server and crosses the
34
- RSC boundary as plain data. The client entry is ~2.7 kB gzipped.
38
+ RSC boundary as plain data. The client entry is ~2.8 kB gzipped.
35
39
 
36
40
  ## Quick Start
37
41
 
@@ -39,7 +43,7 @@ One schema drives everything — the attributes, the messages, and the
39
43
  server-side validation:
40
44
 
41
45
  ```ts
42
- // schema.ts
46
+ // src/lib/schema.ts
43
47
  export const talkSchema = z.object({
44
48
  title: z.string().min(1, 'タイトルを入力してください').max(120),
45
49
  eventUrl: z.url(),
@@ -47,7 +51,7 @@ export const talkSchema = z.object({
47
51
  ```
48
52
 
49
53
  ```tsx
50
- // page.tsx — Server Component
54
+ // src/routes/talks/new/page.tsx — Server Component
51
55
  import { formFields } from '@k8ordo/form/server';
52
56
 
53
57
  const talkFields = formFields(talkSchema); // derived once, plain data
@@ -58,7 +62,7 @@ export default function Page() {
58
62
  ```
59
63
 
60
64
  ```tsx
61
- // talk-form.tsx
65
+ // src/components/talk-form.tsx
62
66
  'use client';
63
67
  import { useForm } from '@k8ordo/form';
64
68
 
@@ -78,29 +82,33 @@ export const TalkForm = ({ action, fields }: TalkFormProps) => {
78
82
  ```
79
83
 
80
84
  ```ts
81
- // actions.ts
85
+ // src/lib/actions.ts
82
86
  'use server';
83
87
  import { parseForm } from '@k8ordo/form/server';
88
+ import { href } from '@k8ordo/framework';
89
+ import { redirect } from '@k8ordo/framework/server';
84
90
 
85
91
  export async function createTalk(_prev: FormState, formData: FormData) {
86
92
  const parsed = parseForm(talkSchema, formData);
87
93
  if (!parsed.success) return parsed.state;
88
94
  await insertTalk(parsed.data); // typed
89
- redirect('/talks');
95
+ redirect(href('/talks'));
90
96
  }
91
97
  ```
92
98
 
93
99
  The [design guide](docs/GUIDE.md) covers the rest: nested objects and repeated
94
100
  rows, checkbox groups, cross-field rules typed against the schema's paths,
95
101
  async per-field checks, components that render no input of their own, and what
96
- the package guarantees (nothing dropped in silence, native validation kept
97
- without JavaScript, secrets never echoed).
98
-
99
- A check HTML cannot express — a `refine`, a regex the `pattern` attribute
100
- would reinterpret — is returned in `dropped` (typed as `DroppedCheck`, exported
101
- from both entries) and, outside production, logged once per schema with
102
- `console.warn`, so it is seen without anyone remembering to read it. It still
103
- runs on the server.
102
+ the package guarantees (checks the client skips are reported, native
103
+ validation kept without JavaScript, secrets never echoed).
104
+
105
+ A check HTML cannot express — a `refine` on the schema as a whole, a regex the
106
+ `pattern` attribute would reinterpret, several regexes on one string, a
107
+ `.mime()` that `accept` only suggests — is returned in `dropped` (typed as
108
+ `DroppedCheck`, exported from both entries) and, outside production, logged
109
+ once per schema with `console.warn`, so it is seen without anyone remembering
110
+ to read it. It still runs on the server. Not reported yet: a `refine` on a
111
+ single field, a nested object, or a row.
104
112
 
105
113
  ## AI Agent Documentation
106
114
 
@@ -8,7 +8,7 @@
8
8
  * the same message path as every built-in check instead of opening a second
9
9
  * one.
10
10
  */
11
- type AsyncCheck = {
11
+ export type AsyncCheck = {
12
12
  /** Attach to the input this check guards. */
13
13
  props: {
14
14
  onBlur: () => void;
@@ -17,6 +17,5 @@ type AsyncCheck = {
17
17
  /** True while an answer is outstanding, for disabling submit. */
18
18
  isChecking: boolean;
19
19
  };
20
- declare const useAsyncCheck: (check: (value: string) => Promise<string | undefined>) => AsyncCheck;
21
- //#endregion
22
- export { AsyncCheck, useAsyncCheck };
20
+ export declare const useAsyncCheck: (check: (value: string) => Promise<string | undefined>) => AsyncCheck;
21
+ //#endregion
@@ -1,7 +1,7 @@
1
1
  import { DroppedCheck, FieldInput } from "../types.mjs";
2
2
  //#region src/derive/attributes.d.ts
3
3
  /** The subset of JSON Schema that `z.toJSONSchema` emits for a leaf field. */
4
- type LeafSchema = {
4
+ export type LeafSchema = {
5
5
  type?: string;
6
6
  format?: string;
7
7
  pattern?: string;
@@ -13,20 +13,55 @@ type LeafSchema = {
13
13
  exclusiveMaximum?: number;
14
14
  multipleOf?: number;
15
15
  enum?: unknown[];
16
- anyOf?: unknown[];
16
+ contentMediaType?: string;
17
+ anyOf?: LeafSchema[];
18
+ /** Where zod puts a string's patterns once there is more than one. */
19
+ allOf?: LeafSchema[];
20
+ };
21
+ /**
22
+ * Which control a leaf becomes, at the resolution the submission cares about.
23
+ * `attributesFor` needs the finer distinctions (`email` vs `url`); the walk and
24
+ * the parse need only this. The walk settles it once per leaf, and both read it
25
+ * from there, which is what keeps the three from disagreeing about what an
26
+ * untouched control submits.
27
+ *
28
+ * チェックボックスは、スキーマが何を読むかで 2 つに分かれる。`checkbox` は
29
+ * チェックの有無を真偽値で(`z.boolean()`)、`string-checkbox` は送られた
30
+ * 文字列か何も無いかを(`z.stringbool()`)読む。JSON Schema ではどちらも
31
+ * boolean なので、分けられるのは walk だけ。
32
+ */
33
+ export type ControlKind = 'checkbox' | 'choice' | 'file' | 'number' | 'string-checkbox' | 'text';
34
+ export declare const controlKindOf: (schema: LeafSchema) => ControlKind;
35
+ /**
36
+ * What the control hands the schema when nobody fills it in. A text field
37
+ * always submits `''` and an unchecked checkbox parses to `false`, but a
38
+ * number, a file and a choice have no such value: an empty numeric field is not
39
+ * 0, an unfilled file input is not a zero-byte file, and a select left on its
40
+ * placeholder has chosen nothing — just like a radio group with no selection,
41
+ * which submits no entry at all (as does an unchecked `string-checkbox`).
42
+ * Reading them as "nothing was entered" is what keeps `required` honest —
43
+ * `z.coerce.number()` turns `''` into 0, so probing with `''` would say an
44
+ * empty field is acceptable and then let a blank submission through as a
45
+ * number the person never typed.
46
+ */
47
+ export declare const emptySubmissionOf: (kind: ControlKind) => unknown;
48
+ /** Whether a JSON Schema `format` names what a control submits, not a refinement of it. */
49
+ export declare const namesAControl: (format: string) => boolean;
50
+ /** What the JSON Schema has already lost, recovered from the zod side when it can be. */
51
+ export type PatternHints = {
52
+ /** Flags of the RegExp behind a lone `pattern`. */
53
+ flags?: string;
54
+ /** Source of the regex the format itself carries, as opposed to one stacked on it. */
55
+ ofFormat?: string;
17
56
  };
18
57
  /**
19
58
  * Turn one leaf JSON Schema into input attributes, collecting whatever HTML
20
59
  * cannot express. Anything not representable is returned rather than dropped,
21
60
  * so the caller can report it instead of leaving the author to discover at
22
61
  * runtime that a check never ran on the client.
23
- *
24
- * `patternFlags` are the flags of the source RegExp when the caller could
25
- * recover it — the JSON Schema string has already lost them.
26
62
  */
27
- declare const attributesFor: (name: string, schema: LeafSchema, required: boolean, patternFlags?: string) => {
63
+ export declare const attributesFor: (name: string, schema: LeafSchema, required: boolean, hints?: PatternHints) => {
28
64
  input: FieldInput;
29
65
  dropped: DroppedCheck[];
30
66
  };
31
- //#endregion
32
- export { LeafSchema, attributesFor };
67
+ //#endregion
@@ -1,4 +1,27 @@
1
1
  //#region src/derive/attributes.ts
2
+ const controlKindOf = (schema) => {
3
+ if (schema.format === "binary") return "file";
4
+ if (schema.enum !== void 0) return "choice";
5
+ if (schema.type === "boolean") return "checkbox";
6
+ if (schema.type === "integer" || schema.type === "number") return "number";
7
+ return "text";
8
+ };
9
+ /**
10
+ * What the control hands the schema when nobody fills it in. A text field
11
+ * always submits `''` and an unchecked checkbox parses to `false`, but a
12
+ * number, a file and a choice have no such value: an empty numeric field is not
13
+ * 0, an unfilled file input is not a zero-byte file, and a select left on its
14
+ * placeholder has chosen nothing — just like a radio group with no selection,
15
+ * which submits no entry at all (as does an unchecked `string-checkbox`).
16
+ * Reading them as "nothing was entered" is what keeps `required` honest —
17
+ * `z.coerce.number()` turns `''` into 0, so probing with `''` would say an
18
+ * empty field is acceptable and then let a blank submission through as a
19
+ * number the person never typed.
20
+ */
21
+ const emptySubmissionOf = (kind) => {
22
+ if (kind === "checkbox") return false;
23
+ return kind === "text" ? "" : void 0;
24
+ };
2
25
  /**
3
26
  * `.int()` carries JavaScript's safe-integer range into the schema. Emitting it
4
27
  * as `max` would put 9007199254740991 in the markup, which tells a reader
@@ -44,19 +67,37 @@ const patternVerdict = (source, flags) => {
44
67
  }
45
68
  })() ? void 0 : "ブラウザは pattern を v フラグで解釈し、コンパイルできない正規表現は黙って無視します";
46
69
  };
70
+ /** Whether a JSON Schema `format` names what a control submits, not a refinement of it. */
71
+ const namesAControl = (format) => Object.hasOwn(FORMAT_TO_INPUT_TYPE, format);
47
72
  /**
48
73
  * Turn one leaf JSON Schema into input attributes, collecting whatever HTML
49
74
  * cannot express. Anything not representable is returned rather than dropped,
50
75
  * so the caller can report it instead of leaving the author to discover at
51
76
  * runtime that a check never ran on the client.
52
- *
53
- * `patternFlags` are the flags of the source RegExp when the caller could
54
- * recover it — the JSON Schema string has already lost them.
55
77
  */
56
- const attributesFor = (name, schema, required, patternFlags) => {
78
+ const attributesFor = (name, schema, required, hints = {}) => {
57
79
  const input = { name };
58
80
  const dropped = [];
59
81
  if (required) input.required = true;
82
+ if (controlKindOf(schema) === "file") {
83
+ input.type = "file";
84
+ const accept = [schema.contentMediaType, ...(schema.anyOf ?? []).map((branch) => branch.contentMediaType)].filter((type) => type !== void 0);
85
+ if (accept.length > 0) {
86
+ input.accept = accept.join(",");
87
+ dropped.push({
88
+ field: name,
89
+ reason: "accept はファイル選択ダイアログの候補を絞るだけで、ブラウザは選ばれたファイルの MIME タイプを検査しません"
90
+ });
91
+ }
92
+ if (schema.minLength !== void 0 || schema.maxLength !== void 0) dropped.push({
93
+ field: name,
94
+ reason: "ファイルサイズの制限に対応する HTML 属性はありません(minlength / maxlength は type=\"file\" では無視されます)"
95
+ });
96
+ return {
97
+ input,
98
+ dropped
99
+ };
100
+ }
60
101
  if (schema.anyOf !== void 0) {
61
102
  input.type = "text";
62
103
  dropped.push({
@@ -103,23 +144,37 @@ const attributesFor = (name, schema, required, patternFlags) => {
103
144
  dropped
104
145
  };
105
146
  }
147
+ if (schema.type !== "string") {
148
+ input.type = "text";
149
+ dropped.push({
150
+ field: name,
151
+ reason: "スキーマから制約を読み取れないため(transform / pipe / custom など)、type=\"text\" 以外は何も導出できません。ブラウザ側では何も検査されません"
152
+ });
153
+ return {
154
+ input,
155
+ dropped
156
+ };
157
+ }
106
158
  input.type = schema.format === void 0 ? "text" : FORMAT_TO_INPUT_TYPE[schema.format] ?? "text";
107
159
  if (isFiniteNumber(schema.minLength) && schema.minLength > 0) input.minLength = schema.minLength;
108
160
  if (isFiniteNumber(schema.maxLength)) input.maxLength = schema.maxLength;
109
- if (schema.pattern !== void 0) {
110
- if (TYPES_WITHOUT_PATTERN.has(input.type)) {
111
- if (input.type !== "date" && input.type !== "datetime-local") dropped.push({
112
- field: name,
113
- reason: `pattern は type="${input.type}" では無視されます`
114
- });
115
- } else {
116
- const verdict = patternVerdict(schema.pattern, patternFlags);
117
- if (verdict === void 0) input.pattern = schema.pattern;
118
- else dropped.push({
119
- field: name,
120
- reason: verdict
121
- });
122
- }
161
+ const patterns = schema.pattern === void 0 ? (schema.allOf ?? []).flatMap((branch) => branch.pattern === void 0 ? [] : [branch.pattern]) : [schema.pattern];
162
+ if (TYPES_WITHOUT_PATTERN.has(input.type)) {
163
+ if (patterns.filter((pattern) => !((input.type === "date" || input.type === "datetime-local") && pattern === hints.ofFormat)).length > 0) dropped.push({
164
+ field: name,
165
+ reason: `pattern は type="${input.type}" では無視されます`
166
+ });
167
+ } else if (patterns.length > 1) dropped.push({
168
+ field: name,
169
+ reason: `HTML の pattern は 1 つしか持てないため、重ねた ${String(patterns.length)} 個の正規表現はブラウザでは検査されません`
170
+ });
171
+ else if (patterns[0] !== void 0) {
172
+ const verdict = patternVerdict(patterns[0], hints.flags);
173
+ if (verdict === void 0) input.pattern = patterns[0];
174
+ else dropped.push({
175
+ field: name,
176
+ reason: verdict
177
+ });
123
178
  }
124
179
  return {
125
180
  input,
@@ -127,4 +182,4 @@ const attributesFor = (name, schema, required, patternFlags) => {
127
182
  };
128
183
  };
129
184
  //#endregion
130
- export { attributesFor };
185
+ export { attributesFor, controlKindOf, emptySubmissionOf, namesAControl };
@@ -1,5 +1,5 @@
1
1
  import { FormFields } from "../types.mjs";
2
- import { ArrayPathsOf, FieldPathsOf } from "../paths.mjs";
2
+ import { ArrayPathsOf, FieldPathsOf, StringCheckboxPathsOf } from "../paths.mjs";
3
3
  import { ObjectSchema } from "../schema/object-schema.mjs";
4
4
  import { FormDefinition } from "../rules/define-form.mjs";
5
5
  //#region src/derive/form-fields.d.ts
@@ -8,8 +8,9 @@ import { FormDefinition } from "../rules/define-form.mjs";
8
8
  *
9
9
  * Call this on the server — in a Server Component or at module scope. The
10
10
  * result is plain data, so it crosses to the client as props and zod never
11
- * enters the bundle.
11
+ * enters the bundle. Messages are read when it runs — zod's, and a rule's
12
+ * function message — so when one follows the request (its locale, say), call
13
+ * it during the render, not at module scope.
12
14
  */
13
- declare const formFields: <Schema extends ObjectSchema>(input: FormDefinition<Schema> | Schema) => FormFields<FieldPathsOf<Schema>, ArrayPathsOf<Schema>>;
14
- //#endregion
15
- export { formFields };
15
+ export declare const formFields: <Schema extends ObjectSchema>(input: FormDefinition<Schema> | Schema) => FormFields<FieldPathsOf<Schema>, ArrayPathsOf<Schema>, StringCheckboxPathsOf<Schema>>;
16
+ //#endregion
@@ -1,26 +1,40 @@
1
+ import { deriveRule } from "../rules/rules.mjs";
1
2
  import { asDefinition } from "../rules/define-form.mjs";
2
3
  import { asProbe } from "../schema/object-schema.mjs";
3
- import { schemaMap } from "../schema/walk.mjs";
4
- import { attributesFor } from "./attributes.mjs";
4
+ import { attributesFor, emptySubmissionOf, namesAControl } from "./attributes.mjs";
5
+ import { schemaMap, unwrap } from "../schema/walk.mjs";
5
6
  import { messagesFor } from "./messages.mjs";
6
7
  //#region src/derive/form-fields.ts
7
8
  const objectLevelCheckCount = (schema) => schema._zod?.def?.checks?.length ?? 0;
8
9
  /**
9
- * Recover the flags of the RegExp behind a JSON Schema `pattern` string. The
10
- * JSON string has already lost them, and whether the browser may see the
11
- * pattern depends on them (an `i` flag has no HTML equivalent).
10
+ * The string formats a leaf declares — its own (`z.email()`) first, then the
11
+ * ones its checks add (`.regex()`, `.lowercase()`) — each with the RegExp
12
+ * behind it. The JSON Schema cannot stand in: its `pattern` strings have lost
13
+ * their flags (an `i` has no HTML equivalent), and a later check overwrites
14
+ * the `format` keyword (`z.url().lowercase()` says `lowercase`) or erases it
15
+ * (`.regex()`).
12
16
  */
13
- const patternFlags = (schema, source) => {
14
- const def = schema._zod?.def;
15
- if (def?.pattern?.source === source) return def.pattern.flags;
16
- for (const check of def?.checks ?? []) {
17
- const candidate = check._zod?.def?.pattern;
18
- if (candidate?.source === source) return candidate.flags;
19
- }
17
+ const stringFormatsOf = (schema) => {
18
+ const def = unwrap(schema)._zod?.def;
19
+ return [def, ...(def?.checks ?? []).map((check) => check._zod?.def)].filter((entry) => entry?.format !== void 0);
20
+ };
21
+ /** zod's own name for a format is not always the JSON Schema one. */
22
+ const ZOD_FORMAT_TO_JSON_FORMAT = {
23
+ datetime: "date-time",
24
+ guid: "uuid",
25
+ url: "uri"
20
26
  };
27
+ const jsonFormatOf = (format) => ZOD_FORMAT_TO_JSON_FORMAT[format] ?? format;
21
28
  /** What a `datetime-local` control actually submits: no seconds, no zone. */
22
29
  const DATETIME_LOCAL_PROBE = "2000-01-01T00:00";
23
30
  /**
31
+ * Whether the date-time format itself turns away what a `datetime-local`
32
+ * control submits. Only the format's own issue counts: a regex stacked on it
33
+ * that fails the probe is a check the control ignores, not a shape the control
34
+ * cannot produce.
35
+ */
36
+ const refusesDatetimeLocal = (schema) => asProbe(schema).safeParse(DATETIME_LOCAL_PROBE).error?.issues.some((issue) => issue.code === "invalid_format" && issue.format === "datetime") ?? false;
37
+ /**
24
38
  * Schemas whose `dropped` list has already been reported. `formFields` is not
25
39
  * memoized, so a schema derived in every request would otherwise warn on
26
40
  * every request; keyed by the schema object, an HMR re-evaluation still gets
@@ -49,7 +63,9 @@ const warnDropped = (schema, dropped) => {
49
63
  *
50
64
  * Call this on the server — in a Server Component or at module scope. The
51
65
  * result is plain data, so it crosses to the client as props and zod never
52
- * enters the bundle.
66
+ * enters the bundle. Messages are read when it runs — zod's, and a rule's
67
+ * function message — so when one follows the request (its locale, say), call
68
+ * it during the render, not at module scope.
53
69
  */
54
70
  const formFields = (input) => {
55
71
  const { schema, rules } = asDefinition(input);
@@ -64,23 +80,39 @@ const formFields = (input) => {
64
80
  item: {}
65
81
  };
66
82
  for (const leaf of map.leaves) {
67
- const attributes = attributesFor(leaf.name, leaf.json, leaf.required, leaf.json.pattern === void 0 ? void 0 : patternFlags(leaf.zod, leaf.json.pattern));
68
- const secret = leaf.json.input === "password";
69
- if (secret) attributes.input.type = "password";
70
- if (attributes.input.type === "datetime-local" && !asProbe(leaf.zod).safeParse(DATETIME_LOCAL_PROBE).success) {
71
- attributes.input.type = "text";
72
- attributes.dropped.push({
83
+ const formats = stringFormatsOf(leaf.zod);
84
+ const own = leaf.json.type === "string" ? formats.find((entry) => namesAControl(jsonFormatOf(entry.format))) : void 0;
85
+ let format = own === void 0 ? leaf.json.format : jsonFormatOf(own.format);
86
+ const formatDropped = [];
87
+ if (format === "date-time" && refusesDatetimeLocal(leaf.zod)) {
88
+ format = void 0;
89
+ formatDropped.push({
73
90
  field: leaf.name,
74
- reason: "z.iso.datetime() はタイムゾーンを要求しますが、datetime-local はタイムゾーンを送信できません。type=\"text\" に落とします(local: true なら datetime-local が使えます)"
91
+ reason: `この日時書式は datetime-local が送信する形(${DATETIME_LOCAL_PROBE} のように秒もタイムゾーンも付かない)を受け付けないため、type="text" に落とします(z.iso.datetime({ local: true }) なら datetime-local が使えます)`
75
92
  });
76
93
  }
94
+ const { pattern } = leaf.json;
95
+ const attributes = attributesFor(leaf.name, {
96
+ ...leaf.json,
97
+ format
98
+ }, leaf.required, {
99
+ flags: pattern === void 0 ? void 0 : formats.find((entry) => entry.pattern?.source === pattern)?.pattern?.flags,
100
+ ofFormat: format === void 0 ? void 0 : own?.pattern?.source
101
+ });
102
+ attributes.dropped.unshift(...formatDropped);
103
+ const secret = leaf.json.input === "password";
104
+ if (secret) attributes.input.type = "password";
105
+ const fieldInput = leaf.checkedValue === void 0 ? attributes.input : {
106
+ ...attributes.input,
107
+ value: leaf.checkedValue
108
+ };
77
109
  if (leaf.group !== void 0 && (leaf.group.minItems !== void 0 || leaf.group.maxItems !== void 0)) dropped.push({
78
110
  field: leaf.path,
79
111
  reason: "チェックボックス群の個数制限に対応する HTML 属性はありません。クライアントでも検査するには minChecked を使ってください"
80
112
  });
81
113
  const derived = {
82
- input: attributes.input,
83
- messages: messagesFor(leaf.zod, attributes.input, leaf.required),
114
+ input: fieldInput,
115
+ messages: messagesFor(leaf.zod, fieldInput, leaf.required, emptySubmissionOf(leaf.kind)),
84
116
  secret
85
117
  };
86
118
  const array = leaf.arrayPath === null ? void 0 : arrays[leaf.arrayPath];
@@ -100,7 +132,7 @@ const formFields = (input) => {
100
132
  return {
101
133
  fields,
102
134
  arrays,
103
- rules,
135
+ rules: rules.map((rule) => deriveRule(rule)),
104
136
  dropped
105
137
  };
106
138
  };
@@ -7,6 +7,5 @@ import { $ZodType } from "zod/v4/core";
7
7
  * flag — the browser's own wording is never used, since it is locale-dependent
8
8
  * and cannot be controlled.
9
9
  */
10
- declare const messagesFor: (schema: $ZodType, input: FieldInput, required: boolean) => Partial<Record<ValidityFlag, string>>;
11
- //#endregion
12
- export { messagesFor };
10
+ export declare const messagesFor: (schema: $ZodType, input: FieldInput, required: boolean, emptySubmission: unknown) => Partial<Record<ValidityFlag, string>>;
11
+ //#endregion
@@ -1,19 +1,16 @@
1
1
  import { asProbe } from "../schema/object-schema.mjs";
2
2
  //#region src/derive/messages.ts
3
3
  const repeat = (length) => "a".repeat(Math.max(length, 0));
4
- const probesFor = (input, required) => {
4
+ const probesFor = (input, required, emptySubmission) => {
5
5
  const probes = [];
6
- if (required) probes.push(input.type === "checkbox" ? {
6
+ if (required) probes.push({
7
7
  flag: "valueMissing",
8
- value: false,
9
- codes: ["invalid_value", "invalid_type"]
10
- } : {
11
- flag: "valueMissing",
12
- value: "",
8
+ value: emptySubmission,
13
9
  codes: [
14
10
  "too_small",
15
- "invalid_type",
16
- "invalid_format"
11
+ "invalid_value",
12
+ "invalid_format",
13
+ "invalid_type"
17
14
  ]
18
15
  });
19
16
  if (input.minLength !== void 0) probes.push({
@@ -66,9 +63,9 @@ const probesFor = (input, required) => {
66
63
  * flag — the browser's own wording is never used, since it is locale-dependent
67
64
  * and cannot be controlled.
68
65
  */
69
- const messagesFor = (schema, input, required) => {
66
+ const messagesFor = (schema, input, required, emptySubmission) => {
70
67
  const messages = {};
71
- for (const probe of probesFor(input, required)) {
68
+ for (const probe of probesFor(input, required, emptySubmission)) {
72
69
  const result = asProbe(schema).safeParse(probe.value);
73
70
  if (result.success) continue;
74
71
  const issue = probe.codes.map((code) => result.error.issues.find((candidate) => candidate.code === code)).find((found) => found !== void 0) ?? result.error.issues[0];
@@ -8,18 +8,14 @@ import { FC } from "react";
8
8
  * pulling its value into React state and fighting it back out at submit, park
9
9
  * the value in a hidden input and let the form collect it like any other.
10
10
  *
11
- * This is the whole of what `Controller` exists to do in react-hook-form, and
12
- * it is the one place that binding silently breaks there.
13
- *
14
11
  * A component rather than a props helper for two reasons the form cannot see
15
12
  * from outside: React updates a controlled value without any DOM event, so a
16
13
  * change here has to be announced with one for rules and `isDirty` to notice;
17
- * and a controlled input's defaultValue tracks its value, so the first
18
- * render's value is snapshotted into `data-initial` as the dirtiness baseline.
14
+ * and a controlled input's defaultValue tracks its value, so the value it
15
+ * mounted with is burned into `data-initial` as the dirtiness baseline.
19
16
  */
20
- declare const HiddenValue: FC<{
17
+ export declare const HiddenValue: FC<{
21
18
  name: string;
22
19
  value: string;
23
20
  }>;
24
- //#endregion
25
- export { HiddenValue };
21
+ //#endregion
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { useEffect, useRef } from "react";
2
+ import { useCallback, useEffect, useRef } from "react";
3
3
  import { jsx } from "react/jsx-runtime";
4
4
  //#region src/hidden-value.tsx
5
5
  /**
@@ -10,29 +10,28 @@ import { jsx } from "react/jsx-runtime";
10
10
  * pulling its value into React state and fighting it back out at submit, park
11
11
  * the value in a hidden input and let the form collect it like any other.
12
12
  *
13
- * This is the whole of what `Controller` exists to do in react-hook-form, and
14
- * it is the one place that binding silently breaks there.
15
- *
16
13
  * A component rather than a props helper for two reasons the form cannot see
17
14
  * from outside: React updates a controlled value without any DOM event, so a
18
15
  * change here has to be announced with one for rules and `isDirty` to notice;
19
- * and a controlled input's defaultValue tracks its value, so the first
20
- * render's value is snapshotted into `data-initial` as the dirtiness baseline.
16
+ * and a controlled input's defaultValue tracks its value, so the value it
17
+ * mounted with is burned into `data-initial` as the dirtiness baseline.
21
18
  */
22
19
  const HiddenValue = ({ name, value }) => {
23
20
  const node = useRef(null);
24
- const initial = useRef(value);
25
21
  const last = useRef(value);
22
+ const attach = useCallback((element) => {
23
+ node.current = element;
24
+ if (element !== null) element.dataset.initial = element.value;
25
+ }, []);
26
26
  useEffect(() => {
27
27
  if (last.current === value) return;
28
28
  last.current = value;
29
29
  node.current?.dispatchEvent(new Event("input", { bubbles: true }));
30
30
  }, [value]);
31
31
  return /* @__PURE__ */ jsx("input", {
32
- "data-initial": initial.current,
33
32
  name,
34
33
  readOnly: true,
35
- ref: node,
34
+ ref: attach,
36
35
  type: "hidden",
37
36
  value
38
37
  });
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AsyncCheck, useAsyncCheck } from "./async-check.mjs";
2
- import { Rule } from "./rules/rules.mjs";
3
- import { DerivedArray, DerivedField, DroppedCheck, FieldInput, FormFields, FormState, ValidityFlag } from "./types.mjs";
2
+ import { DerivedRule, Rule, RuleMessage } from "./rules/rules.mjs";
3
+ import { DerivedArray, DerivedField, DroppedCheck, FieldInput, FormFields, FormState, StringCheckboxInput, ValidityFlag } from "./types.mjs";
4
4
  import { HiddenValue } from "./hidden-value.mjs";
5
- import { ArrayView, FieldView, RowView, UseFormReturn, useForm } from "./use-form.mjs";
6
- export { type ArrayView, type AsyncCheck, type DerivedArray, type DerivedField, type DroppedCheck, type FieldInput, type FieldView, type FormFields, type FormState, HiddenValue, type RowView, type Rule, type UseFormReturn, type ValidityFlag, useAsyncCheck, useForm };
5
+ import { ArrayView, FieldView, FormErrorView, RowView, UseFormReturn, useForm } from "./use-form.mjs";
6
+ export { type ArrayView, type AsyncCheck, type DerivedArray, type DerivedField, type DerivedRule, type DroppedCheck, type FieldInput, type FieldView, type FormErrorView, type FormFields, type FormState, HiddenValue, type RowView, type Rule, type RuleMessage, type StringCheckboxInput, type UseFormReturn, type ValidityFlag, useAsyncCheck, useForm };
@@ -3,7 +3,7 @@ import { ObjectSchema } from "../schema/object-schema.mjs";
3
3
  import { FormDefinition } from "../rules/define-form.mjs";
4
4
  import { output } from "zod/v4/core";
5
5
  //#region src/parse/parse-form.d.ts
6
- type ParseResult<Output> = {
6
+ export type ParseResult<Output> = {
7
7
  success: true;
8
8
  data: Output;
9
9
  state: FormState;
@@ -21,6 +21,5 @@ type ParseResult<Output> = {
21
21
  * schema's job via `z.coerce`, so the schema keeps describing what it
22
22
  * actually validates.
23
23
  */
24
- declare const parseForm: <Shape extends ObjectSchema>(input: FormDefinition<Shape> | Shape, formData: FormData) => ParseResult<output<Shape>>;
25
- //#endregion
26
- export { ParseResult, parseForm };
24
+ export declare const parseForm: <Shape extends ObjectSchema>(input: FormDefinition<Shape> | Shape, formData: FormData) => ParseResult<output<Shape>>;
25
+ //#endregion