@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,192 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { AnyFormApi, ValidationLogicFn } from "@tanstack/react-form"
|
|
4
|
+
|
|
5
|
+
type Validators = Parameters<ValidationLogicFn>[0]["validators"]
|
|
6
|
+
type RawRunValidation = Parameters<ValidationLogicFn>[0]["runValidation"]
|
|
7
|
+
|
|
8
|
+
// TanStack Form types `runValidation` and `ValidationLogicFn` as returning
|
|
9
|
+
// `void`, but at runtime `getSyncValidatorArray` does
|
|
10
|
+
// `return options.validationLogic(...)` and forwards whatever
|
|
11
|
+
// `runValidation` returns as the validators array. So we must actually
|
|
12
|
+
// return the value, even though TS says we shouldn't.
|
|
13
|
+
type RunValidation = (
|
|
14
|
+
props: Parameters<RawRunValidation>[0]
|
|
15
|
+
) => ReturnType<RawRunValidation> & unknown
|
|
16
|
+
|
|
17
|
+
function pickLiveValidator(validators: Validators, isAsync: boolean) {
|
|
18
|
+
const fn = isAsync
|
|
19
|
+
? (validators?.onChangeAsync ?? validators?.onBlurAsync)
|
|
20
|
+
: (validators?.onChange ?? validators?.onBlur)
|
|
21
|
+
return fn ? { cause: "change" as const, fn } : undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function shouldRunFormChange(form: AnyFormApi): boolean {
|
|
25
|
+
if (form.state.submissionAttempts > 0) {
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
28
|
+
// Form-level call: re-validate the whole schema if at least one field has
|
|
29
|
+
// been blurred. Keeps cross-field checks (password confirmation, dependent
|
|
30
|
+
// fields) live once the user has committed to editing somewhere.
|
|
31
|
+
return Object.values(form.state.fieldMeta).some(
|
|
32
|
+
(meta) => meta?.isBlurred === true
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Standard cause-matching logic for field-level validators. Per-field
|
|
37
|
+
// `validators={{ onChange, onBlur, … }}` are user-authored opt-ins, so we
|
|
38
|
+
// honour the cause they're declared on directly — no extra gating. Mirrors
|
|
39
|
+
// TanStack Form's `defaultValidationLogic` for the field path.
|
|
40
|
+
function fieldLevelLogic(props: Parameters<ValidationLogicFn>[0]) {
|
|
41
|
+
const { event, validators, form } = props
|
|
42
|
+
const run = props.runValidation as RunValidation
|
|
43
|
+
|
|
44
|
+
if (!validators) {
|
|
45
|
+
return run({ form, validators: [] })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const isAsync = event.async
|
|
49
|
+
|
|
50
|
+
if (event.type === "mount") {
|
|
51
|
+
return run({
|
|
52
|
+
form,
|
|
53
|
+
validators: isAsync
|
|
54
|
+
? []
|
|
55
|
+
: [{ cause: "mount" as const, fn: validators.onMount }],
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
if (event.type === "change") {
|
|
59
|
+
return run({
|
|
60
|
+
form,
|
|
61
|
+
validators: [
|
|
62
|
+
{
|
|
63
|
+
cause: "change" as const,
|
|
64
|
+
fn: isAsync ? validators.onChangeAsync : validators.onChange,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
if (event.type === "blur") {
|
|
70
|
+
return run({
|
|
71
|
+
form,
|
|
72
|
+
validators: [
|
|
73
|
+
{
|
|
74
|
+
cause: "blur" as const,
|
|
75
|
+
fn: isAsync ? validators.onBlurAsync : validators.onBlur,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
if (event.type === "submit") {
|
|
81
|
+
return run({
|
|
82
|
+
form,
|
|
83
|
+
validators: [
|
|
84
|
+
{
|
|
85
|
+
cause: "change" as const,
|
|
86
|
+
fn: isAsync ? validators.onChangeAsync : validators.onChange,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
cause: "blur" as const,
|
|
90
|
+
fn: isAsync ? validators.onBlurAsync : validators.onBlur,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
cause: "submit" as const,
|
|
94
|
+
fn: isAsync ? validators.onSubmitAsync : validators.onSubmit,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
return run({ form, validators: [] })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildSubmitValidators(
|
|
103
|
+
validators: NonNullable<Validators>,
|
|
104
|
+
isAsync: boolean,
|
|
105
|
+
liveValidator: ReturnType<typeof pickLiveValidator>
|
|
106
|
+
) {
|
|
107
|
+
return [
|
|
108
|
+
...(liveValidator ? [liveValidator] : []),
|
|
109
|
+
{
|
|
110
|
+
cause: "submit" as const,
|
|
111
|
+
fn: isAsync ? validators.onSubmitAsync : validators.onSubmit,
|
|
112
|
+
},
|
|
113
|
+
...(isAsync ? [] : [{ cause: "server" as const, fn: () => undefined }]),
|
|
114
|
+
]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Custom validation logic implementing the "blur first, change after" pattern
|
|
118
|
+
// used by `useForm` by default.
|
|
119
|
+
//
|
|
120
|
+
// **Why this exists.** TanStack Form's default logic stores errors per cause:
|
|
121
|
+
// the `onBlur` validator writes to `errorMap.onBlur`, the `onChange` validator
|
|
122
|
+
// writes to `errorMap.onChange`. When both are set to the same schema, blurring
|
|
123
|
+
// with an invalid value populates the blur slot, and a subsequent successful
|
|
124
|
+
// change only clears the change slot — the stale blur error sticks, so the UI
|
|
125
|
+
// keeps showing it even after the value is fixed.
|
|
126
|
+
//
|
|
127
|
+
// **What this does for the form-level schema.** A single user-provided schema
|
|
128
|
+
// (read from `onChange`, or `onBlur` as a fallback) is run on:
|
|
129
|
+
//
|
|
130
|
+
// - every **blur** event (so empty required fields error on first commit),
|
|
131
|
+
// - **change** events only after at least one field has been blurred (so
|
|
132
|
+
// pristine typing stays quiet, but correcting an existing error is live),
|
|
133
|
+
// - **submit** events.
|
|
134
|
+
//
|
|
135
|
+
// **Per-field validators bypass this gate.** When the user opts into a cause
|
|
136
|
+
// directly via `validators={{ onChange: … }}` on a field, that's an explicit
|
|
137
|
+
// opt-in — we run the matching slot whenever the cause fires, no gating. This
|
|
138
|
+
// preserves "I asked for onChange, I get onChange" intuition.
|
|
139
|
+
//
|
|
140
|
+
// All form-level errors are written to the `change` slot, so there is exactly
|
|
141
|
+
// one place errors live — no per-cause stickiness.
|
|
142
|
+
function blurThenChangeLogicImpl(props: Parameters<ValidationLogicFn>[0]) {
|
|
143
|
+
// Field-level call (validators are the field's own opt-ins) — pass through
|
|
144
|
+
// standard cause-matching with no gating.
|
|
145
|
+
if (props.event.fieldName !== undefined) {
|
|
146
|
+
return fieldLevelLogic(props)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { event, validators, form } = props
|
|
150
|
+
const run = props.runValidation as RunValidation
|
|
151
|
+
|
|
152
|
+
if (!validators) {
|
|
153
|
+
return run({ form, validators: [] })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const isAsync = event.async
|
|
157
|
+
const liveValidator = pickLiveValidator(validators, isAsync)
|
|
158
|
+
|
|
159
|
+
if (event.type === "mount") {
|
|
160
|
+
return run({
|
|
161
|
+
form,
|
|
162
|
+
validators: isAsync
|
|
163
|
+
? []
|
|
164
|
+
: [{ cause: "mount" as const, fn: validators.onMount }],
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
if (event.type === "blur") {
|
|
168
|
+
return run({
|
|
169
|
+
form,
|
|
170
|
+
validators: liveValidator ? [liveValidator] : [],
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
if (event.type === "change") {
|
|
174
|
+
const shouldRun = liveValidator && shouldRunFormChange(form)
|
|
175
|
+
return run({
|
|
176
|
+
form,
|
|
177
|
+
validators: shouldRun ? [liveValidator] : [],
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
if (event.type === "submit") {
|
|
181
|
+
return run({
|
|
182
|
+
form,
|
|
183
|
+
validators: buildSubmitValidators(validators, isAsync, liveValidator),
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return run({ form, validators: [] })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const blurThenChangeLogic = blurThenChangeLogicImpl as ValidationLogicFn
|
|
191
|
+
|
|
192
|
+
export { blurThenChangeLogic }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { expectTypeOf, test } from "@zeno-lib/vitest"
|
|
2
|
+
|
|
3
|
+
import type { ValidationMode } from "./validation-modes"
|
|
4
|
+
|
|
5
|
+
test("ValidationMode is the exact union of the four supported modes", () => {
|
|
6
|
+
expectTypeOf<ValidationMode>().toEqualTypeOf<
|
|
7
|
+
"change" | "blur" | "submit" | "blur-then-change"
|
|
8
|
+
>()
|
|
9
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { AnyFormApi } from "@tanstack/react-form"
|
|
2
|
+
import { describe, expect, test } from "@zeno-lib/vitest"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_VALIDATION_MODE,
|
|
6
|
+
getFormHideFieldErrors,
|
|
7
|
+
getFormValidationMode,
|
|
8
|
+
isFieldRequired,
|
|
9
|
+
setFormZenoState,
|
|
10
|
+
} from "./validation-modes"
|
|
11
|
+
|
|
12
|
+
function makeFormStub() {
|
|
13
|
+
return { store: {} } as unknown as AnyFormApi
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("validation-modes WeakMap state", () => {
|
|
17
|
+
test("default mode is 'blur-then-change'", () => {
|
|
18
|
+
expect(DEFAULT_VALIDATION_MODE).toBe("blur-then-change")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("unset form returns default state", () => {
|
|
22
|
+
const form = makeFormStub()
|
|
23
|
+
expect(getFormValidationMode(form)).toBe("blur-then-change")
|
|
24
|
+
expect(getFormHideFieldErrors(form)).toBe(false)
|
|
25
|
+
expect(isFieldRequired(form, "email")).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test("setFormZenoState merges partial updates onto current state", () => {
|
|
29
|
+
const form = makeFormStub()
|
|
30
|
+
setFormZenoState(form, { validation: "submit" })
|
|
31
|
+
expect(getFormValidationMode(form)).toBe("submit")
|
|
32
|
+
expect(getFormHideFieldErrors(form)).toBe(false)
|
|
33
|
+
|
|
34
|
+
setFormZenoState(form, { hideFieldErrors: true })
|
|
35
|
+
// first partial preserved
|
|
36
|
+
expect(getFormValidationMode(form)).toBe("submit")
|
|
37
|
+
expect(getFormHideFieldErrors(form)).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("isFieldRequired honours requiredIndicator flag", () => {
|
|
41
|
+
const form = makeFormStub()
|
|
42
|
+
setFormZenoState(form, {
|
|
43
|
+
requiredFields: new Set(["email"]),
|
|
44
|
+
requiredIndicator: true,
|
|
45
|
+
})
|
|
46
|
+
expect(isFieldRequired(form, "email")).toBe(true)
|
|
47
|
+
expect(isFieldRequired(form, "password")).toBe(false)
|
|
48
|
+
|
|
49
|
+
setFormZenoState(form, { requiredIndicator: false })
|
|
50
|
+
expect(isFieldRequired(form, "email")).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("two forms keyed on different store references are independent", () => {
|
|
54
|
+
const a = makeFormStub()
|
|
55
|
+
const b = makeFormStub()
|
|
56
|
+
setFormZenoState(a, { validation: "change" })
|
|
57
|
+
setFormZenoState(b, { validation: "submit" })
|
|
58
|
+
expect(getFormValidationMode(a)).toBe("change")
|
|
59
|
+
expect(getFormValidationMode(b)).toBe("submit")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("state is keyed on store, not the form object identity", () => {
|
|
63
|
+
const store = {}
|
|
64
|
+
const formA = { store } as unknown as AnyFormApi
|
|
65
|
+
const formB = { extra: "spread", store } as unknown as AnyFormApi
|
|
66
|
+
setFormZenoState(formA, { validation: "change" })
|
|
67
|
+
// Both reach the same WeakMap entry because they share `store` — this
|
|
68
|
+
// mirrors how TanStack's `useForm` spread differs from the FormApi seen
|
|
69
|
+
// by child fields.
|
|
70
|
+
expect(getFormValidationMode(formB)).toBe("change")
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { AnyFormApi } from "@tanstack/react-form"
|
|
4
|
+
|
|
5
|
+
type ValidationMode = "change" | "blur" | "submit" | "blur-then-change"
|
|
6
|
+
|
|
7
|
+
const DEFAULT_VALIDATION_MODE: ValidationMode = "blur-then-change"
|
|
8
|
+
|
|
9
|
+
type ZenoFormState = {
|
|
10
|
+
validation: ValidationMode
|
|
11
|
+
hideFieldErrors: boolean
|
|
12
|
+
requiredIndicator: boolean
|
|
13
|
+
requiredFields: ReadonlySet<string>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const EMPTY_REQUIRED_FIELDS: ReadonlySet<string> = new Set<string>()
|
|
17
|
+
|
|
18
|
+
const DEFAULT_FORM_STATE: ZenoFormState = {
|
|
19
|
+
hideFieldErrors: false,
|
|
20
|
+
requiredFields: EMPTY_REQUIRED_FIELDS,
|
|
21
|
+
requiredIndicator: true,
|
|
22
|
+
validation: DEFAULT_VALIDATION_MODE,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Per-form Zeno-specific state. Keyed by `form.store` rather than the form
|
|
26
|
+
// object: TanStack Form's `useForm` returns a spread `{ ...formApi, ... }`
|
|
27
|
+
// whose identity differs from the underlying `formApi` that fields receive
|
|
28
|
+
// via `field.form`. The `store` instance is set in the FormApi constructor
|
|
29
|
+
// and survives the spread, so it's the only reference shared by both the
|
|
30
|
+
// caller of `useForm` and every child field.
|
|
31
|
+
const formZenoState = new WeakMap<object, ZenoFormState>()
|
|
32
|
+
|
|
33
|
+
function stateKey(form: AnyFormApi): object {
|
|
34
|
+
return (form as { store: object }).store
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function setFormZenoState(
|
|
38
|
+
form: AnyFormApi,
|
|
39
|
+
state: Partial<ZenoFormState>
|
|
40
|
+
): void {
|
|
41
|
+
const key = stateKey(form)
|
|
42
|
+
const current = formZenoState.get(key) ?? DEFAULT_FORM_STATE
|
|
43
|
+
formZenoState.set(key, { ...current, ...state })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getFormZenoState(form: AnyFormApi): ZenoFormState {
|
|
47
|
+
return formZenoState.get(stateKey(form)) ?? DEFAULT_FORM_STATE
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getFormValidationMode(form: AnyFormApi): ValidationMode {
|
|
51
|
+
return getFormZenoState(form).validation
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getFormHideFieldErrors(form: AnyFormApi): boolean {
|
|
55
|
+
return getFormZenoState(form).hideFieldErrors
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isFieldRequired(form: AnyFormApi, name: string): boolean {
|
|
59
|
+
const state = getFormZenoState(form)
|
|
60
|
+
if (!state.requiredIndicator) {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
return state.requiredFields.has(name)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export {
|
|
67
|
+
DEFAULT_VALIDATION_MODE,
|
|
68
|
+
getFormHideFieldErrors,
|
|
69
|
+
getFormValidationMode,
|
|
70
|
+
isFieldRequired,
|
|
71
|
+
setFormZenoState,
|
|
72
|
+
type ValidationMode,
|
|
73
|
+
}
|
package/src/tanstack.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { test } from "@zeno-lib/vitest"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
|
|
4
|
+
import { useForm } from "./use-form"
|
|
5
|
+
|
|
6
|
+
const schema = z.object({
|
|
7
|
+
accept: z.boolean(),
|
|
8
|
+
age: z.number(),
|
|
9
|
+
bio: z.string(),
|
|
10
|
+
email: z.string(),
|
|
11
|
+
notes: z.string(),
|
|
12
|
+
password: z.string(),
|
|
13
|
+
profile: z.object({ nick: z.string() }),
|
|
14
|
+
size: z.string(),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const schemaNoEmail = z.object({
|
|
18
|
+
contact: z.string(),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("InputField.name is constrained to DeepKeys<T>", () => {
|
|
22
|
+
const form = useForm({ onSubmit: () => undefined, schema })
|
|
23
|
+
const { InputField } = form
|
|
24
|
+
InputField({ name: "email" })
|
|
25
|
+
InputField({ name: "profile.nick" })
|
|
26
|
+
// @ts-expect-error — not a valid path
|
|
27
|
+
InputField({ name: "nonexistent" })
|
|
28
|
+
// @ts-expect-error — wrong nested path
|
|
29
|
+
InputField({ name: "profile.unknown" })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test("NumberField.name is constrained to DeepKeys<T>", () => {
|
|
33
|
+
const form = useForm({ onSubmit: () => undefined, schema })
|
|
34
|
+
form.NumberField({ name: "age" })
|
|
35
|
+
// @ts-expect-error — not a valid path
|
|
36
|
+
form.NumberField({ name: "nonexistent" })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test("CheckboxField.name is constrained to DeepKeys<T>", () => {
|
|
40
|
+
const form = useForm({ onSubmit: () => undefined, schema })
|
|
41
|
+
form.CheckboxField({ name: "accept" })
|
|
42
|
+
// @ts-expect-error — not a valid path
|
|
43
|
+
form.CheckboxField({ name: "nonexistent" })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("SelectField.name is constrained to DeepKeys<T>", () => {
|
|
47
|
+
const form = useForm({ onSubmit: () => undefined, schema })
|
|
48
|
+
form.SelectField({ children: null, name: "size" })
|
|
49
|
+
// @ts-expect-error — not a valid path
|
|
50
|
+
form.SelectField({ children: null, name: "nonexistent" })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("TextAreaField.name is constrained to DeepKeys<T>", () => {
|
|
54
|
+
const form = useForm({ onSubmit: () => undefined, schema })
|
|
55
|
+
form.TextAreaField({ name: "notes" })
|
|
56
|
+
// @ts-expect-error — not a valid path
|
|
57
|
+
form.TextAreaField({ name: "nonexistent" })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test("EmailField.name is optional when T has an `email` field", () => {
|
|
61
|
+
const form = useForm({ onSubmit: () => undefined, schema })
|
|
62
|
+
const { EmailField } = form
|
|
63
|
+
// No `name` required.
|
|
64
|
+
EmailField({})
|
|
65
|
+
EmailField({ label: "Email" })
|
|
66
|
+
EmailField({ name: "email" })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("EmailField.name is required when T has no `email` field", () => {
|
|
70
|
+
const form = useForm({ onSubmit: () => undefined, schema: schemaNoEmail })
|
|
71
|
+
// @ts-expect-error — name becomes required without an "email" key on T
|
|
72
|
+
form.EmailField({})
|
|
73
|
+
form.EmailField({ name: "contact" })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("PasswordField.name is optional when T has a `password` field", () => {
|
|
77
|
+
const form = useForm({ onSubmit: () => undefined, schema })
|
|
78
|
+
const { PasswordField } = form
|
|
79
|
+
PasswordField({})
|
|
80
|
+
PasswordField({ name: "password" })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("PasswordField.name is required when T has no `password` field", () => {
|
|
84
|
+
const form = useForm({ onSubmit: () => undefined, schema: schemaNoEmail })
|
|
85
|
+
// @ts-expect-error — name becomes required without a "password" key on T
|
|
86
|
+
form.PasswordField({})
|
|
87
|
+
form.PasswordField({ name: "contact" })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test("validators prop is accepted on every field wrapper", () => {
|
|
91
|
+
const form = useForm({ onSubmit: () => undefined, schema })
|
|
92
|
+
form.InputField({
|
|
93
|
+
name: "email",
|
|
94
|
+
validators: {
|
|
95
|
+
onChange: () => undefined,
|
|
96
|
+
onChangeAsyncDebounceMs: 300,
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
form.SelectField({
|
|
100
|
+
children: null,
|
|
101
|
+
name: "size",
|
|
102
|
+
validators: { onBlur: () => undefined },
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test("listeners prop is accepted on every field wrapper", () => {
|
|
107
|
+
const form = useForm({ onSubmit: () => undefined, schema })
|
|
108
|
+
form.InputField({
|
|
109
|
+
listeners: { onChange: () => undefined, onChangeDebounceMs: 50 },
|
|
110
|
+
name: "email",
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
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 EMAIL_LABEL = /Email/
|
|
10
|
+
const CONTACT_EMAIL_LABEL = /Contact email/
|
|
11
|
+
const PASSWORD_LABEL = /Password/
|
|
12
|
+
const NAME_LABEL = /Name/
|
|
13
|
+
const ACCEPT_NAME = /Accept/
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
cleanup()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe("EmailField / PasswordField — default name", () => {
|
|
20
|
+
test("EmailField with no `name` renders an input with id='email'", () => {
|
|
21
|
+
const schema = z.object({ email: z.email() })
|
|
22
|
+
function H() {
|
|
23
|
+
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
24
|
+
const { EmailField } = form
|
|
25
|
+
return (
|
|
26
|
+
<FormProvider form={form}>
|
|
27
|
+
<Form>
|
|
28
|
+
<EmailField label="Email" />
|
|
29
|
+
</Form>
|
|
30
|
+
</FormProvider>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
render(<H />)
|
|
34
|
+
const input = screen.getByLabelText(EMAIL_LABEL) as HTMLInputElement
|
|
35
|
+
expect(input.id).toBe("email")
|
|
36
|
+
expect(input.name).toBe("email")
|
|
37
|
+
expect(input.type).toBe("email")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("EmailField with an explicit `name` binds to that field", async () => {
|
|
41
|
+
const user = userEvent.setup()
|
|
42
|
+
const schema = z.object({ contactEmail: z.email() })
|
|
43
|
+
function H() {
|
|
44
|
+
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
45
|
+
const { EmailField } = form
|
|
46
|
+
return (
|
|
47
|
+
<FormProvider form={form}>
|
|
48
|
+
<Form>
|
|
49
|
+
<EmailField label="Contact email" name="contactEmail" />
|
|
50
|
+
</Form>
|
|
51
|
+
</FormProvider>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
render(<H />)
|
|
55
|
+
const input = screen.getByLabelText(CONTACT_EMAIL_LABEL) as HTMLInputElement
|
|
56
|
+
expect(input.id).toBe("contactEmail")
|
|
57
|
+
expect(input.name).toBe("contactEmail")
|
|
58
|
+
await user.type(input, "u@example.com")
|
|
59
|
+
expect(input.value).toBe("u@example.com")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("PasswordField with no `name` renders an input with id='password'", () => {
|
|
63
|
+
const schema = z.object({
|
|
64
|
+
email: z.email(),
|
|
65
|
+
password: z.string().min(1),
|
|
66
|
+
})
|
|
67
|
+
function H() {
|
|
68
|
+
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
69
|
+
const { EmailField, PasswordField } = form
|
|
70
|
+
return (
|
|
71
|
+
<FormProvider form={form}>
|
|
72
|
+
<Form>
|
|
73
|
+
<EmailField />
|
|
74
|
+
<PasswordField label="Password" />
|
|
75
|
+
</Form>
|
|
76
|
+
</FormProvider>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
render(<H />)
|
|
80
|
+
const input = screen.getByLabelText(PASSWORD_LABEL) as HTMLInputElement
|
|
81
|
+
expect(input.id).toBe("password")
|
|
82
|
+
expect(input.name).toBe("password")
|
|
83
|
+
expect(input.type).toBe("password")
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe("validators & listeners props forward into the underlying AppField", () => {
|
|
88
|
+
test("InputField forwards validators (runs on user input)", async () => {
|
|
89
|
+
const user = userEvent.setup()
|
|
90
|
+
const schema = z.object({ name: z.string() })
|
|
91
|
+
const validator = vi.fn(() => undefined)
|
|
92
|
+
function H() {
|
|
93
|
+
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
94
|
+
const { InputField } = form
|
|
95
|
+
return (
|
|
96
|
+
<FormProvider form={form}>
|
|
97
|
+
<Form>
|
|
98
|
+
<InputField
|
|
99
|
+
label="Name"
|
|
100
|
+
name="name"
|
|
101
|
+
validators={{ onChange: validator }}
|
|
102
|
+
/>
|
|
103
|
+
</Form>
|
|
104
|
+
</FormProvider>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
render(<H />)
|
|
108
|
+
await user.type(screen.getByLabelText(NAME_LABEL), "Alice")
|
|
109
|
+
expect(validator).toHaveBeenCalled()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("InputField forwards listeners (fires on user input)", async () => {
|
|
113
|
+
const user = userEvent.setup()
|
|
114
|
+
const schema = z.object({ name: z.string() })
|
|
115
|
+
const listener = vi.fn()
|
|
116
|
+
function H() {
|
|
117
|
+
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
118
|
+
const { InputField } = form
|
|
119
|
+
return (
|
|
120
|
+
<FormProvider form={form}>
|
|
121
|
+
<Form>
|
|
122
|
+
<InputField
|
|
123
|
+
label="Name"
|
|
124
|
+
listeners={{ onChange: listener }}
|
|
125
|
+
name="name"
|
|
126
|
+
/>
|
|
127
|
+
</Form>
|
|
128
|
+
</FormProvider>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
render(<H />)
|
|
132
|
+
await user.type(screen.getByLabelText(NAME_LABEL), "Bob")
|
|
133
|
+
expect(listener).toHaveBeenCalled()
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe("CheckboxField — boolean state wrapper", () => {
|
|
138
|
+
test("binds boolean state and reflects toggle clicks", async () => {
|
|
139
|
+
const user = userEvent.setup()
|
|
140
|
+
const schema = z.object({ accept: z.boolean() })
|
|
141
|
+
function H() {
|
|
142
|
+
const form = useForm({
|
|
143
|
+
defaultValues: { accept: false },
|
|
144
|
+
onSubmit: vi.fn(),
|
|
145
|
+
schema,
|
|
146
|
+
})
|
|
147
|
+
const { CheckboxField } = form
|
|
148
|
+
return (
|
|
149
|
+
<FormProvider form={form}>
|
|
150
|
+
<Form>
|
|
151
|
+
<CheckboxField label="Accept" name="accept" />
|
|
152
|
+
</Form>
|
|
153
|
+
</FormProvider>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
render(<H />)
|
|
157
|
+
// BaseUI renders the checkbox as a <button role="checkbox">.
|
|
158
|
+
const checkbox = screen.getByRole("checkbox", { name: ACCEPT_NAME })
|
|
159
|
+
expect(checkbox.getAttribute("aria-checked")).toBe("false")
|
|
160
|
+
await user.click(checkbox)
|
|
161
|
+
expect(checkbox.getAttribute("aria-checked")).toBe("true")
|
|
162
|
+
})
|
|
163
|
+
})
|