@zeno-lib/forms 0.2.2 → 0.4.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 (45) hide show
  1. package/dist/create-zeno-form.mjs +5 -3
  2. package/dist/index.d.mts +3 -2
  3. package/dist/index.mjs +4 -3
  4. package/dist/lib/empty-value.d.mts +12 -0
  5. package/dist/lib/empty-value.mjs +68 -0
  6. package/dist/lib/schema-defaults.d.mts +9 -1
  7. package/dist/lib/schema-defaults.mjs +33 -1
  8. package/dist/lib/schema-required.d.mts +2 -1
  9. package/dist/lib/schema-required.mjs +67 -18
  10. package/dist/lib/submit-action.mjs +1 -1
  11. package/dist/lib/use-form-dialog.d.mts +3 -2
  12. package/dist/lib/use-form-dialog.mjs +7 -3
  13. package/dist/lib/use-is-invalid.d.mts +2 -1
  14. package/dist/lib/use-is-invalid.mjs +5 -2
  15. package/dist/lib/validation-modes.d.mts +5 -1
  16. package/dist/lib/validation-modes.mjs +9 -4
  17. package/package.json +5 -3
  18. package/src/create-zeno-form.tsx +13 -5
  19. package/src/fields/checkbox-field.tsx +14 -6
  20. package/src/fields/checkbox-group-field.tsx +8 -7
  21. package/src/fields/combobox-field.tsx +35 -17
  22. package/src/fields/date-picker-field.tsx +42 -13
  23. package/src/fields/email-field.tsx +8 -0
  24. package/src/fields/input-field.tsx +12 -7
  25. package/src/fields/money-field.tsx +3 -1
  26. package/src/fields/multi-select-field.tsx +14 -6
  27. package/src/fields/number-field.tsx +9 -7
  28. package/src/fields/otp-field.tsx +14 -6
  29. package/src/fields/percentage-field.tsx +3 -1
  30. package/src/fields/radio-group-field.tsx +14 -6
  31. package/src/fields/select-field.tsx +31 -7
  32. package/src/fields/slider-field.tsx +6 -5
  33. package/src/fields/switch-field.tsx +14 -6
  34. package/src/fields/textarea-field.tsx +14 -6
  35. package/src/fields/year-field.tsx +3 -1
  36. package/src/index.ts +2 -0
  37. package/src/lib/empty-value.test.ts +72 -0
  38. package/src/lib/empty-value.ts +130 -0
  39. package/src/lib/schema-defaults.ts +50 -1
  40. package/src/lib/schema-required.test.ts +45 -3
  41. package/src/lib/schema-required.ts +113 -29
  42. package/src/lib/use-form-dialog.ts +11 -3
  43. package/src/lib/use-is-invalid.ts +14 -1
  44. package/src/lib/validation-modes.ts +18 -6
  45. package/src/use-app-fields.test.tsx +341 -4
@@ -9,9 +9,17 @@
9
9
  // A missing parent hides its children (the schema stops at the missing key), so
10
10
  // the probe descends: when an issue says it expected an object or an array
11
11
  // (Zod and Valibot both put that on `issue.expected`), the next pass fills that
12
- // path with `{}` / `[{}]` and validates again. Array rows are probed through
13
- // index `0` and recorded index-agnostically, so `members[0].name` in the set
14
- // matches every row's `members[3].name` via `toRequiredPathKey`.
12
+ // path and validates again. An object gets `{}`. An array gets one missing row
13
+ // (`[undefined]`), so the following pass probes the row's own schema. Rows are
14
+ // probed through index `0` and recorded index-agnostically, so
15
+ // `members[0].name` in the set matches every row's `members[3].name` via
16
+ // `toRequiredPathKey`.
17
+ //
18
+ // A missing field is then probed with an empty value: `""` for a string, then
19
+ // `null`; `[]` for an array; `null` for anything else. A field that accepts one
20
+ // (a bare `z.string()` or `z.array()`, anything `.nullable()`) leaves the set,
21
+ // matching `lib/empty-value.ts`; one that rejects them all (`.min(1)`,
22
+ // `z.email()`, a bare `z.number()`) stays.
15
23
  //
16
24
  // Paths use TanStack Form's field-name syntax: dots for object keys, brackets
17
25
  // for array indices (`members[0].name`, not `members.0.name`).
@@ -42,7 +50,8 @@ type StandardSchemaLike = {
42
50
  }
43
51
  }
44
52
 
45
- const MAX_PROBE_DEPTH = 8
53
+ // Each level can take two passes: the empty value, then the descent.
54
+ const MAX_PROBE_DEPTH = 16
46
55
 
47
56
  const INDEX_SEGMENT = /^\d+$/
48
57
 
@@ -86,22 +95,26 @@ function toRequiredPathKey(name: string): string {
86
95
  return name.replace(INDEX_IN_NAME, "[0]")
87
96
  }
88
97
 
89
- function probeContainer(expected: unknown): unknown {
90
- if (typeof expected !== "string") {
91
- return
92
- }
93
- const kind = expected.toLowerCase()
94
- if (kind === "object") {
95
- return {}
98
+ function probeValue(expected: unknown): unknown {
99
+ switch (typeof expected === "string" ? expected.toLowerCase() : undefined) {
100
+ case "object":
101
+ return {}
102
+ case "array":
103
+ return []
104
+ case "string":
105
+ return ""
106
+ default:
107
+ return null
96
108
  }
97
- return kind === "array" ? [{}] : undefined
98
109
  }
99
110
 
100
- // Write `value` at `keys` inside `root`, only where nothing is set yet.
111
+ // Write `value` at `keys` inside `root`, only where nothing is set yet unless
112
+ // `replace` is set.
101
113
  function fillAt(
102
114
  root: Record<PropertyKey, unknown>,
103
115
  keys: readonly PropertyKey[],
104
- value: unknown
116
+ value: unknown,
117
+ replace = false
105
118
  ): boolean {
106
119
  let node: Record<PropertyKey, unknown> = root
107
120
  for (const key of keys.slice(0, -1)) {
@@ -112,7 +125,7 @@ function fillAt(
112
125
  node = next as Record<PropertyKey, unknown>
113
126
  }
114
127
  const last = keys.at(-1) as PropertyKey
115
- if (node[last] !== undefined) {
128
+ if (!replace && node[last] !== undefined) {
116
129
  return false
117
130
  }
118
131
  node[last] = value
@@ -135,27 +148,98 @@ function validateSync(
135
148
  return "issues" in result && result.issues ? result.issues : []
136
149
  }
137
150
 
151
+ type ProbeFill = {
152
+ readonly keys: readonly PropertyKey[]
153
+ readonly path: string
154
+ readonly value: unknown
155
+ }
156
+
157
+ type ProbePass = {
158
+ readonly failing: ReadonlySet<string>
159
+ readonly filled: readonly ProbeFill[]
160
+ }
161
+
162
+ // Record one pass's failing paths, and fill the probe at each of them for the
163
+ // next pass. A path whose empty value already passed stays out of the set.
164
+ function recordPass(
165
+ issues: readonly StandardIssue[],
166
+ probe: Record<PropertyKey, unknown>,
167
+ required: Set<string>,
168
+ cleared: ReadonlySet<string>
169
+ ): ProbePass {
170
+ const failing = new Set<string>()
171
+ const filled: ProbeFill[] = []
172
+ for (const issue of issues) {
173
+ const keys = issue.path?.map(pathKey) ?? []
174
+ if (keys.length === 0) {
175
+ continue
176
+ }
177
+ const path = joinPath(keys)
178
+ failing.add(path)
179
+ if (!cleared.has(path)) {
180
+ required.add(path)
181
+ }
182
+ const value = probeValue(issue.expected)
183
+ if (value !== undefined && fillAt(probe, keys, value)) {
184
+ filled.push({ keys, path, value })
185
+ }
186
+ }
187
+ return { failing, filled }
188
+ }
189
+
190
+ function isEmptyProbe(value: unknown): boolean {
191
+ return value === "" || value === null || Array.isArray(value)
192
+ }
193
+
194
+ // Settle what the previous pass filled with an empty value: a path that now
195
+ // passes leaves the required set. A string that rejects `""` gets `null` next,
196
+ // and an array checked empty gets one missing row, so the row is probed.
197
+ function settleEmptied(
198
+ emptied: readonly ProbeFill[],
199
+ failing: ReadonlySet<string>,
200
+ probe: Record<PropertyKey, unknown>,
201
+ required: Set<string>,
202
+ cleared: Set<string>
203
+ ): { readonly next: ProbeFill[]; readonly descended: boolean } {
204
+ const next: ProbeFill[] = []
205
+ let descended = false
206
+ for (const fill of emptied) {
207
+ const passed = !failing.has(fill.path)
208
+ if (passed) {
209
+ required.delete(fill.path)
210
+ cleared.add(fill.path)
211
+ }
212
+ if (Array.isArray(fill.value)) {
213
+ fill.value.push(undefined)
214
+ descended = true
215
+ } else if (!passed && fill.value === "") {
216
+ fillAt(probe, fill.keys, null, true)
217
+ next.push({ ...fill, value: null })
218
+ descended = true
219
+ }
220
+ }
221
+ return { descended, next }
222
+ }
223
+
138
224
  function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
139
225
  const required = new Set<string>()
226
+ const cleared = new Set<string>()
140
227
  const probe: Record<PropertyKey, unknown> = {}
228
+ // What the previous pass filled with an empty value: each stays required
229
+ // only if it still fails.
230
+ let emptied: readonly ProbeFill[] = []
141
231
  for (let depth = 0; depth < MAX_PROBE_DEPTH; depth += 1) {
142
232
  const issues = validateSync(schema, probe)
143
233
  if (!issues) {
144
234
  return required
145
235
  }
146
- let descended = false
147
- for (const issue of issues) {
148
- const keys = issue.path?.map(pathKey) ?? []
149
- if (keys.length === 0) {
150
- continue
151
- }
152
- required.add(joinPath(keys))
153
- const container = probeContainer(issue.expected)
154
- if (container !== undefined && fillAt(probe, keys, container)) {
155
- descended = true
156
- }
157
- }
158
- if (!descended) {
236
+ const { failing, filled } = recordPass(issues, probe, required, cleared)
237
+ const settled = settleEmptied(emptied, failing, probe, required, cleared)
238
+ emptied = [
239
+ ...filled.filter(({ value }) => isEmptyProbe(value)),
240
+ ...settled.next,
241
+ ]
242
+ if (filled.length === 0 && !settled.descended) {
159
243
  break
160
244
  }
161
245
  }
@@ -163,4 +247,4 @@ function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
163
247
  }
164
248
 
165
249
  export type { StandardSchemaLike }
166
- export { getRequiredPaths, toRequiredPathKey }
250
+ export { getRequiredPaths, toRequiredPathKey, validateSync }
@@ -156,8 +156,9 @@ function attributeSelector(attribute: string, value: string): string {
156
156
 
157
157
  /**
158
158
  * Find the element to focus for field `name` inside `root`: the control with
159
- * `id={name}` (what the shipped fields render), else the first focusable
160
- * element inside the field root marked `data-field={name}`.
159
+ * `id={name}` (fields that use the name as their id), else, inside the field
160
+ * root marked `data-field={name}`, the control its label points at (the shipped
161
+ * fields' ids come from `useId`), else that root's first focusable element.
161
162
  */
162
163
  function findFieldElement(
163
164
  root: ParentNode | null | undefined,
@@ -171,7 +172,14 @@ function findFieldElement(
171
172
  return byId
172
173
  }
173
174
  const fieldRoot = root.querySelector(attributeSelector("data-field", name))
174
- return fieldRoot?.querySelector<HTMLElement>(FOCUSABLE) ?? null
175
+ if (!fieldRoot) {
176
+ return null
177
+ }
178
+ const labelFor = fieldRoot.querySelector("label[for]")?.getAttribute("for")
179
+ const labelled = labelFor
180
+ ? fieldRoot.querySelector<HTMLElement>(attributeSelector("id", labelFor))
181
+ : null
182
+ return labelled ?? fieldRoot.querySelector<HTMLElement>(FOCUSABLE)
175
183
  }
176
184
 
177
185
  export type {
@@ -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()
@@ -30,7 +46,6 @@ describe("EmailField / PasswordField", () => {
30
46
  }
31
47
  render(<H />)
32
48
  const input = screen.getByLabelText(CONTACT_EMAIL_LABEL) as HTMLInputElement
33
- expect(input.id).toBe("contactEmail")
34
49
  expect(input.name).toBe("contactEmail")
35
50
  await user.type(input, "u@example.com")
36
51
  expect(input.value).toBe("u@example.com")
@@ -54,7 +69,6 @@ describe("EmailField / PasswordField", () => {
54
69
  }
55
70
  render(<H />)
56
71
  const input = screen.getByLabelText(PASSWORD_LABEL) as HTMLInputElement
57
- expect(input.id).toBe("password")
58
72
  expect(input.name).toBe("password")
59
73
  expect(input.type).toBe("password")
60
74
  })
@@ -137,3 +151,326 @@ describe("CheckboxField — boolean state wrapper", () => {
137
151
  expect(checkbox.getAttribute("aria-checked")).toBe("true")
138
152
  })
139
153
  })
154
+
155
+ describe("field ids", () => {
156
+ test("two forms with the same field name label their own controls", () => {
157
+ const schema = z.object({ description: z.string().min(1) })
158
+ function H({ label }: { label: string }) {
159
+ const form = useForm({ onSubmit: vi.fn(), schema })
160
+ const { InputField } = form
161
+ return (
162
+ <FormProvider form={form}>
163
+ <Form>
164
+ <InputField description="Hint" label={label} name="description" />
165
+ </Form>
166
+ </FormProvider>
167
+ )
168
+ }
169
+ render(
170
+ <>
171
+ <H label="Page description" />
172
+ <H label="Dialog description" />
173
+ </>
174
+ )
175
+ const page = screen.getByRole("textbox", { name: "Page description" })
176
+ const dialog = screen.getByRole("textbox", { name: "Dialog description" })
177
+ expect(dialog).not.toBe(page)
178
+ const hints = screen.getAllByText("Hint")
179
+ expect(page.getAttribute("aria-describedby")).toBe(hints[0]?.id)
180
+ expect(dialog.getAttribute("aria-describedby")).toBe(hints[1]?.id)
181
+ })
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
+ })