@zeno-lib/forms 0.4.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.
Files changed (39) hide show
  1. package/dist/create-zeno-form.d.mts +8 -2
  2. package/dist/create-zeno-form.mjs +4 -0
  3. package/dist/form-element.mjs +7 -5
  4. package/dist/index.d.mts +2 -1
  5. package/dist/index.mjs +2 -1
  6. package/dist/lib/focus-first-invalid.d.mts +23 -0
  7. package/dist/lib/focus-first-invalid.mjs +37 -0
  8. package/dist/lib/submit-action.d.mts +8 -2
  9. package/dist/lib/submit-action.mjs +4 -2
  10. package/dist/lib/submit-error.d.mts +36 -0
  11. package/dist/lib/submit-error.mjs +66 -0
  12. package/dist/lib/use-field-control.d.mts +76 -0
  13. package/dist/lib/use-field-control.mjs +67 -0
  14. package/dist/lib/use-form-dialog.d.mts +15 -1
  15. package/dist/lib/use-form-dialog.mjs +20 -2
  16. package/package.json +1 -1
  17. package/src/control-field.test.tsx +338 -0
  18. package/src/create-form.tsx +5 -0
  19. package/src/create-zeno-form.tsx +32 -1
  20. package/src/fields/control-field.tsx +57 -0
  21. package/src/fields/field-shell.tsx +73 -0
  22. package/src/fields/form-error.tsx +61 -0
  23. package/src/fields/index.ts +3 -0
  24. package/src/fields/input-field.tsx +14 -48
  25. package/src/fields/textarea-field.tsx +14 -48
  26. package/src/form-dialog.test.tsx +493 -2
  27. package/src/form-dialog.tsx +217 -33
  28. package/src/form-element.tsx +13 -11
  29. package/src/form-error.test.tsx +255 -0
  30. package/src/index.ts +6 -0
  31. package/src/lib/focus-first-invalid.test.ts +22 -0
  32. package/src/lib/focus-first-invalid.ts +52 -0
  33. package/src/lib/submit-action.ts +18 -4
  34. package/src/lib/submit-error.ts +133 -0
  35. package/src/lib/use-field-control.ts +181 -0
  36. package/src/lib/use-form-dialog.ts +49 -1
  37. package/src/submit-action.test.tsx +103 -0
  38. package/src/use-app-fields.test-d.ts +51 -1
  39. package/src/use-form.focus-invalid.test.tsx +34 -0
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,
@@ -0,0 +1,22 @@
1
+ import { afterEach, describe, expect, test } from "vitest"
2
+ import { focusFirstInvalid } from "./focus-first-invalid"
3
+
4
+ afterEach(() => {
5
+ document.body.innerHTML = ""
6
+ })
7
+
8
+ describe("focusFirstInvalid", () => {
9
+ test("skips an invalid control that won't take focus", () => {
10
+ document.body.innerHTML = `
11
+ <form>
12
+ <input aria-invalid="true" disabled name="locked" />
13
+ <div aria-invalid="true" role="radiogroup">
14
+ <button disabled role="radio" type="button">A</button>
15
+ <button role="radio" type="button">B</button>
16
+ </div>
17
+ </form>`
18
+ const focused = focusFirstInvalid(document.querySelector("form"))
19
+ expect(focused?.textContent).toBe("B")
20
+ expect(document.activeElement).toBe(focused)
21
+ })
22
+ })
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Move focus to the first invalid control inside `root` (a `<form>`): the
3
+ * first element with `aria-invalid="true"`, or, for a group root that can't
4
+ * take focus itself (radio group, slider), its first tabbable descendant. An
5
+ * invalid control that won't take focus (disabled, hidden) is skipped for the
6
+ * next one. Call it after `await form.handleSubmit()` leaves the form invalid.
7
+ * Searching only `root` keeps focus inside the submitted form, so a dialog's
8
+ * form never sends it to the page behind. Returns the focused element, or
9
+ * `null`.
10
+ */
11
+ function focusFirstInvalid(
12
+ root: ParentNode | null | undefined
13
+ ): HTMLElement | null {
14
+ const invalid = root?.querySelectorAll<HTMLElement>('[aria-invalid="true"]')
15
+ for (const element of invalid ?? []) {
16
+ for (const target of [
17
+ element,
18
+ ...element.querySelectorAll<HTMLElement>("*"),
19
+ ]) {
20
+ if (target.tabIndex < 0) {
21
+ continue
22
+ }
23
+ target.focus()
24
+ if (target.ownerDocument.activeElement === target) {
25
+ return target
26
+ }
27
+ }
28
+ }
29
+ return null
30
+ }
31
+
32
+ /**
33
+ * Give focus back to `element`, what had it when the submit started, if the
34
+ * submit dropped it to the body. A submit button that disables itself while
35
+ * submitting does that in browsers, so a failed submit with no invalid field
36
+ * to focus would otherwise leave the user nowhere. It doesn't scroll, so a
37
+ * user who scrolled away during a slow submit stays put. Returns whether it
38
+ * moved focus.
39
+ */
40
+ function restoreFocus(element: Element | null): boolean {
41
+ if (!(element instanceof HTMLElement && element.isConnected)) {
42
+ return false
43
+ }
44
+ const active = element.ownerDocument.activeElement
45
+ if (active !== null && active !== element.ownerDocument.body) {
46
+ return false
47
+ }
48
+ element.focus({ preventScroll: true })
49
+ return element.ownerDocument.activeElement === element
50
+ }
51
+
52
+ export { focusFirstInvalid, restoreFocus }
@@ -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,133 @@
1
+ "use client"
2
+
3
+ import { useCallback, useSyncExternalStore } from "react"
4
+
5
+ // A submit whose handler threw (a network failure, an unexpected server
6
+ // error), kept per form so `FormError` can show it.
7
+ //
8
+ // Not written to TanStack's `errorMap`: any entry there makes the form
9
+ // invalid, which disables `SubmitButton`, and `handleSubmit` then skips
10
+ // `onSubmit` until an edit clears the entry. The user couldn't retry a failed
11
+ // save as is.
12
+
13
+ type SubmitErrorState = {
14
+ readonly submissionAttempts: number
15
+ readonly values: unknown
16
+ }
17
+
18
+ // The slice of a form the helpers read. Keyed by `store`, as the per-form
19
+ // Zeno state is (`validation-modes.ts`): it is the one reference shared by
20
+ // the `useForm()` result and the form API that fields and context receive.
21
+ type SubmitErrorForm = {
22
+ readonly state: SubmitErrorState
23
+ readonly store: {
24
+ subscribe: (listener: () => void) => { unsubscribe: () => void }
25
+ }
26
+ }
27
+
28
+ type SubmitError = {
29
+ /** What the submit handler threw. */
30
+ readonly error: unknown
31
+ }
32
+
33
+ type Entry = {
34
+ readonly submitError: SubmitError
35
+ readonly unsubscribe: () => void
36
+ }
37
+
38
+ const entries = new WeakMap<object, Entry>()
39
+ const listeners = new WeakMap<object, Set<() => void>>()
40
+
41
+ function notify(key: object): void {
42
+ for (const listener of listeners.get(key) ?? []) {
43
+ listener()
44
+ }
45
+ }
46
+
47
+ function clear(key: object): void {
48
+ const entry = entries.get(key)
49
+ if (entry) {
50
+ entry.unsubscribe()
51
+ entries.delete(key)
52
+ notify(key)
53
+ }
54
+ }
55
+
56
+ // Next.js `redirect()`, `notFound()`, `forbidden()` and `unauthorized()` throw
57
+ // an error whose `digest` starts with `NEXT_`, and a server action that calls
58
+ // one rejects with it on the client. Next navigates on its own: no failure.
59
+ function isNavigationError(error: unknown): boolean {
60
+ if (typeof error !== "object" || error === null || !("digest" in error)) {
61
+ return false
62
+ }
63
+ return typeof error.digest === "string" && error.digest.startsWith("NEXT_")
64
+ }
65
+
66
+ /**
67
+ * Record that `form`'s submit threw `error`. `useForm` calls it for what
68
+ * `onSubmit` throws; call it for a failure your own submit code catches
69
+ * outside `onSubmit`. The error lasts until the values change or the next
70
+ * submit starts (`reset()` does both), so an edit or a retry clears it. A
71
+ * Next.js navigation (`redirect()`, `notFound()`) is ignored.
72
+ */
73
+ function setSubmitError(form: SubmitErrorForm, error: unknown): void {
74
+ if (isNavigationError(error)) {
75
+ return
76
+ }
77
+ const key = form.store
78
+ entries.get(key)?.unsubscribe()
79
+ const { submissionAttempts, values } = form.state
80
+ // Dropped, not just hidden, once stale: a reset restores the attempt count
81
+ // and can restore the same values object, which would bring it back.
82
+ const subscription = form.store.subscribe(() => {
83
+ const { state } = form
84
+ if (
85
+ state.values !== values ||
86
+ state.submissionAttempts !== submissionAttempts
87
+ ) {
88
+ clear(key)
89
+ }
90
+ })
91
+ entries.set(key, {
92
+ submitError: { error },
93
+ unsubscribe: subscription.unsubscribe,
94
+ })
95
+ notify(key)
96
+ }
97
+
98
+ /**
99
+ * Report a submit rejection that is caught so the user can retry, as
100
+ * `<Form>` and `FormDialog` do: `reportError` logs it and passes it to the
101
+ * page's `error` listeners (where error monitoring hooks in), as if it were
102
+ * uncaught. A Next.js navigation is skipped.
103
+ */
104
+ function reportSubmitError(error: unknown): void {
105
+ if (!isNavigationError(error)) {
106
+ // Missing in jsdom and older browsers.
107
+ globalThis.reportError?.(error)
108
+ }
109
+ }
110
+
111
+ /** The error `form`'s last submit threw, until it went stale. */
112
+ function useSubmitError(form: SubmitErrorForm): SubmitError | undefined {
113
+ const key = form.store
114
+ const subscribe = useCallback(
115
+ (listener: () => void) => {
116
+ const set = listeners.get(key) ?? new Set()
117
+ listeners.set(key, set)
118
+ set.add(listener)
119
+ return () => {
120
+ set.delete(listener)
121
+ }
122
+ },
123
+ [key]
124
+ )
125
+ return useSyncExternalStore(
126
+ subscribe,
127
+ () => entries.get(key)?.submitError,
128
+ () => undefined
129
+ )
130
+ }
131
+
132
+ export type { SubmitError, SubmitErrorForm }
133
+ export { reportSubmitError, setSubmitError, useSubmitError }
@@ -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 }
@@ -97,6 +97,11 @@ function useFormDialog<TValues>(
97
97
  type UseLeaveGuardOptions = {
98
98
  /** Whether leaving now would lose changes. */
99
99
  hasUnsavedChanges: boolean
100
+ /**
101
+ * Save the changes and resolve whether that worked. Backs `saveAndLeave`,
102
+ * so the confirm UI can offer Save next to Discard.
103
+ */
104
+ onSave?: () => boolean | Promise<boolean>
100
105
  }
101
106
 
102
107
  type LeaveGuard = {
@@ -111,14 +116,25 @@ type LeaveGuard = {
111
116
  confirmLeave: () => void
112
117
  /** The user chose to stay: drop the held leave. */
113
118
  cancelLeave: () => void
119
+ /**
120
+ * The user chose to save: run `onSave`, then the held leave if it saved.
121
+ * Either way the confirm UI closes; after a failed save the user stays.
122
+ * Resolves whether it saved. A rejection from `onSave` keeps the leave held
123
+ * and propagates.
124
+ */
125
+ saveAndLeave: () => Promise<boolean>
126
+ /** Whether `saveAndLeave` is waiting on `onSave`. */
127
+ isSaving: boolean
114
128
  }
115
129
 
116
130
  /** Ask before a leave (close, navigate, switch record) drops unsaved changes. */
117
131
  function useLeaveGuard({
118
132
  hasUnsavedChanges,
133
+ onSave,
119
134
  }: UseLeaveGuardOptions): LeaveGuard {
120
135
  const pending = useRef<(() => void) | null>(null)
121
136
  const [isConfirming, setIsConfirming] = useState(false)
137
+ const [isSaving, setIsSaving] = useState(false)
122
138
 
123
139
  const requestLeave = useCallback(
124
140
  (proceed: () => void) => {
@@ -144,7 +160,39 @@ function useLeaveGuard({
144
160
  setIsConfirming(false)
145
161
  }, [])
146
162
 
147
- return { cancelLeave, confirmLeave, isConfirming, requestLeave }
163
+ const saveAndLeave = useCallback(async () => {
164
+ const proceed = pending.current
165
+ if (!(proceed && onSave)) {
166
+ return false
167
+ }
168
+ setIsSaving(true)
169
+ let saved = false
170
+ try {
171
+ saved = await onSave()
172
+ } finally {
173
+ setIsSaving(false)
174
+ }
175
+ // Answered otherwise while saving (cancelled, or a newer leave replaced
176
+ // this one): leave that answer alone.
177
+ if (pending.current !== proceed) {
178
+ return saved
179
+ }
180
+ pending.current = null
181
+ setIsConfirming(false)
182
+ if (saved) {
183
+ proceed()
184
+ }
185
+ return saved
186
+ }, [onSave])
187
+
188
+ return {
189
+ cancelLeave,
190
+ confirmLeave,
191
+ isConfirming,
192
+ isSaving,
193
+ requestLeave,
194
+ saveAndLeave,
195
+ }
148
196
  }
149
197
 
150
198
  const FOCUSABLE =
@@ -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({