@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.
Files changed (48) hide show
  1. package/package.json +47 -0
  2. package/src/addons/index.ts +2 -0
  3. package/src/addons/validation-spinner.tsx +25 -0
  4. package/src/fields/checkbox-field.tsx +80 -0
  5. package/src/fields/combobox-field.tsx +189 -0
  6. package/src/fields/date-picker-field.tsx +141 -0
  7. package/src/fields/email-field.tsx +21 -0
  8. package/src/fields/index.ts +16 -0
  9. package/src/fields/input-field.tsx +95 -0
  10. package/src/fields/number-field.tsx +33 -0
  11. package/src/fields/otp-field.tsx +111 -0
  12. package/src/fields/password-field.tsx +20 -0
  13. package/src/fields/radio-group-field.tsx +105 -0
  14. package/src/fields/reset-button.tsx +29 -0
  15. package/src/fields/select-field.tsx +113 -0
  16. package/src/fields/slider-field.tsx +103 -0
  17. package/src/fields/submit-button.tsx +31 -0
  18. package/src/fields/switch-field.tsx +80 -0
  19. package/src/fields/textarea-field.tsx +91 -0
  20. package/src/form.tsx +105 -0
  21. package/src/lib/apply-validation-error.test.ts +97 -0
  22. package/src/lib/apply-validation-error.ts +31 -0
  23. package/src/lib/aria.ts +7 -0
  24. package/src/lib/contexts.ts +9 -0
  25. package/src/lib/required-indicator.tsx +22 -0
  26. package/src/lib/schema-defaults.test.ts +195 -0
  27. package/src/lib/schema-defaults.ts +127 -0
  28. package/src/lib/schema-required.test.ts +132 -0
  29. package/src/lib/schema-required.ts +64 -0
  30. package/src/lib/use-is-invalid.ts +109 -0
  31. package/src/lib/use-unsaved-changes-warning.ts +57 -0
  32. package/src/lib/validation-error.test.ts +41 -0
  33. package/src/lib/validation-error.ts +30 -0
  34. package/src/lib/validation-logic.test.ts +261 -0
  35. package/src/lib/validation-logic.ts +192 -0
  36. package/src/lib/validation-modes.test-d.ts +9 -0
  37. package/src/lib/validation-modes.test.ts +72 -0
  38. package/src/lib/validation-modes.ts +73 -0
  39. package/src/tanstack.ts +2 -0
  40. package/src/use-app-fields.test-d.ts +112 -0
  41. package/src/use-app-fields.test.tsx +163 -0
  42. package/src/use-app-fields.tsx +272 -0
  43. package/src/use-form.cascade.test.tsx +83 -0
  44. package/src/use-form.test-d.ts +153 -0
  45. package/src/use-form.test.tsx +348 -0
  46. package/src/use-form.tsx +450 -0
  47. package/src/use-form.unsaved-warning.test.tsx +192 -0
  48. 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
+ }
@@ -0,0 +1,2 @@
1
+ /** biome-ignore-all lint/performance/noBarrelFile: re-exporting for convenience */
2
+ export * from "@tanstack/react-form"
@@ -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
+ })