@zeno-lib/forms 0.3.0 → 0.5.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 (59) hide show
  1. package/dist/create-zeno-form.mjs +9 -3
  2. package/dist/form-element.mjs +7 -5
  3. package/dist/index.d.mts +2 -2
  4. package/dist/index.mjs +3 -3
  5. package/dist/lib/empty-value.d.mts +12 -0
  6. package/dist/lib/empty-value.mjs +68 -0
  7. package/dist/lib/focus-first-invalid.d.mts +23 -0
  8. package/dist/lib/focus-first-invalid.mjs +37 -0
  9. package/dist/lib/schema-defaults.d.mts +9 -1
  10. package/dist/lib/schema-defaults.mjs +33 -1
  11. package/dist/lib/schema-required.d.mts +2 -1
  12. package/dist/lib/schema-required.mjs +49 -21
  13. package/dist/lib/submit-action.mjs +1 -1
  14. package/dist/lib/submit-error.d.mts +36 -0
  15. package/dist/lib/submit-error.mjs +66 -0
  16. package/dist/lib/use-form-dialog.d.mts +15 -1
  17. package/dist/lib/use-form-dialog.mjs +20 -2
  18. package/dist/lib/use-is-invalid.d.mts +2 -1
  19. package/dist/lib/use-is-invalid.mjs +5 -2
  20. package/dist/lib/validation-modes.d.mts +5 -1
  21. package/dist/lib/validation-modes.mjs +9 -4
  22. package/package.json +1 -1
  23. package/src/create-form.tsx +2 -0
  24. package/src/create-zeno-form.tsx +25 -5
  25. package/src/fields/checkbox-field.tsx +8 -1
  26. package/src/fields/combobox-field.tsx +29 -12
  27. package/src/fields/date-picker-field.tsx +37 -9
  28. package/src/fields/email-field.tsx +8 -0
  29. package/src/fields/form-error.tsx +61 -0
  30. package/src/fields/index.ts +1 -0
  31. package/src/fields/input-field.tsx +6 -2
  32. package/src/fields/money-field.tsx +3 -1
  33. package/src/fields/multi-select-field.tsx +8 -1
  34. package/src/fields/number-field.tsx +9 -7
  35. package/src/fields/otp-field.tsx +8 -1
  36. package/src/fields/percentage-field.tsx +3 -1
  37. package/src/fields/radio-group-field.tsx +11 -4
  38. package/src/fields/select-field.tsx +26 -3
  39. package/src/fields/switch-field.tsx +8 -1
  40. package/src/fields/textarea-field.tsx +8 -1
  41. package/src/fields/year-field.tsx +3 -1
  42. package/src/form-dialog.test.tsx +493 -2
  43. package/src/form-dialog.tsx +217 -33
  44. package/src/form-element.tsx +13 -11
  45. package/src/form-error.test.tsx +255 -0
  46. package/src/index.ts +1 -0
  47. package/src/lib/empty-value.test.ts +72 -0
  48. package/src/lib/empty-value.ts +130 -0
  49. package/src/lib/focus-first-invalid.test.ts +22 -0
  50. package/src/lib/focus-first-invalid.ts +52 -0
  51. package/src/lib/schema-defaults.ts +50 -1
  52. package/src/lib/schema-required.test.ts +24 -2
  53. package/src/lib/schema-required.ts +81 -43
  54. package/src/lib/submit-error.ts +133 -0
  55. package/src/lib/use-form-dialog.ts +49 -1
  56. package/src/lib/use-is-invalid.ts +14 -1
  57. package/src/lib/validation-modes.ts +18 -6
  58. package/src/use-app-fields.test.tsx +312 -2
  59. package/src/use-form.focus-invalid.test.tsx +34 -0
@@ -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 }
@@ -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 =
@@ -6,6 +6,7 @@ import {
6
6
  getFormHideFieldErrors,
7
7
  getFormValidationMode,
8
8
  isFieldRequired,
9
+ isFieldRequiredBySchema,
9
10
  type ValidationMode,
10
11
  } from "./validation-modes"
11
12
 
@@ -106,4 +107,16 @@ function useIsFieldRequired(field: AnyFieldApi): boolean {
106
107
  return isFieldRequired(field.form, field.name)
107
108
  }
108
109
 
109
- export { useHideFieldErrors, useIsFieldRequired, useIsInvalid }
110
+ // Returns whether the form's schema requires this field, even with
111
+ // `requiredIndicator: false`. Drives what a required field does (no clear
112
+ // button, `aria-required`), where `useIsFieldRequired` drives the `*`.
113
+ function useIsFieldRequiredBySchema(field: AnyFieldApi): boolean {
114
+ return isFieldRequiredBySchema(field.form, field.name)
115
+ }
116
+
117
+ export {
118
+ useHideFieldErrors,
119
+ useIsFieldRequired,
120
+ useIsFieldRequiredBySchema,
121
+ useIsInvalid,
122
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import type { AnyFormApi } from "@tanstack/react-form"
4
4
 
5
- import { toRequiredPathKey } from "./schema-required"
5
+ import { type StandardSchemaLike, toRequiredPathKey } from "./schema-required"
6
6
 
7
7
  type ValidationMode = "change" | "blur" | "submit" | "blur-then-change"
8
8
 
@@ -13,6 +13,7 @@ type ZenoFormState = {
13
13
  hideFieldErrors: boolean
14
14
  requiredIndicator: boolean
15
15
  requiredFields: ReadonlySet<string>
16
+ schema: StandardSchemaLike | undefined
16
17
  }
17
18
 
18
19
  const EMPTY_REQUIRED_FIELDS: ReadonlySet<string> = new Set<string>()
@@ -21,6 +22,7 @@ const DEFAULT_FORM_STATE: ZenoFormState = {
21
22
  hideFieldErrors: false,
22
23
  requiredFields: EMPTY_REQUIRED_FIELDS,
23
24
  requiredIndicator: true,
25
+ schema: undefined,
24
26
  validation: DEFAULT_VALIDATION_MODE,
25
27
  }
26
28
 
@@ -57,19 +59,29 @@ function getFormHideFieldErrors(form: AnyFormApi): boolean {
57
59
  return getFormZenoState(form).hideFieldErrors
58
60
  }
59
61
 
62
+ // Whether the schema requires the field, whatever `requiredIndicator` says.
63
+ function getFormSchema(form: AnyFormApi): StandardSchemaLike | undefined {
64
+ return getFormZenoState(form).schema
65
+ }
66
+
67
+ function isFieldRequiredBySchema(form: AnyFormApi, name: string): boolean {
68
+ return getFormZenoState(form).requiredFields.has(toRequiredPathKey(name))
69
+ }
70
+
60
71
  function isFieldRequired(form: AnyFormApi, name: string): boolean {
61
- const state = getFormZenoState(form)
62
- if (!state.requiredIndicator) {
63
- return false
64
- }
65
- return state.requiredFields.has(toRequiredPathKey(name))
72
+ return (
73
+ getFormZenoState(form).requiredIndicator &&
74
+ isFieldRequiredBySchema(form, name)
75
+ )
66
76
  }
67
77
 
68
78
  export {
69
79
  DEFAULT_VALIDATION_MODE,
70
80
  getFormHideFieldErrors,
81
+ getFormSchema,
71
82
  getFormValidationMode,
72
83
  isFieldRequired,
84
+ isFieldRequiredBySchema,
73
85
  setFormZenoState,
74
86
  type ValidationMode,
75
87
  }
@@ -1,13 +1,29 @@
1
- import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
1
+ import {
2
+ cleanup,
3
+ fireEvent,
4
+ render,
5
+ screen,
6
+ waitFor,
7
+ within,
8
+ } from "@zeno-lib/test/testing-library"
2
9
  import userEvent from "@zeno-lib/test/user-event"
3
10
  import { afterEach, describe, expect, test, vi } from "vitest"
4
11
  import { z } from "zod"
5
- import { Form, FormProvider, useForm } from "./create-form"
12
+ import { SelectItem } from "@/components/ui/select"
13
+ import { Form, FormProvider, RadioGroupFieldItem, useForm } from "./create-form"
6
14
 
7
15
  const CONTACT_EMAIL_LABEL = /Contact email/
8
16
  const PASSWORD_LABEL = /Password/
9
17
  const NAME_LABEL = /Name/
10
18
  const ACCEPT_NAME = /Accept/
19
+ const TITLE_LABEL = /Title/
20
+ const NOTE_LABEL = /Note/
21
+ const DAY_LABEL = /Day/
22
+ const PICKED_DAY = /October 9/
23
+ const ROLE_LABEL = /Role/
24
+ const OWNER_LABEL = /Owner/
25
+ const PICK_A_ROLE = "Pick a role"
26
+ const PICK_A_DAY = "Pick a day"
11
27
 
12
28
  afterEach(() => {
13
29
  cleanup()
@@ -164,3 +180,297 @@ describe("field ids", () => {
164
180
  expect(dialog.getAttribute("aria-describedby")).toBe(hints[1]?.id)
165
181
  })
166
182
  })
183
+
184
+ describe("ComboboxField", () => {
185
+ test("offers to clear an optional value but not a required one", () => {
186
+ const schema = z.object({
187
+ forced: z.string().optional(),
188
+ kind: z.string().min(1),
189
+ note: z.string().optional(),
190
+ overridden: z.string().min(1),
191
+ })
192
+ const ITEMS = ["a", "b"]
193
+ function H() {
194
+ const form = useForm({
195
+ defaultValues: { forced: "a", kind: "a", note: "a", overridden: "a" },
196
+ onSubmit: vi.fn(),
197
+ schema,
198
+ })
199
+ const { ComboboxField } = form
200
+ return (
201
+ <FormProvider form={form}>
202
+ <Form>
203
+ <ComboboxField items={ITEMS} name="note" />
204
+ <ComboboxField items={ITEMS} name="kind" />
205
+ <ComboboxField items={ITEMS} name="forced" required />
206
+ <ComboboxField items={ITEMS} name="overridden" showClear />
207
+ </Form>
208
+ </FormProvider>
209
+ )
210
+ }
211
+ const { container } = render(<H />)
212
+ const hasClear = (name: string) => {
213
+ const root = container.querySelector<HTMLElement>(
214
+ `[data-field="${name}"]`
215
+ )
216
+ return root
217
+ ? within(root).queryByRole("button", { name: "Clear" }) !== null
218
+ : false
219
+ }
220
+ expect(hasClear("note")).toBe(true)
221
+ expect(hasClear("kind")).toBe(false)
222
+ expect(hasClear("forced")).toBe(false)
223
+ expect(hasClear("overridden")).toBe(true)
224
+ })
225
+ })
226
+
227
+ describe("required fields", () => {
228
+ test("a required control says so to assistive tech, with or without the `*`", () => {
229
+ const schema = z.object({
230
+ kind: z.string().min(1),
231
+ note: z.string(),
232
+ title: z.string().min(1),
233
+ })
234
+ function H({ requiredIndicator }: { requiredIndicator: boolean }) {
235
+ const form = useForm({
236
+ defaultValues: { kind: "a" },
237
+ onSubmit: vi.fn(),
238
+ requiredIndicator,
239
+ schema,
240
+ })
241
+ const { ComboboxField, InputField } = form
242
+ return (
243
+ <FormProvider form={form}>
244
+ <Form>
245
+ <InputField label="Title" name="title" />
246
+ <InputField label="Note" name="note" />
247
+ <ComboboxField items={["a", "b"]} label="Kind" name="kind" />
248
+ </Form>
249
+ </FormProvider>
250
+ )
251
+ }
252
+ for (const requiredIndicator of [true, false]) {
253
+ const { container, unmount } = render(
254
+ <H requiredIndicator={requiredIndicator} />
255
+ )
256
+ const title = screen.getByRole("textbox", { name: TITLE_LABEL })
257
+ const note = screen.getByRole("textbox", { name: NOTE_LABEL })
258
+ expect(title.getAttribute("aria-required")).toBe("true")
259
+ expect(note.hasAttribute("aria-required")).toBe(false)
260
+ expect(
261
+ container.querySelectorAll('[data-slot="required-indicator"]')
262
+ ).toHaveLength(requiredIndicator ? 2 : 0)
263
+ expect(
264
+ container.querySelector(
265
+ '[data-field="kind"] [data-slot="combobox-clear"]'
266
+ )
267
+ ).toBeNull()
268
+ unmount()
269
+ }
270
+ })
271
+ })
272
+
273
+ describe("DatePickerField", () => {
274
+ const DAY = new Date(2026, 9, 9)
275
+
276
+ async function pickSelectedDayAgain(required: boolean) {
277
+ const captured: { day?: Date } = {}
278
+ function H() {
279
+ const form = useForm({
280
+ defaultValues: { day: DAY as Date | undefined },
281
+ onSubmit: vi.fn(),
282
+ })
283
+ const { DatePickerField, Subscribe } = form
284
+ return (
285
+ <FormProvider form={form}>
286
+ <Form>
287
+ <DatePickerField label="Day" name="day" required={required} />
288
+ <Subscribe selector={(state) => state.values.day}>
289
+ {(day) => {
290
+ captured.day = day
291
+ return null
292
+ }}
293
+ </Subscribe>
294
+ </Form>
295
+ </FormProvider>
296
+ )
297
+ }
298
+ const user = userEvent.setup()
299
+ render(<H />)
300
+ fireEvent.click(screen.getByRole("button", { name: DAY_LABEL }))
301
+ await user.click(await screen.findByRole("button", { name: PICKED_DAY }))
302
+ return captured.day
303
+ }
304
+
305
+ test("picking a required date's day again keeps it", async () => {
306
+ expect(await pickSelectedDayAgain(true)).toEqual(DAY)
307
+ })
308
+
309
+ test("picking an optional date's day again clears it", async () => {
310
+ expect(await pickSelectedDayAgain(false)).toBeUndefined()
311
+ })
312
+ })
313
+
314
+ describe("ComboboxField with items still loading", () => {
315
+ test("shows a stored id's label once its item arrives, never the raw id", () => {
316
+ function H({ items }: { items: { label: string; value: string }[] }) {
317
+ const form = useForm({
318
+ defaultValues: { ownerId: "user-7" },
319
+ onSubmit: vi.fn(),
320
+ })
321
+ const { ComboboxField } = form
322
+ return (
323
+ <FormProvider form={form}>
324
+ <Form>
325
+ <ComboboxField items={items} label="Owner" name="ownerId" />
326
+ </Form>
327
+ </FormProvider>
328
+ )
329
+ }
330
+ const { rerender } = render(<H items={[]} />)
331
+ const input = screen.getByRole("combobox", { name: OWNER_LABEL })
332
+ expect(input).toHaveProperty("value", "")
333
+ rerender(<H items={[{ label: "Ada Lovelace", value: "user-7" }]} />)
334
+ expect(input).toHaveProperty("value", "Ada Lovelace")
335
+ })
336
+ })
337
+
338
+ describe("popup fields", () => {
339
+ test("a required select shows its error once the popup closes, not while it is open", async () => {
340
+ const user = userEvent.setup()
341
+ function H() {
342
+ const form = useForm({
343
+ onSubmit: vi.fn(),
344
+ schema: z.object({ role: z.string().min(1, PICK_A_ROLE) }),
345
+ })
346
+ const { SelectField } = form
347
+ return (
348
+ <FormProvider form={form}>
349
+ <Form>
350
+ <SelectField label="Role" name="role">
351
+ <SelectItem value="admin">Admin</SelectItem>
352
+ </SelectField>
353
+ </Form>
354
+ </FormProvider>
355
+ )
356
+ }
357
+ render(<H />)
358
+ await user.click(screen.getByRole("combobox", { name: ROLE_LABEL }))
359
+ await screen.findByRole("option", { name: "Admin" })
360
+ expect(screen.queryByText(PICK_A_ROLE)).toBeNull()
361
+ await user.keyboard("{Escape}")
362
+ expect(await screen.findByText(PICK_A_ROLE)).toBeTruthy()
363
+ })
364
+
365
+ test("a required date shows its error once the popover closes, not while it is open", async () => {
366
+ const user = userEvent.setup()
367
+ function H() {
368
+ const form = useForm({
369
+ onSubmit: vi.fn(),
370
+ schema: z.object({ day: z.date({ error: PICK_A_DAY }) }),
371
+ })
372
+ const { DatePickerField } = form
373
+ return (
374
+ <FormProvider form={form}>
375
+ <Form>
376
+ <DatePickerField label="Day" name="day" />
377
+ </Form>
378
+ </FormProvider>
379
+ )
380
+ }
381
+ render(<H />)
382
+ const trigger = screen.getByRole("button", { name: DAY_LABEL })
383
+ trigger.focus()
384
+ fireEvent.click(trigger)
385
+ await screen.findByRole("grid")
386
+ // The trigger's blur is what used to mark the field touched.
387
+ await waitFor(() => expect(document.activeElement).not.toBe(trigger))
388
+ expect(screen.queryByText(PICK_A_DAY)).toBeNull()
389
+ await user.keyboard("{Escape}")
390
+ expect(await screen.findByText(PICK_A_DAY)).toBeTruthy()
391
+ })
392
+ })
393
+
394
+ describe("RadioGroupField", () => {
395
+ test("stores each item's value as is", async () => {
396
+ const user = userEvent.setup()
397
+ const captured: { consent?: unknown } = {}
398
+ function H() {
399
+ const form = useForm({
400
+ defaultValues: { consent: undefined as boolean | undefined },
401
+ onSubmit: vi.fn(),
402
+ })
403
+ const { RadioGroupField, Subscribe } = form
404
+ return (
405
+ <FormProvider form={form}>
406
+ <Form>
407
+ <RadioGroupField label="Consent" name="consent">
408
+ <RadioGroupFieldItem value={true}>Yes</RadioGroupFieldItem>
409
+ <RadioGroupFieldItem value={false}>No</RadioGroupFieldItem>
410
+ </RadioGroupField>
411
+ <Subscribe selector={(state) => state.values.consent}>
412
+ {(consent) => {
413
+ captured.consent = consent
414
+ return null
415
+ }}
416
+ </Subscribe>
417
+ </Form>
418
+ </FormProvider>
419
+ )
420
+ }
421
+ render(<H />)
422
+ await user.click(screen.getByRole("radio", { name: "No" }))
423
+ expect(captured.consent).toBe(false)
424
+ await user.click(screen.getByRole("radio", { name: "Yes" }))
425
+ expect(captured.consent).toBe(true)
426
+ })
427
+ })
428
+
429
+ describe("clearing a field", () => {
430
+ test("empties it to a value its schema accepts", async () => {
431
+ const user = userEvent.setup()
432
+ const OWNER_ID = "00000000-0000-4000-8000-000000000001"
433
+ const schema = z.object({ kind: z.string(), ownerId: z.uuid().nullable() })
434
+ const captured: { values?: unknown } = {}
435
+ function H() {
436
+ const form = useForm({
437
+ defaultValues: { kind: "a", ownerId: OWNER_ID },
438
+ onSubmit: vi.fn(),
439
+ schema,
440
+ })
441
+ const { ComboboxField, Subscribe } = form
442
+ return (
443
+ <FormProvider form={form}>
444
+ <Form>
445
+ <ComboboxField items={["a", "b"]} label="Kind" name="kind" />
446
+ <ComboboxField
447
+ items={[{ label: "Ada Lovelace", value: OWNER_ID }]}
448
+ label="Owner"
449
+ name="ownerId"
450
+ />
451
+ <Subscribe selector={(state) => state.values}>
452
+ {(values) => {
453
+ captured.values = values
454
+ return null
455
+ }}
456
+ </Subscribe>
457
+ </Form>
458
+ </FormProvider>
459
+ )
460
+ }
461
+ const { container } = render(<H />)
462
+ const clearButton = (name: string) => {
463
+ const root = container.querySelector<HTMLElement>(
464
+ `[data-field="${name}"]`
465
+ )
466
+ if (!root) {
467
+ throw new Error(`no ${name} field`)
468
+ }
469
+ return within(root).getByRole("button", { name: "Clear" })
470
+ }
471
+ await user.click(clearButton("kind"))
472
+ await user.click(clearButton("ownerId"))
473
+ expect(captured.values).toEqual({ kind: "", ownerId: null })
474
+ expect(schema.safeParse(captured.values).success).toBe(true)
475
+ })
476
+ })
@@ -9,6 +9,7 @@ import type { ReactNode } from "react"
9
9
  import { afterEach, describe, expect, test, vi } from "vitest"
10
10
  import { z } from "zod"
11
11
  import { Form, FormProvider, RadioGroupFieldItem, useForm } from "./create-form"
12
+ import { submitAction } from "./lib/submit-action"
12
13
 
13
14
  afterEach(() => {
14
15
  cleanup()
@@ -103,6 +104,39 @@ describe("<Form> — focus the first invalid field on a failed submit", () => {
103
104
  )
104
105
  })
105
106
 
107
+ test("focuses a field error returned through submitAction", async () => {
108
+ const user = userEvent.setup()
109
+ function Harness() {
110
+ const form = useForm({
111
+ defaultValues: { email: "ada@example.com", name: "Ada" },
112
+ onSubmit: (submit) =>
113
+ submitAction(submit, () =>
114
+ Promise.resolve({
115
+ error: { fieldErrors: { name: ["Taken"] }, formErrors: [] },
116
+ ok: false as const,
117
+ })
118
+ ),
119
+ schema,
120
+ })
121
+ const { EmailField, InputField, SubmitButton } = form
122
+ return (
123
+ <FormProvider form={form}>
124
+ <Form aria-label="Profile">
125
+ <EmailField name="email" />
126
+ <InputField label="Name" name="name" />
127
+ <SubmitButton>Save</SubmitButton>
128
+ </Form>
129
+ </FormProvider>
130
+ )
131
+ }
132
+ render(<Harness />)
133
+ await user.click(screen.getByRole("button", { name: "Save" }))
134
+ expect(await screen.findByText("Taken")).toBeTruthy()
135
+ await waitFor(() =>
136
+ expect(document.activeElement).toBe(control("Profile", "name"))
137
+ )
138
+ })
139
+
106
140
  test("a radio group or slider root hands focus to its tabbable child", async () => {
107
141
  const user = userEvent.setup()
108
142
  function Harness({ sliderFirst }: { sliderFirst: boolean }) {