@zeno-lib/forms 0.5.0 → 0.6.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.
@@ -0,0 +1,57 @@
1
+ "use client"
2
+
3
+ import {
4
+ type FieldControlProps,
5
+ useFieldControl,
6
+ } from "@zeno-lib/forms/lib/use-field-control"
7
+ import type { ReactNode } from "react"
8
+ import { FieldShell } from "./field-shell"
9
+
10
+ type ControlFieldProps<TValue = unknown> = {
11
+ description?: ReactNode
12
+ disabled?: boolean
13
+ label?: ReactNode
14
+ /**
15
+ * Renders the control, given the props that bind it to the field: `id`,
16
+ * `name`, `value`, `onChange(value)`, `onBlur`, `disabled` and the
17
+ * `aria-*`. Spread them on a control that takes its value that way, or map
18
+ * them for one that doesn't. Keep `aria-invalid` on the focusable element
19
+ * (or a group root around it): a failed submit focuses the first invalid
20
+ * field by it.
21
+ */
22
+ render: (control: FieldControlProps<TValue>) => ReactNode
23
+ /**
24
+ * Mark the field required, or not, over the schema. Drives the `*` and
25
+ * `aria-required`.
26
+ */
27
+ required?: boolean
28
+ }
29
+
30
+ /**
31
+ * Binds a controlled component the kit has no field for (a phone input, a
32
+ * star rating, a color picker) in the kit's field layout. Clearing it stores
33
+ * the field's empty value, as the kit's fields do.
34
+ */
35
+ function ControlField<TValue = unknown>({
36
+ description,
37
+ disabled,
38
+ label,
39
+ render,
40
+ required,
41
+ }: ControlFieldProps<TValue>) {
42
+ const { controlProps, ...shell } = useFieldControl<TValue>({
43
+ description,
44
+ disabled,
45
+ label,
46
+ required,
47
+ })
48
+
49
+ return (
50
+ <FieldShell {...shell} description={description} label={label}>
51
+ {render(controlProps)}
52
+ </FieldShell>
53
+ )
54
+ }
55
+
56
+ export type { ControlFieldProps }
57
+ export { ControlField }
@@ -0,0 +1,73 @@
1
+ "use client"
2
+
3
+ import type { FieldControl } from "@zeno-lib/forms/lib/use-field-control"
4
+ import type { ReactNode } from "react"
5
+ import {
6
+ Field,
7
+ FieldDescription,
8
+ FieldError,
9
+ FieldLabel,
10
+ } from "@/components/ui/field"
11
+ import { RequiredIndicator } from "../lib/required-indicator"
12
+
13
+ type FieldShellProps = Pick<
14
+ FieldControl,
15
+ | "descriptionId"
16
+ | "errorId"
17
+ | "errors"
18
+ | "id"
19
+ | "isInvalid"
20
+ | "labelId"
21
+ | "name"
22
+ | "showRequiredIndicator"
23
+ > & {
24
+ /** The control, bound with `useFieldControl`'s `controlProps`. */
25
+ children: ReactNode
26
+ description?: ReactNode
27
+ label?: ReactNode
28
+ }
29
+
30
+ /**
31
+ * The field layout every kit field shares: the label with its `*`, the
32
+ * control, the description and the error, tied together by the ids
33
+ * `useFieldControl` returns. Spread the rest of its result here:
34
+ *
35
+ * ```tsx
36
+ * const { controlProps, ...shell } = useFieldControl({ description, label })
37
+ * <FieldShell {...shell} description={description} label={label}>
38
+ * <MyControl {...controlProps} />
39
+ * </FieldShell>
40
+ * ```
41
+ */
42
+ function FieldShell({
43
+ children,
44
+ description,
45
+ descriptionId,
46
+ errorId,
47
+ errors,
48
+ id,
49
+ isInvalid,
50
+ label,
51
+ labelId,
52
+ name,
53
+ showRequiredIndicator,
54
+ }: FieldShellProps) {
55
+ return (
56
+ <Field data-field={name} data-invalid={isInvalid}>
57
+ {label && (
58
+ <FieldLabel htmlFor={id} id={labelId}>
59
+ {label}
60
+ {showRequiredIndicator && <RequiredIndicator />}
61
+ </FieldLabel>
62
+ )}
63
+ {children}
64
+ {description && (
65
+ <FieldDescription id={descriptionId}>{description}</FieldDescription>
66
+ )}
67
+ {errors.length > 0 && <FieldError errors={errors} id={errorId} />}
68
+ </Field>
69
+ )
70
+ }
71
+
72
+ export type { FieldShellProps }
73
+ export { FieldShell }
@@ -2,8 +2,10 @@
2
2
  export * from "./checkbox-field"
3
3
  export * from "./checkbox-group-field"
4
4
  export * from "./combobox-field"
5
+ export * from "./control-field"
5
6
  export * from "./date-picker-field"
6
7
  export * from "./email-field"
8
+ export * from "./field-shell"
7
9
  export * from "./form-error"
8
10
  export * from "./input-field"
9
11
  export * from "./money-field"
@@ -1,23 +1,11 @@
1
1
  "use client"
2
2
 
3
- import { describedBy } from "@zeno-lib/forms/lib/aria"
4
3
  import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
- import {
6
- useHideFieldErrors,
7
- useIsFieldRequired,
8
- useIsFieldRequiredBySchema,
9
- useIsInvalid,
10
- } from "@zeno-lib/forms/lib/use-is-invalid"
11
- import { Children, type ComponentProps, type ReactNode, useId } from "react"
12
- import {
13
- Field,
14
- FieldDescription,
15
- FieldError,
16
- FieldLabel,
17
- } from "@/components/ui/field"
4
+ import { useFieldControl } from "@zeno-lib/forms/lib/use-field-control"
5
+ import { Children, type ComponentProps, type ReactNode } from "react"
18
6
  import { Input } from "@/components/ui/input"
19
7
  import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
20
- import { RequiredIndicator } from "../lib/required-indicator"
8
+ import { FieldShell } from "./field-shell"
21
9
 
22
10
  type InputFieldProps = Omit<
23
11
  ComponentProps<typeof Input>,
@@ -41,28 +29,18 @@ function InputField({
41
29
  ...props
42
30
  }: InputFieldProps) {
43
31
  const field = useFieldContext<string>()
44
- const id = useId()
45
- const errorId = `${id}-error`
46
- const descriptionId = `${id}-description`
47
- const isInvalid = useIsInvalid(field)
48
- const hideErrors = useHideFieldErrors(field)
49
- const showError = isInvalid && !hideErrors
32
+ // The label names the input through `htmlFor`, so the input needs no
33
+ // `aria-labelledby` and the hook doesn't get `label`.
34
+ const { controlProps, ...shell } = useFieldControl<string>({
35
+ description,
36
+ required,
37
+ })
50
38
  const hasAddons = Children.count(children) > 0
51
- const schemaRequired = useIsFieldRequired(field)
52
- const isRequired = required ?? schemaRequired
53
- const schemaRequiresValue = useIsFieldRequiredBySchema(field)
54
- const requiresValue = required ?? schemaRequiresValue
55
39
 
40
+ // A text input keeps the `""` the user typed, where `controlProps.onChange`
41
+ // would store the field's empty value.
56
42
  const inputProps = {
57
- "aria-describedby": describedBy(
58
- [description, descriptionId],
59
- [showError, errorId]
60
- ),
61
- "aria-invalid": isInvalid || undefined,
62
- "aria-required": requiresValue || undefined,
63
- id,
64
- name: field.name,
65
- onBlur: field.handleBlur,
43
+ ...controlProps,
66
44
  onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
67
45
  field.handleChange(event.target.value),
68
46
  value: field.state.value ?? "",
@@ -70,13 +48,7 @@ function InputField({
70
48
  }
71
49
 
72
50
  return (
73
- <Field data-field={field.name} data-invalid={isInvalid}>
74
- {label && (
75
- <FieldLabel htmlFor={id}>
76
- {label}
77
- {isRequired && <RequiredIndicator />}
78
- </FieldLabel>
79
- )}
51
+ <FieldShell {...shell} description={description} label={label}>
80
52
  {hasAddons ? (
81
53
  <InputGroup>
82
54
  <InputGroupInput {...inputProps} />
@@ -85,13 +57,7 @@ function InputField({
85
57
  ) : (
86
58
  <Input {...inputProps} />
87
59
  )}
88
- {description && (
89
- <FieldDescription id={descriptionId}>{description}</FieldDescription>
90
- )}
91
- {showError && (
92
- <FieldError errors={field.state.meta.errors} id={errorId} />
93
- )}
94
- </Field>
60
+ </FieldShell>
95
61
  )
96
62
  }
97
63
 
@@ -1,23 +1,11 @@
1
1
  "use client"
2
2
 
3
- import { describedBy } from "@zeno-lib/forms/lib/aria"
4
3
  import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
- import {
6
- useHideFieldErrors,
7
- useIsFieldRequired,
8
- useIsFieldRequiredBySchema,
9
- useIsInvalid,
10
- } from "@zeno-lib/forms/lib/use-is-invalid"
11
- import { Children, type ComponentProps, type ReactNode, useId } from "react"
12
- import {
13
- Field,
14
- FieldDescription,
15
- FieldError,
16
- FieldLabel,
17
- } from "@/components/ui/field"
4
+ import { useFieldControl } from "@zeno-lib/forms/lib/use-field-control"
5
+ import { Children, type ComponentProps, type ReactNode } from "react"
18
6
  import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group"
19
7
  import { Textarea } from "@/components/ui/textarea"
20
- import { RequiredIndicator } from "../lib/required-indicator"
8
+ import { FieldShell } from "./field-shell"
21
9
 
22
10
  type TextAreaFieldProps = Omit<
23
11
  ComponentProps<typeof Textarea>,
@@ -41,28 +29,18 @@ function TextAreaField({
41
29
  ...props
42
30
  }: TextAreaFieldProps) {
43
31
  const field = useFieldContext<string>()
44
- const id = useId()
45
- const errorId = `${id}-error`
46
- const descriptionId = `${id}-description`
47
- const isInvalid = useIsInvalid(field)
48
- const hideErrors = useHideFieldErrors(field)
49
- const showError = isInvalid && !hideErrors
32
+ // The label names the textarea through `htmlFor`, so the textarea needs no
33
+ // `aria-labelledby` and the hook doesn't get `label`.
34
+ const { controlProps, ...shell } = useFieldControl<string>({
35
+ description,
36
+ required,
37
+ })
50
38
  const hasAddons = Children.count(children) > 0
51
- const schemaRequired = useIsFieldRequired(field)
52
- const isRequired = required ?? schemaRequired
53
- const schemaRequiresValue = useIsFieldRequiredBySchema(field)
54
- const requiresValue = required ?? schemaRequiresValue
55
39
 
40
+ // A textarea keeps the `""` the user typed, where `controlProps.onChange`
41
+ // would store the field's empty value.
56
42
  const textareaProps = {
57
- "aria-describedby": describedBy(
58
- [description, descriptionId],
59
- [showError, errorId]
60
- ),
61
- "aria-invalid": isInvalid || undefined,
62
- "aria-required": requiresValue || undefined,
63
- id,
64
- name: field.name,
65
- onBlur: field.handleBlur,
43
+ ...controlProps,
66
44
  onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) =>
67
45
  field.handleChange(event.target.value),
68
46
  value: field.state.value ?? "",
@@ -70,13 +48,7 @@ function TextAreaField({
70
48
  }
71
49
 
72
50
  return (
73
- <Field data-field={field.name} data-invalid={isInvalid}>
74
- {label && (
75
- <FieldLabel htmlFor={id}>
76
- {label}
77
- {isRequired && <RequiredIndicator />}
78
- </FieldLabel>
79
- )}
51
+ <FieldShell {...shell} description={description} label={label}>
80
52
  {hasAddons ? (
81
53
  <InputGroup>
82
54
  <InputGroupTextarea {...textareaProps} />
@@ -85,13 +57,7 @@ function TextAreaField({
85
57
  ) : (
86
58
  <Textarea {...textareaProps} />
87
59
  )}
88
- {description && (
89
- <FieldDescription id={descriptionId}>{description}</FieldDescription>
90
- )}
91
- {showError && (
92
- <FieldError errors={field.state.meta.errors} id={errorId} />
93
- )}
94
- </Field>
60
+ </FieldShell>
95
61
  )
96
62
  }
97
63
 
package/src/index.ts CHANGED
@@ -18,6 +18,12 @@ export {
18
18
  type SubmitActionSchema,
19
19
  submitAction,
20
20
  } from "./lib/submit-action"
21
+ export {
22
+ type FieldControl,
23
+ type FieldControlProps,
24
+ type UseFieldControlOptions,
25
+ useFieldControl,
26
+ } from "./lib/use-field-control"
21
27
  export {
22
28
  useHideFieldErrors,
23
29
  useIsFieldRequired,
@@ -8,6 +8,7 @@ import {
8
8
  } from "./action-result"
9
9
  import { applyValidationError } from "./apply-validation-error"
10
10
  import { ValidationError } from "./validation-error"
11
+ import { getFormSchema } from "./validation-modes"
11
12
 
12
13
  // Write an action's failure onto the form. Field messages go through
13
14
  // `applyValidationError`, so they live in `errorMap.onChange` and clear the
@@ -110,7 +111,9 @@ type SubmitActionSchemaOptions<TValues, TOutput, TData> = SubmitActionOptions<
110
111
  /**
111
112
  * Parsed on the client before the call: `onSubmit` receives the form's
112
113
  * input values, and the action gets the schema's output. A failure is
113
- * applied to the form and the action is never called.
114
+ * applied to the form and the action is never called. Replaces the form's
115
+ * own `schema` for this call; pass one only when the action takes a
116
+ * different schema than the form.
114
117
  */
115
118
  schema: SubmitActionSchema<TOutput>
116
119
  }
@@ -120,9 +123,13 @@ type SubmitActionSchemaOptions<TValues, TOutput, TData> = SubmitActionOptions<
120
123
  * form. Pass `onSubmit`'s own argument straight through:
121
124
  *
122
125
  * ```ts
123
- * onSubmit: (submit) => submitAction(submit, saveProfile, { reset: true, schema })
126
+ * onSubmit: (submit) => submitAction(submit, saveProfile, { reset: true })
124
127
  * ```
125
128
  *
129
+ * The values are parsed with `options.schema`, else with the form's own
130
+ * `useForm({ schema })`, and the action gets the schema's output. A form with
131
+ * no schema (`useForm` without one, `useAppForm`) sends its values as they are.
132
+ *
126
133
  * On `{ ok: false }` the field errors land on their fields (array paths like
127
134
  * `owners[0].percentage` included) and clear as each field is edited. On
128
135
  * `{ ok: true }` the form is optionally reset. Either way the result is
@@ -145,10 +152,17 @@ async function submitAction(
145
152
  options: { reset?: unknown; schema?: SubmitActionSchema<unknown> } = {}
146
153
  ): Promise<ActionResult<unknown>> {
147
154
  const { formApi, value } = props
155
+ // `useForm` types its schema as `StandardSchema<Values>`, so the output
156
+ // still fits the `TValues` the action is typed against.
157
+ const schema =
158
+ options.schema ??
159
+ (getFormSchema(formApi as AnyFormApi) as
160
+ | SubmitActionSchema<unknown>
161
+ | undefined)
148
162
  let input: unknown = value
149
163
 
150
- if (options.schema) {
151
- const parsed = await options.schema["~standard"].validate(value)
164
+ if (schema) {
165
+ const parsed = await schema["~standard"].validate(value)
152
166
  if (parsed.issues) {
153
167
  const error = toActionError(parsed.issues)
154
168
  applyActionError(formApi, error)
@@ -0,0 +1,181 @@
1
+ "use client"
2
+
3
+ import type { AnyFieldMeta } from "@tanstack/react-form"
4
+ import { type ReactNode, useCallback, useId } from "react"
5
+
6
+ import { describedBy } from "./aria"
7
+ import { useFieldContext } from "./contexts"
8
+ import { emptyValueFor } from "./empty-value"
9
+ import {
10
+ useHideFieldErrors,
11
+ useIsFieldRequired,
12
+ useIsFieldRequiredBySchema,
13
+ useIsInvalid,
14
+ } from "./use-is-invalid"
15
+ import { getFormSchema } from "./validation-modes"
16
+
17
+ type UseFieldControlOptions = {
18
+ /**
19
+ * The description the field renders, if any. The control is described by
20
+ * it, and by the error while one shows.
21
+ */
22
+ description?: ReactNode
23
+ disabled?: boolean
24
+ /**
25
+ * The label the field renders, if any. The control gets `aria-labelledby`,
26
+ * which names a control a `<label htmlFor>` can't, such as a
27
+ * `role="radiogroup"`.
28
+ */
29
+ label?: ReactNode
30
+ /**
31
+ * Mark the field required, or not, over the schema. Drives the `*` and
32
+ * `aria-required`.
33
+ */
34
+ required?: boolean
35
+ }
36
+
37
+ /** The props that bind a control to its field. Spread them on the control. */
38
+ type FieldControlProps<TValue = unknown> = {
39
+ "aria-describedby": string | undefined
40
+ "aria-invalid": true | undefined
41
+ "aria-labelledby": string | undefined
42
+ "aria-required": true | undefined
43
+ disabled: boolean | undefined
44
+ id: string
45
+ name: string
46
+ /** Marks the field touched. */
47
+ onBlur: () => void
48
+ /**
49
+ * Stores `value`, not a change event: a control that reports one maps it
50
+ * (`onChange(event.target.value)`). An empty value (`null`, `undefined`,
51
+ * `""`, `NaN` or `[]`) stores the field's empty value instead, which its
52
+ * schema picks. Stable across renders.
53
+ */
54
+ onChange: (value: TValue) => void
55
+ /**
56
+ * The value as stored, the field's empty value (`undefined` or `null`)
57
+ * included. A control that needs a defined value maps it
58
+ * (`value={value ?? ""}`), or it turns uncontrolled once the field empties.
59
+ */
60
+ value: TValue
61
+ }
62
+
63
+ type FieldControl<TValue = unknown> = {
64
+ controlProps: FieldControlProps<TValue>
65
+ descriptionId: string
66
+ errorId: string
67
+ /**
68
+ * The errors to render: none while the field's errors are hidden, by the
69
+ * form's validation mode or `hideFieldErrors`.
70
+ */
71
+ errors: AnyFieldMeta["errors"]
72
+ /** The control's id, for the label's `htmlFor`. */
73
+ id: string
74
+ isInvalid: boolean
75
+ /** The field requires a value, by the schema or `required`. */
76
+ isRequired: boolean
77
+ /** The label's id, when `label` is passed. */
78
+ labelId: string | undefined
79
+ name: string
80
+ /** Show the `*`: as `isRequired`, but off under `requiredIndicator: false`. */
81
+ showRequiredIndicator: boolean
82
+ }
83
+
84
+ function isEmpty(value: unknown): boolean {
85
+ return (
86
+ value === null ||
87
+ value === undefined ||
88
+ value === "" ||
89
+ Number.isNaN(value) ||
90
+ (Array.isArray(value) && value.length === 0)
91
+ )
92
+ }
93
+
94
+ // A React synthetic event, from spreading the control props on a native input
95
+ // or an event-emitting component. Storing it would put the event in the form.
96
+ function isSyntheticEvent(value: unknown): boolean {
97
+ return (
98
+ typeof value === "object" &&
99
+ value !== null &&
100
+ "nativeEvent" in value &&
101
+ "target" in value
102
+ )
103
+ }
104
+
105
+ /**
106
+ * Everything a control needs to act as a field of the form, inside a field
107
+ * component the form renders (`form.AppField`, or one registered with
108
+ * `createZenoForm`): the props to spread on the control, and the ids and
109
+ * state its label, description and error need.
110
+ */
111
+ function useFieldControl<TValue = unknown>({
112
+ description,
113
+ disabled,
114
+ label,
115
+ required,
116
+ }: UseFieldControlOptions = {}): FieldControl<TValue> {
117
+ const field = useFieldContext<TValue>()
118
+ const id = useId()
119
+ const labelId = label ? `${id}-label` : undefined
120
+ const descriptionId = `${id}-description`
121
+ const errorId = `${id}-error`
122
+ const isInvalid = useIsInvalid(field)
123
+ const hideErrors = useHideFieldErrors(field)
124
+ const showError = isInvalid && !hideErrors
125
+ const schemaRequired = useIsFieldRequired(field)
126
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
127
+ const isRequired = required ?? schemaRequiresValue
128
+ // `field` is rebuilt on every value change; these members aren't, so a
129
+ // control that memoizes on `onChange` doesn't see a new one each render.
130
+ const { form, handleBlur, handleChange, name } = field
131
+ const onChange = useCallback(
132
+ (value: TValue) => {
133
+ if (isSyntheticEvent(value)) {
134
+ throw new TypeError(
135
+ `Field "${name}" got a change event, not a value. Map it in the control: onChange(event.target.value).`
136
+ )
137
+ }
138
+ handleChange(
139
+ isEmpty(value)
140
+ ? (emptyValueFor(
141
+ getFormSchema(form),
142
+ form.state.values,
143
+ name,
144
+ value
145
+ ) as TValue)
146
+ : value
147
+ )
148
+ },
149
+ [form, handleChange, name]
150
+ )
151
+
152
+ return {
153
+ controlProps: {
154
+ "aria-describedby": describedBy(
155
+ [description, descriptionId],
156
+ [showError, errorId]
157
+ ),
158
+ "aria-invalid": isInvalid || undefined,
159
+ "aria-labelledby": labelId,
160
+ "aria-required": isRequired || undefined,
161
+ disabled,
162
+ id,
163
+ name,
164
+ onBlur: handleBlur,
165
+ onChange,
166
+ value: field.state.value,
167
+ },
168
+ descriptionId,
169
+ errorId,
170
+ errors: showError ? field.state.meta.errors : [],
171
+ id,
172
+ isInvalid,
173
+ isRequired,
174
+ labelId,
175
+ name,
176
+ showRequiredIndicator: required ?? schemaRequired,
177
+ }
178
+ }
179
+
180
+ export type { FieldControl, FieldControlProps, UseFieldControlOptions }
181
+ export { useFieldControl }
@@ -244,6 +244,109 @@ describe("submitAction — field errors from the action", () => {
244
244
  })
245
245
 
246
246
  describe("submitAction — schema", () => {
247
+ test("parses with the form's own schema, and reset: true rebases on what the action saved", async () => {
248
+ const user = userEvent.setup()
249
+ const schema = z.object({ age: z.coerce.number(), name: z.string().trim() })
250
+ type Values = z.input<typeof schema>
251
+ const action = vi.fn((input: Values) =>
252
+ Promise.resolve<ActionResult<Values>>({ data: input, ok: true })
253
+ )
254
+ const formRef: { current: unknown } = { current: undefined }
255
+ function Harness() {
256
+ const form = useForm({
257
+ defaultValues: { age: "42", name: " Ada " },
258
+ onSubmit: (submit) => submitAction(submit, action, { reset: true }),
259
+ schema,
260
+ })
261
+ formRef.current = form
262
+ const { InputField, SubmitButton } = form
263
+ return (
264
+ <FormProvider form={form}>
265
+ <Form>
266
+ <InputField label="Name" name="name" />
267
+ <SubmitButton>Submit</SubmitButton>
268
+ </Form>
269
+ </FormProvider>
270
+ )
271
+ }
272
+ render(<Harness />)
273
+
274
+ await user.click(screen.getByRole("button", SUBMIT))
275
+
276
+ await vi.waitFor(() =>
277
+ expect(action).toHaveBeenCalledWith({ age: 42, name: "Ada" })
278
+ )
279
+ const form = formRef.current as {
280
+ state: { isDefaultValue: boolean; values: Values }
281
+ }
282
+ await vi.waitFor(() =>
283
+ expect(form.state.values).toEqual({ age: 42, name: "Ada" })
284
+ )
285
+ expect(form.state.isDefaultValue).toBe(true)
286
+ })
287
+
288
+ test("an explicit schema wins over the form's", async () => {
289
+ const user = userEvent.setup()
290
+ const action = vi.fn((_input: { name: string }) =>
291
+ Promise.resolve<ActionResult<null>>({ data: null, ok: true })
292
+ )
293
+ const actionSchema = z.object({ name: z.string().toUpperCase() })
294
+ function Harness() {
295
+ const form = useForm({
296
+ defaultValues: { name: " Ada " },
297
+ onSubmit: (submit) =>
298
+ submitAction(submit, action, { schema: actionSchema }),
299
+ schema: z.object({ name: z.string().trim() }),
300
+ })
301
+ const { InputField, SubmitButton } = form
302
+ return (
303
+ <FormProvider form={form}>
304
+ <Form>
305
+ <InputField label="Name" name="name" />
306
+ <SubmitButton>Submit</SubmitButton>
307
+ </Form>
308
+ </FormProvider>
309
+ )
310
+ }
311
+ render(<Harness />)
312
+
313
+ await user.click(screen.getByRole("button", SUBMIT))
314
+
315
+ await vi.waitFor(() =>
316
+ expect(action).toHaveBeenCalledWith({ name: " ADA " })
317
+ )
318
+ })
319
+
320
+ // A schema passed as a plain TanStack validator is not the form's schema:
321
+ // the action gets the values as they are, as before.
322
+ const trimmed = z.object({ name: z.string().trim() })
323
+
324
+ test("useForm without a schema sends the values as they are", async () => {
325
+ const user = userEvent.setup()
326
+ const action = vi.fn((_input: { name: string }) =>
327
+ Promise.resolve<ActionResult<null>>({ data: null, ok: true })
328
+ )
329
+ function Harness() {
330
+ const form = useForm({
331
+ defaultValues: { name: " Ada " },
332
+ onSubmit: (submit) => submitAction(submit, action),
333
+ validators: { onChange: trimmed },
334
+ })
335
+ return (
336
+ <button onClick={() => form.handleSubmit()} type="button">
337
+ Submit
338
+ </button>
339
+ )
340
+ }
341
+ render(<Harness />)
342
+
343
+ await user.click(screen.getByRole("button", SUBMIT))
344
+
345
+ await vi.waitFor(() =>
346
+ expect(action).toHaveBeenCalledWith({ name: " Ada " })
347
+ )
348
+ })
349
+
247
350
  test("passes the schema output to the action, not the input values", async () => {
248
351
  const user = userEvent.setup()
249
352
  const schema = z.object({