@zeno-lib/forms 0.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/package.json +47 -0
- package/src/addons/index.ts +2 -0
- package/src/addons/validation-spinner.tsx +25 -0
- package/src/fields/checkbox-field.tsx +80 -0
- package/src/fields/combobox-field.tsx +189 -0
- package/src/fields/date-picker-field.tsx +141 -0
- package/src/fields/email-field.tsx +21 -0
- package/src/fields/index.ts +16 -0
- package/src/fields/input-field.tsx +95 -0
- package/src/fields/number-field.tsx +33 -0
- package/src/fields/otp-field.tsx +111 -0
- package/src/fields/password-field.tsx +20 -0
- package/src/fields/radio-group-field.tsx +105 -0
- package/src/fields/reset-button.tsx +29 -0
- package/src/fields/select-field.tsx +113 -0
- package/src/fields/slider-field.tsx +103 -0
- package/src/fields/submit-button.tsx +31 -0
- package/src/fields/switch-field.tsx +80 -0
- package/src/fields/textarea-field.tsx +91 -0
- package/src/form.tsx +105 -0
- package/src/lib/apply-validation-error.test.ts +97 -0
- package/src/lib/apply-validation-error.ts +31 -0
- package/src/lib/aria.ts +7 -0
- package/src/lib/contexts.ts +9 -0
- package/src/lib/required-indicator.tsx +22 -0
- package/src/lib/schema-defaults.test.ts +195 -0
- package/src/lib/schema-defaults.ts +127 -0
- package/src/lib/schema-required.test.ts +132 -0
- package/src/lib/schema-required.ts +64 -0
- package/src/lib/use-is-invalid.ts +109 -0
- package/src/lib/use-unsaved-changes-warning.ts +57 -0
- package/src/lib/validation-error.test.ts +41 -0
- package/src/lib/validation-error.ts +30 -0
- package/src/lib/validation-logic.test.ts +261 -0
- package/src/lib/validation-logic.ts +192 -0
- package/src/lib/validation-modes.test-d.ts +9 -0
- package/src/lib/validation-modes.test.ts +72 -0
- package/src/lib/validation-modes.ts +73 -0
- package/src/tanstack.ts +2 -0
- package/src/use-app-fields.test-d.ts +112 -0
- package/src/use-app-fields.test.tsx +163 -0
- package/src/use-app-fields.tsx +272 -0
- package/src/use-form.cascade.test.tsx +83 -0
- package/src/use-form.test-d.ts +153 -0
- package/src/use-form.test.tsx +348 -0
- package/src/use-form.tsx +450 -0
- package/src/use-form.unsaved-warning.test.tsx +192 -0
- package/src/use-form.validation-modes.test.tsx +149 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { DeepKeys } from "@tanstack/react-form"
|
|
4
|
+
import { type ComponentProps, useMemo } from "react"
|
|
5
|
+
|
|
6
|
+
import { CheckboxField as CheckboxFieldImpl } from "./fields/checkbox-field"
|
|
7
|
+
import { ComboboxField as ComboboxFieldImpl } from "./fields/combobox-field"
|
|
8
|
+
import { DatePickerField as DatePickerFieldImpl } from "./fields/date-picker-field"
|
|
9
|
+
import { EmailField as EmailFieldImpl } from "./fields/email-field"
|
|
10
|
+
import { InputField as InputFieldImpl } from "./fields/input-field"
|
|
11
|
+
import { NumberField as NumberFieldImpl } from "./fields/number-field"
|
|
12
|
+
import { OtpField as OtpFieldImpl } from "./fields/otp-field"
|
|
13
|
+
import { PasswordField as PasswordFieldImpl } from "./fields/password-field"
|
|
14
|
+
import { RadioGroupField as RadioGroupFieldImpl } from "./fields/radio-group-field"
|
|
15
|
+
import { SelectField as SelectFieldImpl } from "./fields/select-field"
|
|
16
|
+
import { SliderField as SliderFieldImpl } from "./fields/slider-field"
|
|
17
|
+
import { SwitchField as SwitchFieldImpl } from "./fields/switch-field"
|
|
18
|
+
import { TextAreaField as TextAreaFieldImpl } from "./fields/textarea-field"
|
|
19
|
+
|
|
20
|
+
type FormDataOf<F> = F extends { state: { values: infer V } } ? V : never
|
|
21
|
+
|
|
22
|
+
type AnyAppForm = {
|
|
23
|
+
AppField: unknown
|
|
24
|
+
state: { values: unknown }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type WithDefaultName<T, D extends string> =
|
|
28
|
+
D extends DeepKeys<T> ? { name?: DeepKeys<T> } : { name: DeepKeys<T> }
|
|
29
|
+
|
|
30
|
+
// Loose validators type for the typed wrappers — name-aware validator typing
|
|
31
|
+
// from TanStack Form would collide with our per-field generics. Callers pass
|
|
32
|
+
// standard schemas (Zod) or functions; both flow through to `<Field>` at
|
|
33
|
+
// runtime unchanged. The structural shape mirrors TanStack's `FieldValidators`
|
|
34
|
+
// slots so prop-name autocomplete still works.
|
|
35
|
+
type AnyFieldValidators = {
|
|
36
|
+
onMount?: unknown
|
|
37
|
+
onChange?: unknown
|
|
38
|
+
onChangeAsync?: unknown
|
|
39
|
+
onChangeAsyncDebounceMs?: number
|
|
40
|
+
onBlur?: unknown
|
|
41
|
+
onBlurAsync?: unknown
|
|
42
|
+
onBlurAsyncDebounceMs?: number
|
|
43
|
+
onSubmit?: unknown
|
|
44
|
+
onSubmitAsync?: unknown
|
|
45
|
+
onSubmitAsyncDebounceMs?: number
|
|
46
|
+
onDynamic?: unknown
|
|
47
|
+
onDynamicAsync?: unknown
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Mirrors TanStack's `FieldListeners`. Same loose-shape rationale as
|
|
51
|
+
// `AnyFieldValidators`: prop-name autocomplete with no per-field generic
|
|
52
|
+
// collisions. Use for cascade patterns where changing one field updates
|
|
53
|
+
// another (`form.setFieldValue(...)` inside `onChange`).
|
|
54
|
+
type AnyFieldListeners = {
|
|
55
|
+
onChange?: unknown
|
|
56
|
+
onChangeDebounceMs?: number
|
|
57
|
+
onBlur?: unknown
|
|
58
|
+
onBlurDebounceMs?: number
|
|
59
|
+
onMount?: unknown
|
|
60
|
+
onSubmit?: unknown
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type WithValidators = {
|
|
64
|
+
validators?: AnyFieldValidators
|
|
65
|
+
listeners?: AnyFieldListeners
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function useAppFields<TForm extends AnyAppForm>(form: TForm) {
|
|
69
|
+
type T = FormDataOf<TForm>
|
|
70
|
+
|
|
71
|
+
return useMemo(() => {
|
|
72
|
+
const Field = form.AppField as React.ComponentType<{
|
|
73
|
+
name: string
|
|
74
|
+
validators?: AnyFieldValidators
|
|
75
|
+
listeners?: AnyFieldListeners
|
|
76
|
+
children: () => React.ReactNode
|
|
77
|
+
}>
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
CheckboxField<N extends DeepKeys<T>>({
|
|
81
|
+
name,
|
|
82
|
+
validators,
|
|
83
|
+
listeners,
|
|
84
|
+
...props
|
|
85
|
+
}: ComponentProps<typeof CheckboxFieldImpl> & {
|
|
86
|
+
name: N
|
|
87
|
+
} & WithValidators) {
|
|
88
|
+
return (
|
|
89
|
+
<Field listeners={listeners} name={name} validators={validators}>
|
|
90
|
+
{() => <CheckboxFieldImpl {...props} />}
|
|
91
|
+
</Field>
|
|
92
|
+
)
|
|
93
|
+
},
|
|
94
|
+
ComboboxField<N extends DeepKeys<T>>({
|
|
95
|
+
name,
|
|
96
|
+
validators,
|
|
97
|
+
listeners,
|
|
98
|
+
...props
|
|
99
|
+
}: ComponentProps<typeof ComboboxFieldImpl> & {
|
|
100
|
+
name: N
|
|
101
|
+
} & WithValidators) {
|
|
102
|
+
return (
|
|
103
|
+
<Field listeners={listeners} name={name} validators={validators}>
|
|
104
|
+
{() => <ComboboxFieldImpl {...props} />}
|
|
105
|
+
</Field>
|
|
106
|
+
)
|
|
107
|
+
},
|
|
108
|
+
DatePickerField<N extends DeepKeys<T>>({
|
|
109
|
+
name,
|
|
110
|
+
validators,
|
|
111
|
+
listeners,
|
|
112
|
+
...props
|
|
113
|
+
}: ComponentProps<typeof DatePickerFieldImpl> & {
|
|
114
|
+
name: N
|
|
115
|
+
} & WithValidators) {
|
|
116
|
+
return (
|
|
117
|
+
<Field listeners={listeners} name={name} validators={validators}>
|
|
118
|
+
{() => <DatePickerFieldImpl {...props} />}
|
|
119
|
+
</Field>
|
|
120
|
+
)
|
|
121
|
+
},
|
|
122
|
+
EmailField({
|
|
123
|
+
name,
|
|
124
|
+
validators,
|
|
125
|
+
listeners,
|
|
126
|
+
...props
|
|
127
|
+
}: ComponentProps<typeof EmailFieldImpl> &
|
|
128
|
+
WithDefaultName<T, "email"> &
|
|
129
|
+
WithValidators) {
|
|
130
|
+
return (
|
|
131
|
+
<Field
|
|
132
|
+
listeners={listeners}
|
|
133
|
+
name={(name ?? "email") as string}
|
|
134
|
+
validators={validators}
|
|
135
|
+
>
|
|
136
|
+
{() => <EmailFieldImpl {...props} />}
|
|
137
|
+
</Field>
|
|
138
|
+
)
|
|
139
|
+
},
|
|
140
|
+
InputField<N extends DeepKeys<T>>({
|
|
141
|
+
name,
|
|
142
|
+
validators,
|
|
143
|
+
listeners,
|
|
144
|
+
...props
|
|
145
|
+
}: ComponentProps<typeof InputFieldImpl> & { name: N } & WithValidators) {
|
|
146
|
+
return (
|
|
147
|
+
<Field listeners={listeners} name={name} validators={validators}>
|
|
148
|
+
{() => <InputFieldImpl {...props} />}
|
|
149
|
+
</Field>
|
|
150
|
+
)
|
|
151
|
+
},
|
|
152
|
+
NumberField<N extends DeepKeys<T>>({
|
|
153
|
+
name,
|
|
154
|
+
validators,
|
|
155
|
+
listeners,
|
|
156
|
+
...props
|
|
157
|
+
}: ComponentProps<typeof NumberFieldImpl> & {
|
|
158
|
+
name: N
|
|
159
|
+
} & WithValidators) {
|
|
160
|
+
return (
|
|
161
|
+
<Field listeners={listeners} name={name} validators={validators}>
|
|
162
|
+
{() => <NumberFieldImpl {...props} />}
|
|
163
|
+
</Field>
|
|
164
|
+
)
|
|
165
|
+
},
|
|
166
|
+
OtpField<N extends DeepKeys<T>>({
|
|
167
|
+
name,
|
|
168
|
+
validators,
|
|
169
|
+
listeners,
|
|
170
|
+
...props
|
|
171
|
+
}: ComponentProps<typeof OtpFieldImpl> & {
|
|
172
|
+
name: N
|
|
173
|
+
} & WithValidators) {
|
|
174
|
+
return (
|
|
175
|
+
<Field listeners={listeners} name={name} validators={validators}>
|
|
176
|
+
{() => <OtpFieldImpl {...props} />}
|
|
177
|
+
</Field>
|
|
178
|
+
)
|
|
179
|
+
},
|
|
180
|
+
PasswordField({
|
|
181
|
+
name,
|
|
182
|
+
validators,
|
|
183
|
+
listeners,
|
|
184
|
+
...props
|
|
185
|
+
}: ComponentProps<typeof PasswordFieldImpl> &
|
|
186
|
+
WithDefaultName<T, "password"> &
|
|
187
|
+
WithValidators) {
|
|
188
|
+
return (
|
|
189
|
+
<Field
|
|
190
|
+
listeners={listeners}
|
|
191
|
+
name={(name ?? "password") as string}
|
|
192
|
+
validators={validators}
|
|
193
|
+
>
|
|
194
|
+
{() => <PasswordFieldImpl {...props} />}
|
|
195
|
+
</Field>
|
|
196
|
+
)
|
|
197
|
+
},
|
|
198
|
+
RadioGroupField<N extends DeepKeys<T>>({
|
|
199
|
+
name,
|
|
200
|
+
validators,
|
|
201
|
+
listeners,
|
|
202
|
+
...props
|
|
203
|
+
}: ComponentProps<typeof RadioGroupFieldImpl> & {
|
|
204
|
+
name: N
|
|
205
|
+
} & WithValidators) {
|
|
206
|
+
return (
|
|
207
|
+
<Field listeners={listeners} name={name} validators={validators}>
|
|
208
|
+
{() => <RadioGroupFieldImpl {...props} />}
|
|
209
|
+
</Field>
|
|
210
|
+
)
|
|
211
|
+
},
|
|
212
|
+
SelectField<N extends DeepKeys<T>>({
|
|
213
|
+
name,
|
|
214
|
+
validators,
|
|
215
|
+
listeners,
|
|
216
|
+
...props
|
|
217
|
+
}: ComponentProps<typeof SelectFieldImpl> & {
|
|
218
|
+
name: N
|
|
219
|
+
} & WithValidators) {
|
|
220
|
+
return (
|
|
221
|
+
<Field listeners={listeners} name={name} validators={validators}>
|
|
222
|
+
{() => <SelectFieldImpl {...props} />}
|
|
223
|
+
</Field>
|
|
224
|
+
)
|
|
225
|
+
},
|
|
226
|
+
SliderField<N extends DeepKeys<T>>({
|
|
227
|
+
name,
|
|
228
|
+
validators,
|
|
229
|
+
listeners,
|
|
230
|
+
...props
|
|
231
|
+
}: ComponentProps<typeof SliderFieldImpl> & {
|
|
232
|
+
name: N
|
|
233
|
+
} & WithValidators) {
|
|
234
|
+
return (
|
|
235
|
+
<Field listeners={listeners} name={name} validators={validators}>
|
|
236
|
+
{() => <SliderFieldImpl {...props} />}
|
|
237
|
+
</Field>
|
|
238
|
+
)
|
|
239
|
+
},
|
|
240
|
+
SwitchField<N extends DeepKeys<T>>({
|
|
241
|
+
name,
|
|
242
|
+
validators,
|
|
243
|
+
listeners,
|
|
244
|
+
...props
|
|
245
|
+
}: ComponentProps<typeof SwitchFieldImpl> & {
|
|
246
|
+
name: N
|
|
247
|
+
} & WithValidators) {
|
|
248
|
+
return (
|
|
249
|
+
<Field listeners={listeners} name={name} validators={validators}>
|
|
250
|
+
{() => <SwitchFieldImpl {...props} />}
|
|
251
|
+
</Field>
|
|
252
|
+
)
|
|
253
|
+
},
|
|
254
|
+
TextAreaField<N extends DeepKeys<T>>({
|
|
255
|
+
name,
|
|
256
|
+
validators,
|
|
257
|
+
listeners,
|
|
258
|
+
...props
|
|
259
|
+
}: ComponentProps<typeof TextAreaFieldImpl> & {
|
|
260
|
+
name: N
|
|
261
|
+
} & WithValidators) {
|
|
262
|
+
return (
|
|
263
|
+
<Field listeners={listeners} name={name} validators={validators}>
|
|
264
|
+
{() => <TextAreaFieldImpl {...props} />}
|
|
265
|
+
</Field>
|
|
266
|
+
)
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
}, [form])
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export { useAppFields }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from "@zeno-lib/vitest"
|
|
2
|
+
import { cleanup, render, screen } from "@zeno-lib/vitest/testing-library"
|
|
3
|
+
import userEvent from "@zeno-lib/vitest/user-event"
|
|
4
|
+
import { z } from "zod"
|
|
5
|
+
|
|
6
|
+
import { Form, FormProvider } from "./form"
|
|
7
|
+
import { useForm } from "./use-form"
|
|
8
|
+
|
|
9
|
+
const DIET_LABEL = /Diet/
|
|
10
|
+
const SAUSAGE_LABEL = /Sausage/
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
cleanup()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe("Field listeners — cascade pattern", () => {
|
|
17
|
+
test("onChange listener can call form.setFieldValue to update a sibling field", async () => {
|
|
18
|
+
const user = userEvent.setup()
|
|
19
|
+
const schema = z.object({
|
|
20
|
+
diet: z.string().default("none"),
|
|
21
|
+
sausage: z.string().default("yes"),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
function H() {
|
|
25
|
+
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
26
|
+
const { InputField } = form
|
|
27
|
+
return (
|
|
28
|
+
<FormProvider form={form}>
|
|
29
|
+
<Form>
|
|
30
|
+
<InputField
|
|
31
|
+
label="Diet"
|
|
32
|
+
listeners={{
|
|
33
|
+
onChange: ({ value }: { value: string }) => {
|
|
34
|
+
if (value === "vegan") {
|
|
35
|
+
form.setFieldValue("sausage", "no")
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
}}
|
|
39
|
+
name="diet"
|
|
40
|
+
/>
|
|
41
|
+
<InputField label="Sausage" name="sausage" />
|
|
42
|
+
</Form>
|
|
43
|
+
</FormProvider>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
render(<H />)
|
|
47
|
+
const diet = screen.getByLabelText(DIET_LABEL) as HTMLInputElement
|
|
48
|
+
const sausage = screen.getByLabelText(SAUSAGE_LABEL) as HTMLInputElement
|
|
49
|
+
|
|
50
|
+
expect(sausage.value).toBe("yes")
|
|
51
|
+
await user.clear(diet)
|
|
52
|
+
await user.type(diet, "vegan")
|
|
53
|
+
expect(sausage.value).toBe("no")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("listener fires per change without triggering the field's own validator", async () => {
|
|
57
|
+
const user = userEvent.setup()
|
|
58
|
+
type Values = { diet: string }
|
|
59
|
+
const listener = vi.fn()
|
|
60
|
+
function H() {
|
|
61
|
+
const form = useForm<Values>({
|
|
62
|
+
defaultValues: { diet: "" },
|
|
63
|
+
onSubmit: vi.fn(),
|
|
64
|
+
})
|
|
65
|
+
const { InputField } = form
|
|
66
|
+
return (
|
|
67
|
+
<FormProvider form={form}>
|
|
68
|
+
<Form>
|
|
69
|
+
<InputField
|
|
70
|
+
label="Diet"
|
|
71
|
+
listeners={{ onChange: listener }}
|
|
72
|
+
name="diet"
|
|
73
|
+
/>
|
|
74
|
+
</Form>
|
|
75
|
+
</FormProvider>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
render(<H />)
|
|
79
|
+
await user.type(screen.getByLabelText(DIET_LABEL), "abc")
|
|
80
|
+
// "abc" — three keystrokes → listener fired at least three times.
|
|
81
|
+
expect(listener.mock.calls.length).toBeGreaterThanOrEqual(3)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { expectTypeOf, test } from "@zeno-lib/vitest"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
|
|
4
|
+
import { useForm } from "./use-form"
|
|
5
|
+
|
|
6
|
+
const stringSchema = z.object({
|
|
7
|
+
age: z.number(),
|
|
8
|
+
email: z.string(),
|
|
9
|
+
profile: z.object({ nick: z.string() }),
|
|
10
|
+
tags: z.array(z.string()),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
type Schema = z.infer<typeof stringSchema>
|
|
14
|
+
|
|
15
|
+
test("schema path: defaultValues is PartialFormData (deep partial for plain objects)", () => {
|
|
16
|
+
// Plain object keys are optional…
|
|
17
|
+
useForm({
|
|
18
|
+
defaultValues: { email: "x" },
|
|
19
|
+
onSubmit: () => undefined,
|
|
20
|
+
schema: stringSchema,
|
|
21
|
+
})
|
|
22
|
+
useForm({
|
|
23
|
+
defaultValues: {},
|
|
24
|
+
onSubmit: () => undefined,
|
|
25
|
+
schema: stringSchema,
|
|
26
|
+
})
|
|
27
|
+
// Nested object keys are partial…
|
|
28
|
+
useForm({
|
|
29
|
+
defaultValues: { profile: { nick: "x" } },
|
|
30
|
+
onSubmit: () => undefined,
|
|
31
|
+
schema: stringSchema,
|
|
32
|
+
})
|
|
33
|
+
useForm({
|
|
34
|
+
defaultValues: { profile: {} },
|
|
35
|
+
onSubmit: () => undefined,
|
|
36
|
+
schema: stringSchema,
|
|
37
|
+
})
|
|
38
|
+
// Arrays remain atomic (no per-element relaxation).
|
|
39
|
+
useForm({
|
|
40
|
+
defaultValues: { tags: ["a", "b"] },
|
|
41
|
+
onSubmit: () => undefined,
|
|
42
|
+
schema: stringSchema,
|
|
43
|
+
})
|
|
44
|
+
useForm({
|
|
45
|
+
// @ts-expect-error — array element type still enforced (number ≠ string)
|
|
46
|
+
defaultValues: { tags: [1] },
|
|
47
|
+
onSubmit: () => undefined,
|
|
48
|
+
schema: stringSchema,
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test("schema path: validationLogic is forbidden (type never)", () => {
|
|
53
|
+
// Error lands on the overall call argument, not on `validationLogic`:
|
|
54
|
+
// `validationLogic: never` makes the whole options object incompatible.
|
|
55
|
+
// @ts-expect-error — validationLogic is `never` on the schema path
|
|
56
|
+
useForm({
|
|
57
|
+
onSubmit: () => undefined,
|
|
58
|
+
schema: stringSchema,
|
|
59
|
+
validationLogic: () => undefined,
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("schema path: validators rejects the native object shape", () => {
|
|
64
|
+
// @ts-expect-error — object form is not allowed on the schema path
|
|
65
|
+
useForm({
|
|
66
|
+
onSubmit: () => undefined,
|
|
67
|
+
schema: stringSchema,
|
|
68
|
+
validators: { onChange: () => undefined },
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test("schema path: validators accepts a ValidationMode string", () => {
|
|
73
|
+
useForm({
|
|
74
|
+
onSubmit: () => undefined,
|
|
75
|
+
schema: stringSchema,
|
|
76
|
+
validators: "change",
|
|
77
|
+
})
|
|
78
|
+
useForm({
|
|
79
|
+
onSubmit: () => undefined,
|
|
80
|
+
schema: stringSchema,
|
|
81
|
+
validators: "blur",
|
|
82
|
+
})
|
|
83
|
+
useForm({
|
|
84
|
+
onSubmit: () => undefined,
|
|
85
|
+
schema: stringSchema,
|
|
86
|
+
validators: "submit",
|
|
87
|
+
})
|
|
88
|
+
useForm({
|
|
89
|
+
onSubmit: () => undefined,
|
|
90
|
+
schema: stringSchema,
|
|
91
|
+
validators: "blur-then-change",
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test("schema path: validators rejects unknown string values", () => {
|
|
96
|
+
useForm({
|
|
97
|
+
onSubmit: () => undefined,
|
|
98
|
+
schema: stringSchema,
|
|
99
|
+
// @ts-expect-error — must be a ValidationMode
|
|
100
|
+
validators: "bogus",
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test("schema path: onSubmit `value` is typed from the schema output", () => {
|
|
105
|
+
useForm({
|
|
106
|
+
onSubmit: ({ value }) => {
|
|
107
|
+
expectTypeOf(value).toEqualTypeOf<Schema>()
|
|
108
|
+
},
|
|
109
|
+
schema: stringSchema,
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test("manual path: defaultValues accepts the full TFormData shape", () => {
|
|
114
|
+
// Note: the wrapper applies `PartialFormData<TFormData>` on both branches
|
|
115
|
+
// via `ZenoFormExtras`, so omitting keys typechecks here too. This test
|
|
116
|
+
// documents that the full shape is always accepted; the *required* shape
|
|
117
|
+
// contract is enforced by TanStack's runtime, not the wrapper's types.
|
|
118
|
+
useForm<{ a: string; b: number }>({
|
|
119
|
+
defaultValues: { a: "x", b: 1 },
|
|
120
|
+
onSubmit: () => undefined,
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test("manual path: validators string requires the schema path", () => {
|
|
125
|
+
// Supplying a `validators: "change"` string narrows the union to the
|
|
126
|
+
// schema branch, which then demands `schema`. The error lands on the
|
|
127
|
+
// overall call (no `schema` provided), not on the `validators` line.
|
|
128
|
+
// @ts-expect-error — schema becomes required when validators is a string
|
|
129
|
+
useForm({
|
|
130
|
+
defaultValues: { a: "" },
|
|
131
|
+
onSubmit: () => undefined,
|
|
132
|
+
validators: "change",
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test("manual path: validationLogic is accepted (native function shape)", () => {
|
|
137
|
+
useForm({
|
|
138
|
+
defaultValues: { a: "" },
|
|
139
|
+
onSubmit: () => undefined,
|
|
140
|
+
validationLogic: () => undefined,
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test("returned form retains the TanStack Form API surface", () => {
|
|
145
|
+
const form = useForm({
|
|
146
|
+
onSubmit: () => undefined,
|
|
147
|
+
schema: stringSchema,
|
|
148
|
+
})
|
|
149
|
+
expectTypeOf(form.handleSubmit).toBeFunction()
|
|
150
|
+
expectTypeOf(form.reset).toBeFunction()
|
|
151
|
+
expectTypeOf(form.setFieldValue).toBeFunction()
|
|
152
|
+
expectTypeOf(form.state).not.toBeNever()
|
|
153
|
+
})
|