@zeno-lib/forms 0.6.0 → 0.7.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.d.mts +19 -4
  2. package/dist/create-zeno-form.mjs +29 -6
  3. package/dist/form-element.d.mts +16 -3
  4. package/dist/form-element.mjs +38 -18
  5. package/dist/index.d.mts +2 -1
  6. package/dist/index.mjs +2 -1
  7. package/dist/lib/date-value.d.mts +24 -0
  8. package/dist/lib/date-value.mjs +37 -0
  9. package/dist/lib/default-props.d.mts +7 -0
  10. package/dist/lib/default-props.mjs +13 -0
  11. package/dist/lib/empty-value.mjs +25 -1
  12. package/dist/lib/form-disabled.d.mts +28 -0
  13. package/dist/lib/form-disabled.mjs +54 -0
  14. package/dist/lib/item-options.d.mts +45 -0
  15. package/dist/lib/item-options.mjs +70 -0
  16. package/dist/lib/schema-required.mjs +17 -0
  17. package/dist/lib/use-field-control.d.mts +7 -0
  18. package/dist/lib/use-field-control.mjs +4 -1
  19. package/package.json +1 -1
  20. package/src/create-form.tsx +34 -29
  21. package/src/create-zeno-form.tsx +124 -51
  22. package/src/date-picker-field.test.tsx +108 -0
  23. package/src/default-props.test-d.ts +66 -0
  24. package/src/default-props.test.tsx +165 -0
  25. package/src/fields/checkbox-field.tsx +5 -0
  26. package/src/fields/checkbox-group-field.tsx +6 -2
  27. package/src/fields/combobox-field.tsx +107 -40
  28. package/src/fields/date-picker-field.tsx +34 -11
  29. package/src/fields/field-shell.tsx +8 -1
  30. package/src/fields/input-field.tsx +2 -0
  31. package/src/fields/multi-select-field.tsx +100 -42
  32. package/src/fields/otp-field.tsx +8 -2
  33. package/src/fields/radio-group-field.tsx +9 -1
  34. package/src/fields/reset-button.tsx +4 -0
  35. package/src/fields/select-field.tsx +9 -1
  36. package/src/fields/slider-field.tsx +9 -1
  37. package/src/fields/submit-button.tsx +3 -1
  38. package/src/fields/switch-field.tsx +5 -0
  39. package/src/fields/textarea-field.tsx +2 -0
  40. package/src/form-dialog.test.tsx +102 -1
  41. package/src/form-dialog.tsx +143 -42
  42. package/src/form-disabled.test.tsx +329 -0
  43. package/src/form-element.tsx +64 -29
  44. package/src/grouped-options.test-d.ts +59 -0
  45. package/src/grouped-options.test.tsx +251 -0
  46. package/src/index.ts +1 -0
  47. package/src/lib/date-value.test.ts +14 -0
  48. package/src/lib/date-value.ts +70 -0
  49. package/src/lib/default-props.ts +19 -0
  50. package/src/lib/empty-value.test.ts +19 -0
  51. package/src/lib/empty-value.ts +45 -1
  52. package/src/lib/form-disabled.ts +95 -0
  53. package/src/lib/item-options.test.ts +11 -0
  54. package/src/lib/item-options.ts +165 -0
  55. package/src/lib/schema-required.test.ts +9 -0
  56. package/src/lib/schema-required.ts +33 -0
  57. package/src/lib/use-field-control.ts +11 -1
  58. package/src/with-form.test-d.ts +76 -0
  59. package/src/with-form.test.tsx +60 -0
@@ -1,52 +1,87 @@
1
1
  "use client"
2
2
 
3
3
  import type { AnyFormApi } from "@tanstack/react-form"
4
- import type { ComponentProps, FormEvent, ReactNode } from "react"
4
+ import {
5
+ type ComponentProps,
6
+ type FormEvent,
7
+ type ReactNode,
8
+ useMemo,
9
+ } from "react"
5
10
 
6
11
  import { FormProvider as RawFormProvider, useFormContext } from "./lib/contexts"
7
12
  import { focusFirstInvalid, restoreFocus } from "./lib/focus-first-invalid"
13
+ import { FormDisabledContext, useIsFormDisabled } from "./lib/form-disabled"
8
14
  import { reportSubmitError } from "./lib/submit-error"
9
15
 
10
16
  type FormProviderProps = {
11
17
  children: ReactNode
18
+ /**
19
+ * Disable every field of the form and its submit button, as
20
+ * `useForm({ disabled })` does. A field's own `disabled` still applies.
21
+ */
22
+ disabled?: boolean
12
23
  form: { handleSubmit: () => unknown }
13
24
  }
14
25
 
15
- function FormProvider({ children, form }: FormProviderProps) {
26
+ function FormProvider({ children, disabled = false, form }: FormProviderProps) {
27
+ const { store } = form as AnyFormApi
28
+ const scope = useMemo(() => ({ disabled, key: store }), [disabled, store])
16
29
  return (
17
- <RawFormProvider value={form as AnyFormApi}>{children}</RawFormProvider>
30
+ <RawFormProvider value={form as AnyFormApi}>
31
+ <FormDisabledContext value={scope}>{children}</FormDisabledContext>
32
+ </RawFormProvider>
18
33
  )
19
34
  }
20
35
 
21
- type FormProps = Omit<ComponentProps<"form">, "onSubmit">
36
+ type FormProps = Omit<ComponentProps<"form">, "onSubmit"> & {
37
+ /**
38
+ * Disable every field and button of the kit inside, and ignore a submit. A
39
+ * field's own `disabled` still applies. A button outside the `<form>`
40
+ * (`form="id"`) isn't inside: disable the form with `useForm({ disabled })`
41
+ * or `<FormProvider disabled>` to reach it.
42
+ */
43
+ disabled?: boolean
44
+ }
22
45
 
23
- function Form({ children, className, ...props }: FormProps) {
46
+ function Form({ children, className, disabled = false, ...props }: FormProps) {
24
47
  const form = useFormContext()
48
+ const isDisabled = useIsFormDisabled(form) || disabled
49
+ const { store } = form
50
+ const scope = useMemo(
51
+ () => ({ disabled: isDisabled, key: store }),
52
+ [isDisabled, store]
53
+ )
25
54
  return (
26
- <form
27
- className={className}
28
- noValidate
29
- onSubmit={async (event: FormEvent<HTMLFormElement>) => {
30
- event.preventDefault()
31
- event.stopPropagation()
32
- const node = event.currentTarget
33
- const focused = node.ownerDocument.activeElement
34
- // Caught so the user can retry; `useForm` keeps it for `FormError`.
35
- await Promise.resolve(form.handleSubmit()).catch(reportSubmitError)
36
- const { isSubmitSuccessful, isValid } = form.state
37
- if (!isValid && focusFirstInvalid(node)) {
38
- return
39
- }
40
- // A form-level failure has no field to fix, so focus stays where it
41
- // was, or goes back there if the submit button's disabling dropped it.
42
- if (!(isValid && isSubmitSuccessful)) {
43
- restoreFocus(focused)
44
- }
45
- }}
46
- {...props}
47
- >
48
- {children}
49
- </form>
55
+ <FormDisabledContext value={scope}>
56
+ <form
57
+ className={className}
58
+ noValidate
59
+ onSubmit={async (event: FormEvent<HTMLFormElement>) => {
60
+ event.preventDefault()
61
+ event.stopPropagation()
62
+ // A `<button form={id}>` outside the form can still submit it.
63
+ if (isDisabled) {
64
+ return
65
+ }
66
+ const node = event.currentTarget
67
+ const focused = node.ownerDocument.activeElement
68
+ // Caught so the user can retry; `useForm` keeps it for `FormError`.
69
+ await Promise.resolve(form.handleSubmit()).catch(reportSubmitError)
70
+ const { isSubmitSuccessful, isValid } = form.state
71
+ if (!isValid && focusFirstInvalid(node)) {
72
+ return
73
+ }
74
+ // A form-level failure has no field to fix, so focus stays where it
75
+ // was, or goes back there if the submit button's disabling dropped it.
76
+ if (!(isValid && isSubmitSuccessful)) {
77
+ restoreFocus(focused)
78
+ }
79
+ }}
80
+ {...props}
81
+ >
82
+ {children}
83
+ </form>
84
+ </FormDisabledContext>
50
85
  )
51
86
  }
52
87
 
@@ -0,0 +1,59 @@
1
+ import { expectTypeOf, test } from "vitest"
2
+ import { useForm } from "./create-form"
3
+ import { ComboboxField } from "./fields/combobox-field"
4
+ import { MultiSelectField } from "./fields/multi-select-field"
5
+
6
+ type Party = { id: number; kind: "company" | "person" }
7
+ type PartyItem = { label: string; value: Party }
8
+
9
+ const GROUPS: { label: string; items: PartyItem[] }[] = [
10
+ {
11
+ items: [{ label: "Ada", value: { id: 1, kind: "person" } }],
12
+ label: "People",
13
+ },
14
+ ]
15
+
16
+ const isSameParty = (a: Party, b: Party) => a.kind === b.kind && a.id === b.id
17
+
18
+ test("grouped items type renderItem with an item and isItemEqualToValue with its value", () => {
19
+ ComboboxField({
20
+ isItemEqualToValue: (a, b) => {
21
+ expectTypeOf(a).toEqualTypeOf<Party>()
22
+ return isSameParty(a, b)
23
+ },
24
+ items: GROUPS,
25
+ renderItem: (item) => {
26
+ expectTypeOf(item).toEqualTypeOf<PartyItem>()
27
+ return null
28
+ },
29
+ })
30
+ MultiSelectField({
31
+ isItemEqualToValue: (a, b) => {
32
+ expectTypeOf(a).toEqualTypeOf<Party>()
33
+ return isSameParty(a, b)
34
+ },
35
+ items: GROUPS,
36
+ renderItem: (item) => {
37
+ expectTypeOf(item).toEqualTypeOf<PartyItem>()
38
+ return null
39
+ },
40
+ })
41
+ })
42
+
43
+ // `form.ComboboxField` reads the field's props with `T` as `unknown`.
44
+ test("form fields take a comparator typed with the value", () => {
45
+ const form = useForm({
46
+ defaultValues: { parties: [] as Party[], payer: null as Party | null },
47
+ onSubmit: () => undefined,
48
+ })
49
+ form.ComboboxField({
50
+ isItemEqualToValue: isSameParty,
51
+ items: GROUPS,
52
+ name: "payer",
53
+ })
54
+ form.MultiSelectField({
55
+ isItemEqualToValue: isSameParty,
56
+ items: GROUPS,
57
+ name: "parties",
58
+ })
59
+ })
@@ -0,0 +1,251 @@
1
+ import { cleanup, render, screen, within } from "@zeno-lib/test/testing-library"
2
+ import userEvent from "@zeno-lib/test/user-event"
3
+ import { useState } from "react"
4
+ import { afterEach, describe, expect, test, vi } from "vitest"
5
+ import { z } from "zod"
6
+ import { Form, FormProvider, useForm } from "./create-form"
7
+
8
+ afterEach(() => {
9
+ cleanup()
10
+ })
11
+
12
+ const party = z.object({ id: z.number(), kind: z.enum(["company", "person"]) })
13
+ type Party = z.infer<typeof party>
14
+
15
+ const schema = z.object({
16
+ parties: z.array(party),
17
+ payer: party.nullable(),
18
+ })
19
+ type Values = z.infer<typeof schema>
20
+
21
+ // Rebuilt on every render, as options mapped from fetched rows are, so no
22
+ // item value is ever the stored object. A person and a company share an id.
23
+ function partyGroups() {
24
+ return [
25
+ {
26
+ items: [{ label: "Ada Lovelace", value: { id: 1, kind: "person" } }],
27
+ label: "People",
28
+ },
29
+ {
30
+ items: [
31
+ { label: "Globex", value: { id: 1, kind: "company" } },
32
+ { label: "Initech", value: { id: 2, kind: "company" } },
33
+ ],
34
+ label: "Companies",
35
+ },
36
+ ]
37
+ }
38
+
39
+ const isSameParty = (a: Party, b: Party) => a.kind === b.kind && a.id === b.id
40
+
41
+ function Harness({ captured }: { captured: { values?: Values } }) {
42
+ const form = useForm({
43
+ defaultValues: {
44
+ parties: [{ id: 1, kind: "company" }],
45
+ payer: { id: 1, kind: "company" },
46
+ } as Values,
47
+ onSubmit: vi.fn(),
48
+ schema,
49
+ })
50
+ const { ComboboxField, MultiSelectField, Subscribe } = form
51
+ return (
52
+ <FormProvider form={form}>
53
+ <Form>
54
+ <ComboboxField
55
+ isItemEqualToValue={isSameParty}
56
+ items={partyGroups()}
57
+ label="Payer"
58
+ name="payer"
59
+ />
60
+ <MultiSelectField
61
+ isItemEqualToValue={isSameParty}
62
+ items={partyGroups()}
63
+ label="Parties"
64
+ name="parties"
65
+ />
66
+ <Subscribe selector={(state) => state.values}>
67
+ {(values) => {
68
+ captured.values = values
69
+ return null
70
+ }}
71
+ </Subscribe>
72
+ </Form>
73
+ </FormProvider>
74
+ )
75
+ }
76
+
77
+ function fieldRoot(container: HTMLElement, name: string): HTMLElement {
78
+ const root = container.querySelector<HTMLElement>(`[data-field="${name}"]`)
79
+ if (!root) {
80
+ throw new Error(`no ${name} field`)
81
+ }
82
+ return root
83
+ }
84
+
85
+ function chips(container: HTMLElement): string[] {
86
+ return [
87
+ ...fieldRoot(container, "parties").querySelectorAll(
88
+ '[data-slot="combobox-chip"]'
89
+ ),
90
+ ].map((chip) => chip.textContent ?? "")
91
+ }
92
+
93
+ describe("grouped options with object values", () => {
94
+ test("ComboboxField keeps a stored object selected, stores the picked one, and clears to the empty value", async () => {
95
+ const user = userEvent.setup()
96
+ const captured: { values?: Values } = {}
97
+ const { container, rerender } = render(<Harness captured={captured} />)
98
+ const input = screen.getByRole("combobox", { name: "Payer" })
99
+ expect(input).toHaveProperty("value", "Globex")
100
+
101
+ await user.click(input)
102
+ const companies = await screen.findByRole("group", { name: "Companies" })
103
+ expect(
104
+ within(companies)
105
+ .getByRole("option", { name: "Globex" })
106
+ .getAttribute("aria-selected")
107
+ ).toBe("true")
108
+ await user.click(screen.getByRole("option", { name: "Ada Lovelace" }))
109
+ expect(captured.values?.payer).toEqual({ id: 1, kind: "person" })
110
+
111
+ rerender(<Harness captured={captured} />)
112
+ expect(input).toHaveProperty("value", "Ada Lovelace")
113
+
114
+ await user.click(
115
+ within(fieldRoot(container, "payer")).getByRole("button", {
116
+ name: "Clear",
117
+ })
118
+ )
119
+ expect(captured.values?.payer).toBeNull()
120
+ })
121
+
122
+ test("MultiSelectField keeps stored objects selected, adds the picked one, and clears", async () => {
123
+ const user = userEvent.setup()
124
+ const captured: { values?: Values } = {}
125
+ const { container, rerender } = render(<Harness captured={captured} />)
126
+ expect(chips(container)).toEqual(["Globex"])
127
+
128
+ await user.click(screen.getByRole("combobox", { name: "Parties" }))
129
+ const people = await screen.findByRole("group", { name: "People" })
130
+ await user.click(
131
+ within(people).getByRole("option", { name: "Ada Lovelace" })
132
+ )
133
+ await user.keyboard("{Escape}")
134
+ expect(captured.values?.parties).toEqual([
135
+ { id: 1, kind: "company" },
136
+ { id: 1, kind: "person" },
137
+ ])
138
+
139
+ rerender(<Harness captured={captured} />)
140
+ expect(chips(container)).toEqual(["Globex", "Ada Lovelace"])
141
+
142
+ await user.click(
143
+ within(fieldRoot(container, "parties")).getByRole("button", {
144
+ name: "Clear",
145
+ })
146
+ )
147
+ expect(captured.values?.parties).toEqual([])
148
+ })
149
+ })
150
+
151
+ describe("grouped options", () => {
152
+ test.each([
153
+ ["a group first", [{ items: ["Ada"], label: "People" }, "Globex"]],
154
+ ["a plain item first", ["Globex", { items: ["Ada"], label: "People" }]],
155
+ ])("mix groups and plain items, with %s", async (_, items) => {
156
+ const user = userEvent.setup()
157
+ const captured: { values?: { name: string | null } } = {}
158
+ function H() {
159
+ const form = useForm({
160
+ defaultValues: { name: null as string | null },
161
+ onSubmit: vi.fn(),
162
+ })
163
+ return (
164
+ <FormProvider form={form}>
165
+ <form.ComboboxField items={items} label="Name" name="name" />
166
+ <form.Subscribe selector={(state) => state.values}>
167
+ {(values) => {
168
+ captured.values = values
169
+ return null
170
+ }}
171
+ </form.Subscribe>
172
+ </FormProvider>
173
+ )
174
+ }
175
+ render(<H />)
176
+ const input = screen.getByRole("combobox", { name: "Name" })
177
+ await user.type(input, "Ad")
178
+ const people = await screen.findByRole("group", { name: "People" })
179
+ await user.click(within(people).getByRole("option", { name: "Ada" }))
180
+ expect(captured.values?.name).toBe("Ada")
181
+ await user.clear(input)
182
+ await user.type(input, "Glo")
183
+ await user.click(await screen.findByRole("option", { name: "Globex" }))
184
+ expect(captured.values?.name).toBe("Globex")
185
+ })
186
+
187
+ test("ComboboxField keeps what the user types while the items are rebuilt", async () => {
188
+ const user = userEvent.setup()
189
+ function H() {
190
+ const [, setQuery] = useState("")
191
+ const form = useForm({
192
+ defaultValues: { payer: { id: 1, kind: "company" } } as Values,
193
+ onSubmit: vi.fn(),
194
+ schema,
195
+ })
196
+ return (
197
+ <FormProvider form={form}>
198
+ <form.ComboboxField
199
+ isItemEqualToValue={isSameParty}
200
+ items={partyGroups()}
201
+ label="Payer"
202
+ name="payer"
203
+ onInputValueChange={setQuery}
204
+ />
205
+ </FormProvider>
206
+ )
207
+ }
208
+ render(<H />)
209
+ const input = screen.getByRole("combobox", { name: "Payer" })
210
+ await user.type(input, " Corp")
211
+ expect(input).toHaveProperty("value", "Globex Corp")
212
+ })
213
+
214
+ test("MultiSelectField keeps a stored value whose item isn't loaded yet", async () => {
215
+ const user = userEvent.setup()
216
+ const captured: { values?: Values } = {}
217
+ function H() {
218
+ const form = useForm({
219
+ defaultValues: { parties: [{ id: 9, kind: "company" }] } as Values,
220
+ onSubmit: vi.fn(),
221
+ schema,
222
+ })
223
+ return (
224
+ <FormProvider form={form}>
225
+ <form.MultiSelectField
226
+ isItemEqualToValue={isSameParty}
227
+ // The companies are still loading.
228
+ items={partyGroups().slice(0, 1)}
229
+ label="Parties"
230
+ name="parties"
231
+ />
232
+ <form.Subscribe selector={(state) => state.values}>
233
+ {(values) => {
234
+ captured.values = values
235
+ return null
236
+ }}
237
+ </form.Subscribe>
238
+ </FormProvider>
239
+ )
240
+ }
241
+ render(<H />)
242
+ await user.click(screen.getByRole("combobox", { name: "Parties" }))
243
+ await user.click(
244
+ await screen.findByRole("option", { name: "Ada Lovelace" })
245
+ )
246
+ expect(captured.values?.parties).toEqual([
247
+ { id: 9, kind: "company" },
248
+ { id: 1, kind: "person" },
249
+ ])
250
+ })
251
+ })
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  } from "./lib/action-result"
13
13
  export { applyValidationError } from "./lib/apply-validation-error"
14
14
  export { useFieldContext, useFormContext } from "./lib/contexts"
15
+ export { useIsFormDisabled } from "./lib/form-disabled"
15
16
  export {
16
17
  applyActionError,
17
18
  type SubmitActionOptions,
@@ -0,0 +1,14 @@
1
+ import { describe, expect, test } from "vitest"
2
+ import { formatIsoDate, parseDateValue } from "./date-value"
3
+
4
+ describe("parseDateValue", () => {
5
+ test("reads a day the month doesn't have as empty, not as next month's", () => {
6
+ expect(parseDateValue("2026-02-31")).toBeUndefined()
7
+ })
8
+
9
+ test("keeps a year below 100, which `new Date(y, m, d)` reads as 19yy", () => {
10
+ const date = parseDateValue("0042-03-01")
11
+ expect(date?.getFullYear()).toBe(42)
12
+ expect(date && formatIsoDate(date)).toBe("0042-03-01")
13
+ })
14
+ })
@@ -0,0 +1,70 @@
1
+ /**
2
+ * What a date field stores for a picked day:
3
+ * - `"date"`: a `Date`, at local midnight.
4
+ * - `"iso-date"`: the day as `YYYY-MM-DD`, as `z.iso.date()` accepts and a
5
+ * `date` column stores it.
6
+ * - `"iso-datetime"`: the day's local midnight as an ISO 8601 timestamp in
7
+ * UTC (`toISOString()`), as `z.iso.datetime()` accepts and a timestamp
8
+ * column takes it.
9
+ */
10
+ type DateValueType = "date" | "iso-date" | "iso-datetime"
11
+
12
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/
13
+
14
+ function pad(value: number, length: number): string {
15
+ return String(value).padStart(length, "0")
16
+ }
17
+
18
+ // `new Date("2026-10-09")` reads a date-only string as UTC midnight, which is
19
+ // the day before anywhere west of UTC. Read it as the local day instead.
20
+ function parseIsoDate(value: string): Date | undefined {
21
+ const match = ISO_DATE.exec(value)
22
+ if (!match) {
23
+ return
24
+ }
25
+ const [year, month, day] = match.slice(1).map(Number) as [
26
+ number,
27
+ number,
28
+ number,
29
+ ]
30
+ const date = new Date(year, month - 1, day)
31
+ // `new Date` reads the years 0 to 99 as 1900 to 1999.
32
+ date.setFullYear(year, month - 1, day)
33
+ // `2026-02-31` would roll over to March.
34
+ return date.getMonth() === month - 1 ? date : undefined
35
+ }
36
+
37
+ /**
38
+ * The day a stored value stands for, as a local `Date`, or `undefined` for an
39
+ * empty or unreadable one. Reads a `Date`, a `YYYY-MM-DD` string as that day
40
+ * in local time, and any other string `Date` parses, such as an ISO 8601
41
+ * timestamp.
42
+ */
43
+ function parseDateValue(value: unknown): Date | undefined {
44
+ let date: Date | undefined
45
+ if (value instanceof Date) {
46
+ date = value
47
+ } else if (typeof value === "string" && value !== "") {
48
+ date = ISO_DATE.test(value) ? parseIsoDate(value) : new Date(value)
49
+ }
50
+ return date && !Number.isNaN(date.getTime()) ? date : undefined
51
+ }
52
+
53
+ /** The day of a local `Date` as `YYYY-MM-DD`, without going through UTC. */
54
+ function formatIsoDate(date: Date): string {
55
+ return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1, 2)}-${pad(date.getDate(), 2)}`
56
+ }
57
+
58
+ /** The value to store for `date`, a day picked in local time. */
59
+ function toDateValue(date: Date, type: DateValueType): Date | string {
60
+ if (type === "iso-date") {
61
+ return formatIsoDate(date)
62
+ }
63
+ if (type === "iso-datetime") {
64
+ return date.toISOString()
65
+ }
66
+ return date
67
+ }
68
+
69
+ export type { DateValueType }
70
+ export { formatIsoDate, parseDateValue, toDateValue }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The props a component renders with: `props` over `defaults`. A prop passed
3
+ * as `undefined` keeps its default, as a destructuring default would.
4
+ */
5
+ export function withDefaultProps<P extends object>(
6
+ defaults: Partial<P> | undefined,
7
+ props: P
8
+ ): P {
9
+ if (defaults === undefined) {
10
+ return props
11
+ }
12
+ const merged: Record<string, unknown> = { ...defaults }
13
+ for (const [key, value] of Object.entries(props)) {
14
+ if (value !== undefined) {
15
+ merged[key] = value
16
+ }
17
+ }
18
+ return merged as P
19
+ }
@@ -69,4 +69,23 @@ describe("withEmptyStartValues", () => {
69
69
  ownerId: null,
70
70
  })
71
71
  })
72
+
73
+ test("starts an object the defaults only partly fill at its empty value, when it can be absent", () => {
74
+ const reference = z.object({ id: z.uuid(), table: z.string() })
75
+ const schema = z.object({
76
+ address: z.object({ city: z.string(), street: z.string() }).nullable(),
77
+ owner: reference,
78
+ payee: reference.optional(),
79
+ payer: reference.nullable(),
80
+ })
81
+ const start = withEmptyStartValues(schema, extractZodDefaults(schema))
82
+ expect(start).toEqual({
83
+ // Valid as filled, so its inputs stay controlled.
84
+ address: { city: "", street: "" },
85
+ // Required: its own fields still report what is missing.
86
+ owner: { id: "", table: "" },
87
+ payee: undefined,
88
+ payer: null,
89
+ })
90
+ })
72
91
  })
@@ -89,21 +89,65 @@ function isTypeEmpty(value: unknown): boolean {
89
89
  return value === "" || (Array.isArray(value) && value.length === 0)
90
90
  }
91
91
 
92
+ function isUnder(name: string, prefix: string): boolean {
93
+ return (
94
+ name === prefix ||
95
+ name.startsWith(`${prefix}.`) ||
96
+ name.startsWith(`${prefix}[`)
97
+ )
98
+ }
99
+
100
+ // The outermost object above `keys` that the defaults fill and the schema
101
+ // lets be absent, with its empty value (`null` or `undefined`).
102
+ function emptiableAncestor(
103
+ schema: StandardSchemaLike,
104
+ values: unknown,
105
+ keys: readonly PropertyKey[]
106
+ ): { keys: readonly PropertyKey[]; value: unknown } | null {
107
+ for (let length = 1; length < keys.length; length += 1) {
108
+ const prefix = keys.slice(0, length)
109
+ const start = valueAt(values, prefix)
110
+ if (typeof start !== "object" || start === null || Array.isArray(start)) {
111
+ continue
112
+ }
113
+ const empty = emptyValueFor(schema, values, toFieldName(prefix), start)
114
+ if (empty === null || empty === undefined) {
115
+ return { keys: prefix, value: empty }
116
+ }
117
+ }
118
+ return null
119
+ }
120
+
92
121
  // `extractZodDefaults` starts every string at `""` and every array at `[]`.
93
122
  // Where the schema rejects that (`z.uuid().nullable()`,
94
123
  // `z.email().optional()`), start the field at its empty value instead, so an
95
124
  // untouched optional field passes. A required one keeps `""` or `[]`.
125
+ //
126
+ // An object the defaults only partly fill (`{ table: "" }` for
127
+ // `z.object({ table: z.string(), id: z.number() }).nullable()`, a combobox's
128
+ // value) would block submit on a path no field shows: start it at its empty
129
+ // value too, when the schema lets it be absent.
96
130
  function withEmptyStartValues(
97
131
  schema: StandardSchemaLike,
98
132
  defaults: Record<string, unknown>
99
133
  ): Record<string, unknown> {
100
134
  const issues = validateSync(schema, defaults)
101
135
  let result: unknown = defaults
136
+ const emptied: string[] = []
102
137
  for (const issue of issues ?? []) {
103
138
  const name = toFieldName(issue.path)
104
139
  const keys = toPathKeys(name)
140
+ if (keys.length === 0 || emptied.some((prefix) => isUnder(name, prefix))) {
141
+ continue
142
+ }
143
+ const ancestor = emptiableAncestor(schema, result, keys)
144
+ if (ancestor) {
145
+ result = withValueAt(result, ancestor.keys, ancestor.value)
146
+ emptied.push(toFieldName(ancestor.keys))
147
+ continue
148
+ }
105
149
  const start = valueAt(defaults, keys)
106
- if (keys.length === 0 || !isTypeEmpty(start)) {
150
+ if (!isTypeEmpty(start)) {
107
151
  continue
108
152
  }
109
153
  const empty = emptyValueFor(schema, result, name, start)