@zeno-lib/forms 0.0.1 → 0.2.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/package.json +23 -11
- package/src/addons/validation-spinner.tsx +3 -4
- package/src/create-form.tsx +59 -0
- package/src/create-zeno-form.tsx +531 -0
- package/src/fields/checkbox-field.tsx +15 -12
- package/src/fields/checkbox-group-field.tsx +160 -0
- package/src/fields/combobox-field.tsx +13 -14
- package/src/fields/date-picker-field.tsx +18 -15
- package/src/fields/index.ts +5 -0
- package/src/fields/input-field.tsx +12 -13
- package/src/fields/money-field.tsx +123 -0
- package/src/fields/multi-select-field.tsx +193 -0
- package/src/fields/number-field.tsx +1 -2
- package/src/fields/otp-field.tsx +15 -12
- package/src/fields/percentage-field.tsx +73 -0
- package/src/fields/radio-group-field.tsx +12 -13
- package/src/fields/reset-button.tsx +2 -3
- package/src/fields/select-field.tsx +12 -13
- package/src/fields/slider-field.tsx +11 -12
- package/src/fields/submit-button.tsx +3 -4
- package/src/fields/switch-field.tsx +15 -12
- package/src/fields/textarea-field.tsx +12 -13
- package/src/fields/year-field.tsx +56 -0
- package/src/form-dialog.test.tsx +161 -0
- package/src/form-dialog.tsx +253 -0
- package/src/form-element.tsx +51 -0
- package/src/formatted-number-fields.test.tsx +236 -0
- package/src/index.ts +27 -0
- package/src/lib/action-result.ts +61 -0
- package/src/lib/apply-validation-error.test.ts +16 -6
- package/src/lib/apply-validation-error.ts +38 -6
- package/src/lib/formatted-number.test.ts +157 -0
- package/src/lib/formatted-number.ts +307 -0
- package/src/lib/required-indicator.tsx +1 -1
- package/src/lib/schema-defaults.test.ts +1 -2
- package/src/lib/schema-required.test.ts +98 -6
- package/src/lib/schema-required.ts +126 -22
- package/src/lib/submit-action.test-d.ts +83 -0
- package/src/lib/submit-action.ts +178 -0
- package/src/lib/use-form-dialog.ts +184 -0
- package/src/lib/use-formatted-number.ts +235 -0
- package/src/lib/use-is-invalid.ts +4 -4
- package/src/lib/use-rebased-default-values.ts +42 -0
- package/src/lib/use-unsaved-changes-warning.ts +3 -3
- package/src/lib/validation-error.test.ts +1 -1
- package/src/lib/validation-logic.test.ts +1 -1
- package/src/lib/validation-modes.test-d.ts +1 -1
- package/src/lib/validation-modes.test.ts +12 -2
- package/src/lib/validation-modes.ts +3 -1
- package/src/multi-value-fields.test.tsx +116 -0
- package/src/submit-action.test.tsx +421 -0
- package/src/use-app-fields.test-d.ts +8 -25
- package/src/use-app-fields.test.tsx +8 -32
- package/src/use-form.cascade.test.tsx +4 -6
- package/src/use-form.focus-invalid.test.tsx +198 -0
- package/src/use-form.reset-defaults.test.tsx +125 -0
- package/src/use-form.test-d.ts +2 -3
- package/src/use-form.test.tsx +12 -14
- package/src/use-form.unsaved-warning.test.tsx +4 -13
- package/src/use-form.validation-modes.test.tsx +10 -8
- package/src/form.tsx +0 -105
- package/src/use-app-fields.tsx +0 -272
- package/src/use-form.tsx +0 -450
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeno-lib/forms",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"exports": {
|
|
5
|
-
".": "./src/
|
|
6
|
-
"./
|
|
7
|
-
"./
|
|
5
|
+
".": "./src/index.ts",
|
|
6
|
+
"./create-form": "./src/create-form.tsx",
|
|
7
|
+
"./form-dialog": "./src/form-dialog.tsx",
|
|
8
|
+
"./lib/*": "./src/lib/*.ts",
|
|
8
9
|
"./tanstack": "./src/tanstack.ts"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
@@ -23,21 +24,32 @@
|
|
|
23
24
|
"next": ">=16",
|
|
24
25
|
"react": ">=19",
|
|
25
26
|
"react-dom": ">=19",
|
|
26
|
-
"zod": ">=4"
|
|
27
|
-
|
|
27
|
+
"zod": ">=4"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"@tanstack/react-devtools": {
|
|
31
|
+
"optional": true
|
|
32
|
+
},
|
|
33
|
+
"@tanstack/react-form-devtools": {
|
|
34
|
+
"optional": true
|
|
35
|
+
}
|
|
28
36
|
},
|
|
29
37
|
"dependencies": {
|
|
30
|
-
"@tanstack/react-form": "1.
|
|
31
|
-
"@tanstack/react-form-nextjs": "1.
|
|
38
|
+
"@tanstack/react-form": "1.33.5",
|
|
39
|
+
"@tanstack/react-form-nextjs": "1.33.5"
|
|
32
40
|
},
|
|
33
41
|
"devDependencies": {
|
|
34
42
|
"@types/node": "25.6.0",
|
|
35
43
|
"@types/react": "19.2.14",
|
|
36
44
|
"@types/react-dom": "19.2.3",
|
|
37
|
-
"
|
|
45
|
+
"lucide-react": "^1.23.0",
|
|
46
|
+
"typescript": "7.0.2",
|
|
47
|
+
"vite": "8.0.16",
|
|
48
|
+
"vitest": "4.1.11",
|
|
38
49
|
"zod": "^4.3.6",
|
|
39
|
-
"@zeno-lib/typescript": "^1.
|
|
40
|
-
"@zeno-lib/
|
|
50
|
+
"@zeno-lib/typescript": "^1.1.0",
|
|
51
|
+
"@zeno-lib/ui": "^0.0.1",
|
|
52
|
+
"@zeno-lib/test": "^0.1.0"
|
|
41
53
|
},
|
|
42
54
|
"scripts": {
|
|
43
55
|
"test": "vitest --run",
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
import { useFieldContext } from "../lib/contexts"
|
|
3
|
+
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
4
|
+
import { InputGroupAddon } from "@/components/ui/input-group"
|
|
5
|
+
import { Spinner } from "@/components/ui/spinner"
|
|
7
6
|
|
|
8
7
|
type ValidationSpinnerProps = {
|
|
9
8
|
align?: "inline-start" | "inline-end"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/* biome-ignore-all lint/performance/noBarrelFile: composition root for your form. */
|
|
2
|
+
"use client"
|
|
3
|
+
|
|
4
|
+
import { createZenoForm } from "@zeno-lib/forms"
|
|
5
|
+
|
|
6
|
+
import { CheckboxField } from "./fields/checkbox-field"
|
|
7
|
+
import { CheckboxGroupField } from "./fields/checkbox-group-field"
|
|
8
|
+
import { ComboboxField } from "./fields/combobox-field"
|
|
9
|
+
import { DatePickerField } from "./fields/date-picker-field"
|
|
10
|
+
import { EmailField } from "./fields/email-field"
|
|
11
|
+
import { InputField } from "./fields/input-field"
|
|
12
|
+
import { MoneyField } from "./fields/money-field"
|
|
13
|
+
import { MultiSelectField } from "./fields/multi-select-field"
|
|
14
|
+
import { NumberField } from "./fields/number-field"
|
|
15
|
+
import { OtpField } from "./fields/otp-field"
|
|
16
|
+
import { PasswordField } from "./fields/password-field"
|
|
17
|
+
import { PercentageField } from "./fields/percentage-field"
|
|
18
|
+
import { RadioGroupField } from "./fields/radio-group-field"
|
|
19
|
+
import { ResetButton } from "./fields/reset-button"
|
|
20
|
+
import { SelectField } from "./fields/select-field"
|
|
21
|
+
import { SliderField } from "./fields/slider-field"
|
|
22
|
+
import { SubmitButton } from "./fields/submit-button"
|
|
23
|
+
import { SwitchField } from "./fields/switch-field"
|
|
24
|
+
import { TextAreaField } from "./fields/textarea-field"
|
|
25
|
+
import { YearField } from "./fields/year-field"
|
|
26
|
+
|
|
27
|
+
// Wire the dropped-in field components into the headless factory. Edit the
|
|
28
|
+
// field files under `./fields` freely — they use your own shadcn primitives.
|
|
29
|
+
const { useAppForm, useForm, withFieldGroup, withForm } = createZenoForm({
|
|
30
|
+
fieldComponents: {
|
|
31
|
+
CheckboxField,
|
|
32
|
+
CheckboxGroupField,
|
|
33
|
+
ComboboxField,
|
|
34
|
+
DatePickerField,
|
|
35
|
+
EmailField,
|
|
36
|
+
InputField,
|
|
37
|
+
MoneyField,
|
|
38
|
+
MultiSelectField,
|
|
39
|
+
NumberField,
|
|
40
|
+
OtpField,
|
|
41
|
+
PasswordField,
|
|
42
|
+
PercentageField,
|
|
43
|
+
RadioGroupField,
|
|
44
|
+
SelectField,
|
|
45
|
+
SliderField,
|
|
46
|
+
SwitchField,
|
|
47
|
+
TextAreaField,
|
|
48
|
+
YearField,
|
|
49
|
+
},
|
|
50
|
+
formComponents: {
|
|
51
|
+
ResetButton,
|
|
52
|
+
SubmitButton,
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
export * from "@zeno-lib/forms"
|
|
57
|
+
export { ValidationSpinner } from "./addons/validation-spinner"
|
|
58
|
+
export { RadioGroupFieldItem } from "./fields/radio-group-field"
|
|
59
|
+
export { useAppForm, useForm, withFieldGroup, withForm }
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/* biome-ignore-all lint/performance/noBarrelFile: factory returns the wired form API. */
|
|
2
|
+
"use client"
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
AnyFormApi,
|
|
6
|
+
DeepKeys,
|
|
7
|
+
FormAsyncValidateOrFn,
|
|
8
|
+
FormOptions,
|
|
9
|
+
FormValidateOrFn,
|
|
10
|
+
} from "@tanstack/react-form"
|
|
11
|
+
import { createFormHook } from "@tanstack/react-form"
|
|
12
|
+
import { type ComponentProps, type ReactNode, useMemo } from "react"
|
|
13
|
+
import { applyValidationError } from "./lib/apply-validation-error"
|
|
14
|
+
import { fieldContext, formContext } from "./lib/contexts"
|
|
15
|
+
import { deepMergeDefaults, extractZodDefaults } from "./lib/schema-defaults"
|
|
16
|
+
import { getRequiredPaths } from "./lib/schema-required"
|
|
17
|
+
import { useRebasedDefaultValues } from "./lib/use-rebased-default-values"
|
|
18
|
+
import { useUnsavedChangesWarning } from "./lib/use-unsaved-changes-warning"
|
|
19
|
+
import { ValidationError } from "./lib/validation-error"
|
|
20
|
+
import { blurThenChangeLogic } from "./lib/validation-logic"
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_VALIDATION_MODE,
|
|
23
|
+
setFormZenoState,
|
|
24
|
+
type ValidationMode,
|
|
25
|
+
} from "./lib/validation-modes"
|
|
26
|
+
|
|
27
|
+
// Structural shape of a Standard Schema (Zod, Valibot, ArkType, …). Avoids a
|
|
28
|
+
// direct dependency on `@standard-schema/spec`; runtime validation goes through
|
|
29
|
+
// TanStack Form's standard-schema integration unchanged.
|
|
30
|
+
type StandardSchema<T> = {
|
|
31
|
+
readonly "~standard": {
|
|
32
|
+
readonly validate: (value: unknown) => unknown
|
|
33
|
+
readonly types?: { readonly input: T; readonly output: T }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Recursive partial that preserves arrays, dates, and other structural types as
|
|
38
|
+
// atomic — only plain object keys become optional. Relaxes `defaultValues` so
|
|
39
|
+
// users can omit fields covered by the schema's `.default(...)` values or the
|
|
40
|
+
// string/array auto-fill (see `lib/schema-defaults.ts`).
|
|
41
|
+
type PartialFormData<T> = T extends readonly unknown[]
|
|
42
|
+
? T
|
|
43
|
+
: T extends Date | RegExp | ((...args: never[]) => unknown)
|
|
44
|
+
? T
|
|
45
|
+
: T extends object
|
|
46
|
+
? { [K in keyof T]?: PartialFormData<T[K]> }
|
|
47
|
+
: T
|
|
48
|
+
|
|
49
|
+
type ZenoFormExtras<TFormData> = {
|
|
50
|
+
/**
|
|
51
|
+
* If `true`, shipped fields skip rendering their inline `<FieldError>`
|
|
52
|
+
* message. Fields still flip `data-invalid` / `aria-invalid`.
|
|
53
|
+
*/
|
|
54
|
+
hideFieldErrors?: boolean
|
|
55
|
+
/**
|
|
56
|
+
* Show a `*` next to the label of every field the schema treats as required.
|
|
57
|
+
* Defaults to `true`. Required-ness is detected by probing the schema.
|
|
58
|
+
*/
|
|
59
|
+
requiredIndicator?: boolean
|
|
60
|
+
/**
|
|
61
|
+
* Warn the user before they navigate away with unsaved changes.
|
|
62
|
+
* `"if-changed"` (or `true`) warns while values differ from defaults;
|
|
63
|
+
* `"if-touched"` warns after any edit.
|
|
64
|
+
*/
|
|
65
|
+
unsavedChangesWarning?: boolean | "if-changed" | "if-touched"
|
|
66
|
+
/**
|
|
67
|
+
* Initial values, relaxed to a deep partial so schema-provided defaults can
|
|
68
|
+
* be omitted. See `lib/schema-defaults.ts`.
|
|
69
|
+
*/
|
|
70
|
+
defaultValues?: PartialFormData<TFormData>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Schema path — `schema` drives validation. `validators` (if present) selects
|
|
74
|
+
// *when* the schema fires; the matching `validationLogic` is wired internally.
|
|
75
|
+
type SchemaPathExtras<TFormData> = {
|
|
76
|
+
schema: StandardSchema<TFormData>
|
|
77
|
+
validators?: ValidationMode
|
|
78
|
+
validationLogic?: never
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Manual path — no schema. `validators`/`validationLogic` keep native types.
|
|
82
|
+
type ManualPathExtras = {
|
|
83
|
+
schema?: never
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type SchemaFormOptions<
|
|
87
|
+
TFormData,
|
|
88
|
+
TOnMount extends undefined | FormValidateOrFn<TFormData>,
|
|
89
|
+
TOnChange extends undefined | FormValidateOrFn<TFormData>,
|
|
90
|
+
TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
|
|
91
|
+
TOnBlur extends undefined | FormValidateOrFn<TFormData>,
|
|
92
|
+
TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
|
|
93
|
+
TOnSubmit extends undefined | FormValidateOrFn<TFormData>,
|
|
94
|
+
TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
|
|
95
|
+
TOnDynamic extends undefined | FormValidateOrFn<TFormData>,
|
|
96
|
+
TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
|
|
97
|
+
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
|
|
98
|
+
TSubmitMeta,
|
|
99
|
+
> = Omit<
|
|
100
|
+
FormOptions<
|
|
101
|
+
TFormData,
|
|
102
|
+
TOnMount,
|
|
103
|
+
TOnChange,
|
|
104
|
+
TOnChangeAsync,
|
|
105
|
+
TOnBlur,
|
|
106
|
+
TOnBlurAsync,
|
|
107
|
+
TOnSubmit,
|
|
108
|
+
TOnSubmitAsync,
|
|
109
|
+
TOnDynamic,
|
|
110
|
+
TOnDynamicAsync,
|
|
111
|
+
TOnServer,
|
|
112
|
+
TSubmitMeta
|
|
113
|
+
>,
|
|
114
|
+
"defaultValues" | "validators" | "validationLogic"
|
|
115
|
+
> &
|
|
116
|
+
SchemaPathExtras<TFormData> &
|
|
117
|
+
ZenoFormExtras<TFormData>
|
|
118
|
+
|
|
119
|
+
type ManualFormOptions<
|
|
120
|
+
TFormData,
|
|
121
|
+
TOnMount extends undefined | FormValidateOrFn<TFormData>,
|
|
122
|
+
TOnChange extends undefined | FormValidateOrFn<TFormData>,
|
|
123
|
+
TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
|
|
124
|
+
TOnBlur extends undefined | FormValidateOrFn<TFormData>,
|
|
125
|
+
TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
|
|
126
|
+
TOnSubmit extends undefined | FormValidateOrFn<TFormData>,
|
|
127
|
+
TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
|
|
128
|
+
TOnDynamic extends undefined | FormValidateOrFn<TFormData>,
|
|
129
|
+
TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
|
|
130
|
+
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
|
|
131
|
+
TSubmitMeta,
|
|
132
|
+
> = Omit<
|
|
133
|
+
FormOptions<
|
|
134
|
+
TFormData,
|
|
135
|
+
TOnMount,
|
|
136
|
+
TOnChange,
|
|
137
|
+
TOnChangeAsync,
|
|
138
|
+
TOnBlur,
|
|
139
|
+
TOnBlurAsync,
|
|
140
|
+
TOnSubmit,
|
|
141
|
+
TOnSubmitAsync,
|
|
142
|
+
TOnDynamic,
|
|
143
|
+
TOnDynamicAsync,
|
|
144
|
+
TOnServer,
|
|
145
|
+
TSubmitMeta
|
|
146
|
+
>,
|
|
147
|
+
"defaultValues"
|
|
148
|
+
> &
|
|
149
|
+
ManualPathExtras &
|
|
150
|
+
ZenoFormExtras<TFormData>
|
|
151
|
+
|
|
152
|
+
type UseFormOptions<
|
|
153
|
+
TFormData,
|
|
154
|
+
TOnMount extends undefined | FormValidateOrFn<TFormData>,
|
|
155
|
+
TOnChange extends undefined | FormValidateOrFn<TFormData>,
|
|
156
|
+
TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
|
|
157
|
+
TOnBlur extends undefined | FormValidateOrFn<TFormData>,
|
|
158
|
+
TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
|
|
159
|
+
TOnSubmit extends undefined | FormValidateOrFn<TFormData>,
|
|
160
|
+
TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
|
|
161
|
+
TOnDynamic extends undefined | FormValidateOrFn<TFormData>,
|
|
162
|
+
TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
|
|
163
|
+
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
|
|
164
|
+
TSubmitMeta,
|
|
165
|
+
> =
|
|
166
|
+
| SchemaFormOptions<
|
|
167
|
+
TFormData,
|
|
168
|
+
TOnMount,
|
|
169
|
+
TOnChange,
|
|
170
|
+
TOnChangeAsync,
|
|
171
|
+
TOnBlur,
|
|
172
|
+
TOnBlurAsync,
|
|
173
|
+
TOnSubmit,
|
|
174
|
+
TOnSubmitAsync,
|
|
175
|
+
TOnDynamic,
|
|
176
|
+
TOnDynamicAsync,
|
|
177
|
+
TOnServer,
|
|
178
|
+
TSubmitMeta
|
|
179
|
+
>
|
|
180
|
+
| ManualFormOptions<
|
|
181
|
+
TFormData,
|
|
182
|
+
TOnMount,
|
|
183
|
+
TOnChange,
|
|
184
|
+
TOnChangeAsync,
|
|
185
|
+
TOnBlur,
|
|
186
|
+
TOnBlurAsync,
|
|
187
|
+
TOnSubmit,
|
|
188
|
+
TOnSubmitAsync,
|
|
189
|
+
TOnDynamic,
|
|
190
|
+
TOnDynamicAsync,
|
|
191
|
+
TOnServer,
|
|
192
|
+
TSubmitMeta
|
|
193
|
+
>
|
|
194
|
+
|
|
195
|
+
function buildValidatorsFromSchema<TFormData>(
|
|
196
|
+
schema: StandardSchema<TFormData>,
|
|
197
|
+
mode: ValidationMode
|
|
198
|
+
) {
|
|
199
|
+
switch (mode) {
|
|
200
|
+
case "blur":
|
|
201
|
+
return { onBlur: schema }
|
|
202
|
+
case "submit":
|
|
203
|
+
return { onSubmit: schema }
|
|
204
|
+
default:
|
|
205
|
+
return { onChange: schema }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Chain an internal form-level `onMount` listener after the caller's.
|
|
210
|
+
function withMountListener<L extends { onMount?: unknown } | undefined>(
|
|
211
|
+
listeners: L,
|
|
212
|
+
onMount: (props: { formApi: AnyFormApi }) => void
|
|
213
|
+
): NonNullable<L> {
|
|
214
|
+
const user = listeners?.onMount as
|
|
215
|
+
| ((props: { formApi: AnyFormApi }) => void)
|
|
216
|
+
| undefined
|
|
217
|
+
return {
|
|
218
|
+
...listeners,
|
|
219
|
+
onMount: (props: { formApi: AnyFormApi }) => {
|
|
220
|
+
onMount(props)
|
|
221
|
+
user?.(props)
|
|
222
|
+
},
|
|
223
|
+
} as NonNullable<L>
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- typed field wrappers (generic over the injected field components) --------
|
|
227
|
+
|
|
228
|
+
type FormDataOf<F> = F extends { state: { values: infer V } } ? V : never
|
|
229
|
+
|
|
230
|
+
type AnyAppForm = {
|
|
231
|
+
AppField: unknown
|
|
232
|
+
state: { values: unknown }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Loose validators/listeners types for the typed wrappers — name-aware
|
|
236
|
+
// validator typing from TanStack Form would collide with our per-field
|
|
237
|
+
// generics. Callers pass standard schemas or functions; both flow through to
|
|
238
|
+
// `<Field>` at runtime unchanged.
|
|
239
|
+
type AnyFieldValidators = {
|
|
240
|
+
onMount?: unknown
|
|
241
|
+
onChange?: unknown
|
|
242
|
+
onChangeAsync?: unknown
|
|
243
|
+
onChangeAsyncDebounceMs?: number
|
|
244
|
+
onBlur?: unknown
|
|
245
|
+
onBlurAsync?: unknown
|
|
246
|
+
onBlurAsyncDebounceMs?: number
|
|
247
|
+
onSubmit?: unknown
|
|
248
|
+
onSubmitAsync?: unknown
|
|
249
|
+
onSubmitAsyncDebounceMs?: number
|
|
250
|
+
onDynamic?: unknown
|
|
251
|
+
onDynamicAsync?: unknown
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
type AnyFieldListeners = {
|
|
255
|
+
onChange?: unknown
|
|
256
|
+
onChangeDebounceMs?: number
|
|
257
|
+
onBlur?: unknown
|
|
258
|
+
onBlurDebounceMs?: number
|
|
259
|
+
onMount?: unknown
|
|
260
|
+
onSubmit?: unknown
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
type WithValidators = {
|
|
264
|
+
validators?: AnyFieldValidators
|
|
265
|
+
listeners?: AnyFieldListeners
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// biome-ignore lint/suspicious/noExplicitAny: field components accept arbitrary props.
|
|
269
|
+
type AnyComponent = React.ComponentType<any>
|
|
270
|
+
|
|
271
|
+
type FieldWrappers<FC extends Record<string, AnyComponent>, T> = {
|
|
272
|
+
[K in keyof FC]: <N extends DeepKeys<T>>(
|
|
273
|
+
props: ComponentProps<FC[K]> & { name: N } & WithValidators
|
|
274
|
+
) => ReactNode
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
type CreateZenoFormConfig<
|
|
278
|
+
FC extends Record<string, AnyComponent>,
|
|
279
|
+
FMC extends Record<string, AnyComponent>,
|
|
280
|
+
> = {
|
|
281
|
+
fieldComponents: FC
|
|
282
|
+
formComponents: FMC
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Build the wired Zeno form API from the caller's field + form components.
|
|
287
|
+
*
|
|
288
|
+
* The shadcn-style field components are dropped into the consumer's repo via the
|
|
289
|
+
* registry and injected here, so `@zeno-lib/forms` (npm) stays UI-free. Returns
|
|
290
|
+
* the `createFormHook` primitives plus the schema-aware `useForm` and the typed
|
|
291
|
+
* `useAppFields` wrappers (prop types are inferred from the injected components).
|
|
292
|
+
*/
|
|
293
|
+
export function createZenoForm<
|
|
294
|
+
FC extends Record<string, AnyComponent>,
|
|
295
|
+
FMC extends Record<string, AnyComponent>,
|
|
296
|
+
>(config: CreateZenoFormConfig<FC, FMC>) {
|
|
297
|
+
const { useAppForm, withFieldGroup, withForm } = createFormHook({
|
|
298
|
+
fieldComponents: config.fieldComponents,
|
|
299
|
+
fieldContext,
|
|
300
|
+
formComponents: config.formComponents,
|
|
301
|
+
formContext,
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
function useAppFields<TForm extends AnyAppForm>(form: TForm) {
|
|
305
|
+
type T = FormDataOf<TForm>
|
|
306
|
+
return useMemo(() => {
|
|
307
|
+
const Field = form.AppField as React.ComponentType<{
|
|
308
|
+
name: string
|
|
309
|
+
validators?: AnyFieldValidators
|
|
310
|
+
listeners?: AnyFieldListeners
|
|
311
|
+
children: () => ReactNode
|
|
312
|
+
}>
|
|
313
|
+
const wrappers: Record<string, AnyComponent> = {}
|
|
314
|
+
for (const key of Object.keys(config.fieldComponents)) {
|
|
315
|
+
const Impl = config.fieldComponents[key] as AnyComponent
|
|
316
|
+
wrappers[key] = ({
|
|
317
|
+
name,
|
|
318
|
+
validators,
|
|
319
|
+
listeners,
|
|
320
|
+
...props
|
|
321
|
+
}: {
|
|
322
|
+
name: string
|
|
323
|
+
validators?: AnyFieldValidators
|
|
324
|
+
listeners?: AnyFieldListeners
|
|
325
|
+
}) => (
|
|
326
|
+
<Field listeners={listeners} name={name} validators={validators}>
|
|
327
|
+
{() => <Impl {...props} />}
|
|
328
|
+
</Field>
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
return wrappers as FieldWrappers<FC, T>
|
|
332
|
+
}, [form])
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function useForm<
|
|
336
|
+
TFormData,
|
|
337
|
+
TOnMount extends undefined | FormValidateOrFn<TFormData> = undefined,
|
|
338
|
+
TOnChange extends undefined | FormValidateOrFn<TFormData> = undefined,
|
|
339
|
+
TOnChangeAsync extends
|
|
340
|
+
| undefined
|
|
341
|
+
| FormAsyncValidateOrFn<TFormData> = undefined,
|
|
342
|
+
TOnBlur extends undefined | FormValidateOrFn<TFormData> = undefined,
|
|
343
|
+
TOnBlurAsync extends
|
|
344
|
+
| undefined
|
|
345
|
+
| FormAsyncValidateOrFn<TFormData> = undefined,
|
|
346
|
+
TOnSubmit extends undefined | FormValidateOrFn<TFormData> = undefined,
|
|
347
|
+
TOnSubmitAsync extends
|
|
348
|
+
| undefined
|
|
349
|
+
| FormAsyncValidateOrFn<TFormData> = undefined,
|
|
350
|
+
TOnDynamic extends undefined | FormValidateOrFn<TFormData> = undefined,
|
|
351
|
+
TOnDynamicAsync extends
|
|
352
|
+
| undefined
|
|
353
|
+
| FormAsyncValidateOrFn<TFormData> = undefined,
|
|
354
|
+
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData> = undefined,
|
|
355
|
+
TSubmitMeta = never,
|
|
356
|
+
>(
|
|
357
|
+
options: UseFormOptions<
|
|
358
|
+
TFormData,
|
|
359
|
+
TOnMount,
|
|
360
|
+
TOnChange,
|
|
361
|
+
TOnChangeAsync,
|
|
362
|
+
TOnBlur,
|
|
363
|
+
TOnBlurAsync,
|
|
364
|
+
TOnSubmit,
|
|
365
|
+
TOnSubmitAsync,
|
|
366
|
+
TOnDynamic,
|
|
367
|
+
TOnDynamicAsync,
|
|
368
|
+
TOnServer,
|
|
369
|
+
TSubmitMeta
|
|
370
|
+
>
|
|
371
|
+
) {
|
|
372
|
+
type NativeFormOptions = FormOptions<
|
|
373
|
+
TFormData,
|
|
374
|
+
TOnMount,
|
|
375
|
+
TOnChange,
|
|
376
|
+
TOnChangeAsync,
|
|
377
|
+
TOnBlur,
|
|
378
|
+
TOnBlurAsync,
|
|
379
|
+
TOnSubmit,
|
|
380
|
+
TOnSubmitAsync,
|
|
381
|
+
TOnDynamic,
|
|
382
|
+
TOnDynamicAsync,
|
|
383
|
+
TOnServer,
|
|
384
|
+
TSubmitMeta
|
|
385
|
+
>
|
|
386
|
+
type NativeValidators = NativeFormOptions["validators"]
|
|
387
|
+
type NativeValidationLogic = NativeFormOptions["validationLogic"]
|
|
388
|
+
|
|
389
|
+
const {
|
|
390
|
+
schema,
|
|
391
|
+
validators: validatorsInput,
|
|
392
|
+
validationLogic: userValidationLogic,
|
|
393
|
+
hideFieldErrors = false,
|
|
394
|
+
requiredIndicator = true,
|
|
395
|
+
unsavedChangesWarning = false,
|
|
396
|
+
defaultValues: userDefaultValues,
|
|
397
|
+
onSubmit: userOnSubmit,
|
|
398
|
+
...rest
|
|
399
|
+
} = options as Omit<NativeFormOptions, "defaultValues"> & {
|
|
400
|
+
schema?: StandardSchema<TFormData>
|
|
401
|
+
validators?: ValidationMode | NativeValidators
|
|
402
|
+
validationLogic?: NativeValidationLogic
|
|
403
|
+
defaultValues?: PartialFormData<TFormData>
|
|
404
|
+
hideFieldErrors?: boolean
|
|
405
|
+
requiredIndicator?: boolean
|
|
406
|
+
unsavedChangesWarning?: boolean | "if-changed" | "if-touched"
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
let schemaMode: ValidationMode | undefined
|
|
410
|
+
let resolvedValidators: NativeValidators | undefined
|
|
411
|
+
let resolvedValidationLogic: NativeValidationLogic | undefined
|
|
412
|
+
|
|
413
|
+
if (schema === undefined) {
|
|
414
|
+
schemaMode = undefined
|
|
415
|
+
resolvedValidators =
|
|
416
|
+
typeof validatorsInput === "object" && validatorsInput !== null
|
|
417
|
+
? (validatorsInput as NativeValidators)
|
|
418
|
+
: undefined
|
|
419
|
+
resolvedValidationLogic = userValidationLogic
|
|
420
|
+
} else {
|
|
421
|
+
const mode: ValidationMode =
|
|
422
|
+
typeof validatorsInput === "string"
|
|
423
|
+
? (validatorsInput as ValidationMode)
|
|
424
|
+
: DEFAULT_VALIDATION_MODE
|
|
425
|
+
schemaMode = mode
|
|
426
|
+
resolvedValidators = buildValidatorsFromSchema(
|
|
427
|
+
schema,
|
|
428
|
+
mode
|
|
429
|
+
) as unknown as NativeValidators
|
|
430
|
+
resolvedValidationLogic =
|
|
431
|
+
mode === "blur-then-change"
|
|
432
|
+
? (blurThenChangeLogic as unknown as NativeValidationLogic)
|
|
433
|
+
: undefined
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const wrappedOnSubmit = userOnSubmit
|
|
437
|
+
? ((async (props: { formApi: AnyFormApi; value: TFormData }) => {
|
|
438
|
+
try {
|
|
439
|
+
await (
|
|
440
|
+
userOnSubmit as unknown as (
|
|
441
|
+
p: typeof props
|
|
442
|
+
) => unknown | Promise<unknown>
|
|
443
|
+
)(props)
|
|
444
|
+
} catch (error) {
|
|
445
|
+
if (error instanceof ValidationError) {
|
|
446
|
+
applyValidationError(props.formApi, error)
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
throw error
|
|
450
|
+
}
|
|
451
|
+
}) as typeof userOnSubmit)
|
|
452
|
+
: undefined
|
|
453
|
+
|
|
454
|
+
const requiredFields = useMemo(
|
|
455
|
+
() =>
|
|
456
|
+
schema && requiredIndicator
|
|
457
|
+
? getRequiredPaths(schema as Parameters<typeof getRequiredPaths>[0])
|
|
458
|
+
: new Set<string>(),
|
|
459
|
+
[schema, requiredIndicator]
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
const schemaDefaults = useMemo(
|
|
463
|
+
() =>
|
|
464
|
+
schema
|
|
465
|
+
? extractZodDefaults(
|
|
466
|
+
schema as Parameters<typeof extractZodDefaults>[0]
|
|
467
|
+
)
|
|
468
|
+
: undefined,
|
|
469
|
+
[schema]
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
const mergedDefaultValues = useMemo(
|
|
473
|
+
() =>
|
|
474
|
+
schemaDefaults
|
|
475
|
+
? (deepMergeDefaults(
|
|
476
|
+
schemaDefaults,
|
|
477
|
+
userDefaultValues as Record<string, unknown> | undefined
|
|
478
|
+
) as typeof userDefaultValues)
|
|
479
|
+
: userDefaultValues,
|
|
480
|
+
[schemaDefaults, userDefaultValues]
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
// Keep `formApi.reset(values)` from being undone by the next render; see
|
|
484
|
+
// `lib/use-rebased-default-values.ts`.
|
|
485
|
+
const rebased = useRebasedDefaultValues(mergedDefaultValues)
|
|
486
|
+
const listeners = withMountListener(rest.listeners, rebased.onMount)
|
|
487
|
+
|
|
488
|
+
const form = useAppForm<
|
|
489
|
+
TFormData,
|
|
490
|
+
TOnMount,
|
|
491
|
+
TOnChange,
|
|
492
|
+
TOnChangeAsync,
|
|
493
|
+
TOnBlur,
|
|
494
|
+
TOnBlurAsync,
|
|
495
|
+
TOnSubmit,
|
|
496
|
+
TOnSubmitAsync,
|
|
497
|
+
TOnDynamic,
|
|
498
|
+
TOnDynamicAsync,
|
|
499
|
+
TOnServer,
|
|
500
|
+
TSubmitMeta
|
|
501
|
+
>({
|
|
502
|
+
...rest,
|
|
503
|
+
listeners,
|
|
504
|
+
...(rebased.defaultValues === undefined
|
|
505
|
+
? {}
|
|
506
|
+
: { defaultValues: rebased.defaultValues }),
|
|
507
|
+
...(wrappedOnSubmit ? { onSubmit: wrappedOnSubmit } : {}),
|
|
508
|
+
...(resolvedValidators ? { validators: resolvedValidators } : {}),
|
|
509
|
+
...(resolvedValidationLogic
|
|
510
|
+
? { validationLogic: resolvedValidationLogic }
|
|
511
|
+
: {}),
|
|
512
|
+
} as NativeFormOptions)
|
|
513
|
+
|
|
514
|
+
setFormZenoState(form, {
|
|
515
|
+
hideFieldErrors,
|
|
516
|
+
requiredFields,
|
|
517
|
+
requiredIndicator,
|
|
518
|
+
...(schemaMode === undefined ? {} : { validation: schemaMode }),
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
useUnsavedChangesWarning(form, unsavedChangesWarning)
|
|
522
|
+
|
|
523
|
+
const fields = useAppFields(form)
|
|
524
|
+
return useMemo(
|
|
525
|
+
() => Object.assign(form, fields, config.formComponents),
|
|
526
|
+
[form, fields]
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return { useAppFields, useAppForm, useForm, withFieldGroup, withForm }
|
|
531
|
+
}
|