@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
@@ -7,7 +7,7 @@ import {
7
7
  useIsFieldRequired,
8
8
  useIsInvalid,
9
9
  } from "@zeno-lib/forms/lib/use-is-invalid"
10
- import type { ComponentProps, ReactNode } from "react"
10
+ import { type ComponentProps, type ReactNode, useId } from "react"
11
11
  import {
12
12
  Field,
13
13
  FieldContent,
@@ -43,8 +43,9 @@ function SliderField({
43
43
  ...props
44
44
  }: SliderFieldProps) {
45
45
  const field = useFieldContext<SliderValue>()
46
- const errorId = `${field.name}-error`
47
- const descriptionId = `${field.name}-description`
46
+ const id = useId()
47
+ const errorId = `${id}-error`
48
+ const descriptionId = `${id}-description`
48
49
  const isInvalid = useIsInvalid(field)
49
50
  const hideErrors = useHideFieldErrors(field)
50
51
  const showError = isInvalid && !hideErrors
@@ -61,7 +62,7 @@ function SliderField({
61
62
  {(label || hasReadout) && (
62
63
  <FieldContent className="flex-row items-center justify-between">
63
64
  {label && (
64
- <FieldLabel htmlFor={field.name}>
65
+ <FieldLabel htmlFor={id}>
65
66
  {label}
66
67
  {isRequired && <RequiredIndicator />}
67
68
  </FieldLabel>
@@ -79,7 +80,7 @@ function SliderField({
79
80
  [showError, errorId]
80
81
  )}
81
82
  aria-invalid={isInvalid || undefined}
82
- id={field.name}
83
+ id={id}
83
84
  name={field.name}
84
85
  onBlur={field.handleBlur}
85
86
  onValueChange={(next) => {
@@ -5,9 +5,10 @@ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
5
  import {
6
6
  useHideFieldErrors,
7
7
  useIsFieldRequired,
8
+ useIsFieldRequiredBySchema,
8
9
  useIsInvalid,
9
10
  } from "@zeno-lib/forms/lib/use-is-invalid"
10
- import type { ComponentProps, ReactNode } from "react"
11
+ import { type ComponentProps, type ReactNode, useId } from "react"
11
12
  import {
12
13
  Field,
13
14
  FieldContent,
@@ -24,7 +25,10 @@ type SwitchFieldProps = Omit<
24
25
  > & {
25
26
  description?: ReactNode
26
27
  label?: ReactNode
27
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
28
+ /**
29
+ * Mark the field required, or not, over the schema. Drives the `*` and
30
+ * `aria-required`.
31
+ */
28
32
  required?: boolean
29
33
  }
30
34
 
@@ -35,13 +39,16 @@ function SwitchField({
35
39
  ...props
36
40
  }: SwitchFieldProps) {
37
41
  const field = useFieldContext<boolean>()
38
- const errorId = `${field.name}-error`
39
- const descriptionId = `${field.name}-description`
42
+ const id = useId()
43
+ const errorId = `${id}-error`
44
+ const descriptionId = `${id}-description`
40
45
  const isInvalid = useIsInvalid(field)
41
46
  const hideErrors = useHideFieldErrors(field)
42
47
  const showError = isInvalid && !hideErrors
43
48
  const schemaRequired = useIsFieldRequired(field)
44
49
  const isRequired = required ?? schemaRequired
50
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
51
+ const requiresValue = required ?? schemaRequiresValue
45
52
 
46
53
  return (
47
54
  <Field
@@ -51,7 +58,7 @@ function SwitchField({
51
58
  >
52
59
  <FieldContent>
53
60
  {label && (
54
- <FieldLabel htmlFor={field.name}>
61
+ <FieldLabel htmlFor={id}>
55
62
  {label}
56
63
  {isRequired && <RequiredIndicator />}
57
64
  </FieldLabel>
@@ -69,8 +76,9 @@ function SwitchField({
69
76
  [showError, errorId]
70
77
  )}
71
78
  aria-invalid={isInvalid || undefined}
79
+ aria-required={requiresValue || undefined}
72
80
  checked={field.state.value ?? false}
73
- id={field.name}
81
+ id={id}
74
82
  name={field.name}
75
83
  onBlur={field.handleBlur}
76
84
  onCheckedChange={(value) => field.handleChange(value)}
@@ -5,9 +5,10 @@ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
5
  import {
6
6
  useHideFieldErrors,
7
7
  useIsFieldRequired,
8
+ useIsFieldRequiredBySchema,
8
9
  useIsInvalid,
9
10
  } from "@zeno-lib/forms/lib/use-is-invalid"
10
- import { Children, type ComponentProps, type ReactNode } from "react"
11
+ import { Children, type ComponentProps, type ReactNode, useId } from "react"
11
12
  import {
12
13
  Field,
13
14
  FieldDescription,
@@ -25,7 +26,10 @@ type TextAreaFieldProps = Omit<
25
26
  children?: ReactNode
26
27
  description?: ReactNode
27
28
  label?: ReactNode
28
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
29
+ /**
30
+ * Mark the field required, or not, over the schema. Drives the `*` and
31
+ * `aria-required`.
32
+ */
29
33
  required?: boolean
30
34
  }
31
35
 
@@ -37,14 +41,17 @@ function TextAreaField({
37
41
  ...props
38
42
  }: TextAreaFieldProps) {
39
43
  const field = useFieldContext<string>()
40
- const errorId = `${field.name}-error`
41
- const descriptionId = `${field.name}-description`
44
+ const id = useId()
45
+ const errorId = `${id}-error`
46
+ const descriptionId = `${id}-description`
42
47
  const isInvalid = useIsInvalid(field)
43
48
  const hideErrors = useHideFieldErrors(field)
44
49
  const showError = isInvalid && !hideErrors
45
50
  const hasAddons = Children.count(children) > 0
46
51
  const schemaRequired = useIsFieldRequired(field)
47
52
  const isRequired = required ?? schemaRequired
53
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
54
+ const requiresValue = required ?? schemaRequiresValue
48
55
 
49
56
  const textareaProps = {
50
57
  "aria-describedby": describedBy(
@@ -52,7 +59,8 @@ function TextAreaField({
52
59
  [showError, errorId]
53
60
  ),
54
61
  "aria-invalid": isInvalid || undefined,
55
- id: field.name,
62
+ "aria-required": requiresValue || undefined,
63
+ id,
56
64
  name: field.name,
57
65
  onBlur: field.handleBlur,
58
66
  onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) =>
@@ -64,7 +72,7 @@ function TextAreaField({
64
72
  return (
65
73
  <Field data-field={field.name} data-invalid={isInvalid}>
66
74
  {label && (
67
- <FieldLabel htmlFor={field.name}>
75
+ <FieldLabel htmlFor={id}>
68
76
  {label}
69
77
  {isRequired && <RequiredIndicator />}
70
78
  </FieldLabel>
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
4
+ import { getEmptyValue } from "@zeno-lib/forms/lib/empty-value"
4
5
  import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
5
6
  import { InputField, type InputFieldProps } from "./input-field"
6
7
 
@@ -36,7 +37,8 @@ function YearField({ max, min, ...props }: YearFieldProps) {
36
37
  maximumFractionDigits: 0,
37
38
  min,
38
39
  onBlur: field.handleBlur,
39
- onValueChange: (next) => field.handleChange(next),
40
+ onValueChange: (next) =>
41
+ field.handleChange(next ?? getEmptyValue(field, null)),
40
42
  useGrouping: false,
41
43
  value: field.state.value,
42
44
  })
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /* biome-ignore-all lint/performance/noBarrelFile: single npm entry for the headless form API. */
2
2
 
3
+ export { useSelector } from "@tanstack/react-form"
3
4
  export { createZenoForm } from "./create-zeno-form"
4
5
  export { Form, FormProvider } from "./form-element"
5
6
  export {
@@ -20,6 +21,7 @@ export {
20
21
  export {
21
22
  useHideFieldErrors,
22
23
  useIsFieldRequired,
24
+ useIsFieldRequiredBySchema,
23
25
  useIsInvalid,
24
26
  } from "./lib/use-is-invalid"
25
27
  export { type FieldMessage, ValidationError } from "./lib/validation-error"
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from "vitest"
2
+ import { z } from "zod"
3
+
4
+ import { emptyValueFor, withEmptyStartValues } from "./empty-value"
5
+ import { extractZodDefaults } from "./schema-defaults"
6
+
7
+ const FALLBACK = Symbol("fallback")
8
+
9
+ function cleared(field: z.ZodType, name = "field", values: unknown = {}) {
10
+ return emptyValueFor(z.object({ field }), values, name, FALLBACK)
11
+ }
12
+
13
+ describe("emptyValueFor", () => {
14
+ test("null, then undefined, then the type's own empty value", () => {
15
+ expect(cleared(z.string().nullable())).toBeNull()
16
+ expect(cleared(z.string().optional())).toBeUndefined()
17
+ expect(cleared(z.string())).toBe("")
18
+ expect(cleared(z.array(z.string()))).toEqual([])
19
+ expect(cleared(z.uuid().nullable())).toBeNull()
20
+ expect(cleared(z.enum(["a", "b"]).optional())).toBeUndefined()
21
+ expect(cleared(z.number().nullable())).toBeNull()
22
+ expect(cleared(z.date().nullish())).toBeNull()
23
+ })
24
+
25
+ test("a field that accepts no empty value keeps the field's own fallback", () => {
26
+ expect(cleared(z.string().min(1))).toBe(FALLBACK)
27
+ expect(cleared(z.number())).toBe(FALLBACK)
28
+ })
29
+
30
+ test("array rows follow the row schema", () => {
31
+ const schema = z.object({
32
+ members: z.array(
33
+ z.object({
34
+ id: z.uuid().nullable(),
35
+ name: z.string(),
36
+ note: z.string().optional(),
37
+ })
38
+ ),
39
+ })
40
+ const values = { members: [{ id: "x" }, { id: "y" }] }
41
+ const at = (name: string) => emptyValueFor(schema, values, name, FALLBACK)
42
+ expect(at("members[1].id")).toBeNull()
43
+ expect(at("members[1].name")).toBe("")
44
+ expect(at("members[1].note")).toBeUndefined()
45
+ })
46
+
47
+ test("without a schema, or with an async one, the fallback stands", () => {
48
+ expect(emptyValueFor(undefined, {}, "field", FALLBACK)).toBe(FALLBACK)
49
+ const asyncSchema = z.object({
50
+ field: z.string().refine(async () => true),
51
+ })
52
+ expect(emptyValueFor(asyncSchema, {}, "field", FALLBACK)).toBe(FALLBACK)
53
+ })
54
+ })
55
+
56
+ describe("withEmptyStartValues", () => {
57
+ test('starts a field whose schema rejects `""` at its empty value', () => {
58
+ const schema = z.object({
59
+ email: z.email().optional(),
60
+ name: z.string().min(1),
61
+ note: z.string(),
62
+ ownerId: z.uuid().nullable(),
63
+ })
64
+ const start = withEmptyStartValues(schema, extractZodDefaults(schema))
65
+ expect(start).toEqual({
66
+ email: undefined,
67
+ name: "",
68
+ note: "",
69
+ ownerId: null,
70
+ })
71
+ })
72
+ })
@@ -0,0 +1,130 @@
1
+ import type { AnyFieldApi } from "@tanstack/react-form"
2
+
3
+ import { toFieldName } from "./action-result"
4
+ import { extractZodDefaultAt } from "./schema-defaults"
5
+ import { type StandardSchemaLike, validateSync } from "./schema-required"
6
+ import { getFormSchema } from "./validation-modes"
7
+
8
+ // A field's empty value is the first one its schema accepts of `null`,
9
+ // `undefined` and the type's own empty value (`""` for a string, `[]` for an
10
+ // array, through `extractZodDefaults`). So `.nullable()` empties to `null`,
11
+ // `.optional()` to `undefined`, and a bare `z.string()` to `""`. A field that
12
+ // accepts none of them is required. A cleared field holds its empty value,
13
+ // and so does an untouched one whose schema rejects `""` or `[]`.
14
+
15
+ const NAME_SEGMENT = /[^.[\]]+/g
16
+ const INDEX = /^\d+$/
17
+
18
+ // `members[2].name` (or `members.2.name`) → `["members", 2, "name"]`.
19
+ function toPathKeys(name: string): PropertyKey[] {
20
+ return (name.match(NAME_SEGMENT) ?? []).map((part) =>
21
+ INDEX.test(part) ? Number(part) : part
22
+ )
23
+ }
24
+
25
+ function valueAt(root: unknown, keys: readonly PropertyKey[]): unknown {
26
+ let node = root
27
+ for (const key of keys) {
28
+ if (typeof node !== "object" || node === null) {
29
+ return
30
+ }
31
+ node = (node as Record<PropertyKey, unknown>)[key]
32
+ }
33
+ return node
34
+ }
35
+
36
+ // A copy of `root` with `value` at `keys`, copying only the nodes on the way.
37
+ function withValueAt(
38
+ root: unknown,
39
+ keys: readonly PropertyKey[],
40
+ value: unknown
41
+ ): unknown {
42
+ const [key, ...rest] = keys
43
+ if (key === undefined) {
44
+ return value
45
+ }
46
+ const node = (root ?? (typeof key === "number" ? [] : {})) as Record<
47
+ PropertyKey,
48
+ unknown
49
+ >
50
+ const copy = (Array.isArray(node) ? [...node] : { ...node }) as Record<
51
+ PropertyKey,
52
+ unknown
53
+ >
54
+ copy[key] = withValueAt(node[key], rest, value)
55
+ return copy
56
+ }
57
+
58
+ function emptyValueFor(
59
+ schema: StandardSchemaLike | undefined,
60
+ values: unknown,
61
+ name: string,
62
+ fallback: unknown
63
+ ): unknown {
64
+ if (!schema) {
65
+ return fallback
66
+ }
67
+ const keys = toPathKeys(name)
68
+ const fieldName = toFieldName(keys)
69
+ const typeEmpty = extractZodDefaultAt(
70
+ schema as Parameters<typeof extractZodDefaultAt>[0],
71
+ keys
72
+ )
73
+ const candidates = typeEmpty.ok
74
+ ? [null, undefined, typeEmpty.value]
75
+ : [null, undefined]
76
+ for (const candidate of candidates) {
77
+ const issues = validateSync(schema, withValueAt(values, keys, candidate))
78
+ if (!issues) {
79
+ return fallback
80
+ }
81
+ if (!issues.some((issue) => toFieldName(issue.path) === fieldName)) {
82
+ return candidate
83
+ }
84
+ }
85
+ return fallback
86
+ }
87
+
88
+ function isTypeEmpty(value: unknown): boolean {
89
+ return value === "" || (Array.isArray(value) && value.length === 0)
90
+ }
91
+
92
+ // `extractZodDefaults` starts every string at `""` and every array at `[]`.
93
+ // Where the schema rejects that (`z.uuid().nullable()`,
94
+ // `z.email().optional()`), start the field at its empty value instead, so an
95
+ // untouched optional field passes. A required one keeps `""` or `[]`.
96
+ function withEmptyStartValues(
97
+ schema: StandardSchemaLike,
98
+ defaults: Record<string, unknown>
99
+ ): Record<string, unknown> {
100
+ const issues = validateSync(schema, defaults)
101
+ let result: unknown = defaults
102
+ for (const issue of issues ?? []) {
103
+ const name = toFieldName(issue.path)
104
+ const keys = toPathKeys(name)
105
+ const start = valueAt(defaults, keys)
106
+ if (keys.length === 0 || !isTypeEmpty(start)) {
107
+ continue
108
+ }
109
+ const empty = emptyValueFor(schema, result, name, start)
110
+ if (empty !== start) {
111
+ result = withValueAt(result, keys, empty)
112
+ }
113
+ }
114
+ return result as Record<string, unknown>
115
+ }
116
+
117
+ /**
118
+ * The value `field` takes when the user clears it, by the rule above. Typed as
119
+ * the field's value, since the same schema decides both.
120
+ */
121
+ function getEmptyValue<T>(field: AnyFieldApi, fallback: T): T {
122
+ return emptyValueFor(
123
+ getFormSchema(field.form),
124
+ field.form.state.values,
125
+ field.name,
126
+ fallback
127
+ ) as T
128
+ }
129
+
130
+ export { emptyValueFor, getEmptyValue, withEmptyStartValues }
@@ -24,6 +24,7 @@ type SchemaDef = {
24
24
  readonly innerType?: SchemaNode
25
25
  readonly shape?: Record<string, SchemaNode>
26
26
  readonly in?: SchemaNode
27
+ readonly element?: SchemaNode
27
28
  readonly defaultValue?: unknown
28
29
  }
29
30
 
@@ -91,6 +92,54 @@ function extractZodDefaults(schema: SchemaNode): Record<string, unknown> {
91
92
  }
92
93
  }
93
94
 
95
+ // Step from an object or array schema into its `key` child, through any
96
+ // wrapper around it. `null` when there is none.
97
+ function childAt(node: SchemaNode, key: PropertyKey): SchemaNode | null {
98
+ let current: SchemaNode | undefined = node
99
+ for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) {
100
+ const def: SchemaDef | undefined = getDef(current)
101
+ switch (def?.type) {
102
+ case "object":
103
+ return (typeof key === "string" && def.shape?.[key]) || null
104
+ case "array":
105
+ return (typeof key === "number" && def.element) || null
106
+ case "default":
107
+ case "nonoptional":
108
+ case "nullable":
109
+ case "optional":
110
+ case "prefault":
111
+ case "readonly":
112
+ current = def.innerType
113
+ continue
114
+ case "pipe":
115
+ current = def.in
116
+ continue
117
+ default:
118
+ return null
119
+ }
120
+ }
121
+ return null
122
+ }
123
+
124
+ // The value `extractZodDefaults` starts the field at `keys` at, array rows
125
+ // included (`["members", 2, "name"]`). `{ ok: false }` when it gives none.
126
+ function extractZodDefaultAt(
127
+ schema: SchemaNode,
128
+ keys: readonly PropertyKey[]
129
+ ): ExtractResult {
130
+ try {
131
+ let node: SchemaNode | null = schema
132
+ for (const key of keys) {
133
+ node = node && childAt(node, key)
134
+ }
135
+ return node && keys.length > 0
136
+ ? extractDefaultForField(node)
137
+ : { ok: false }
138
+ } catch {
139
+ return { ok: false }
140
+ }
141
+ }
142
+
94
143
  function isPlainObject(value: unknown): value is Record<string, unknown> {
95
144
  return (
96
145
  typeof value === "object" &&
@@ -124,4 +173,4 @@ function deepMergeDefaults(
124
173
  return result
125
174
  }
126
175
 
127
- export { deepMergeDefaults, extractZodDefaults }
176
+ export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults }
@@ -38,6 +38,42 @@ describe("getRequiredPaths", () => {
38
38
  expect(required.has("role")).toBe(false)
39
39
  })
40
40
 
41
+ test("a string that accepts the empty value it starts at is not required", () => {
42
+ const schema = z.object({
43
+ email: z.email(),
44
+ members: z.array(z.object({ name: z.string().min(1), note: z.string() })),
45
+ name: z.string().trim().min(1),
46
+ notes: z.string(),
47
+ profile: z.object({ bio: z.string(), title: z.string().min(1) }),
48
+ subtitle: z.string().nullable(),
49
+ })
50
+ const required = getRequiredPaths(schema)
51
+ expect(required.has("notes")).toBe(false)
52
+ expect(required.has("subtitle")).toBe(false)
53
+ expect(required.has("profile.bio")).toBe(false)
54
+ expect(required.has("members[0].note")).toBe(false)
55
+ expect(required.has("email")).toBe(true)
56
+ expect(required.has("name")).toBe(true)
57
+ expect(required.has("profile.title")).toBe(true)
58
+ expect(required.has("members[0].name")).toBe(true)
59
+ })
60
+
61
+ test("a field that accepts null is not required", () => {
62
+ const schema = z.object({
63
+ count: z.number(),
64
+ day: z.date().nullable(),
65
+ kind: z.enum(["a", "b"]).nullable(),
66
+ ownerId: z.uuid().nullable(),
67
+ total: z.number().nullable(),
68
+ })
69
+ const required = getRequiredPaths(schema)
70
+ expect(required.has("count")).toBe(true)
71
+ expect(required.has("day")).toBe(false)
72
+ expect(required.has("kind")).toBe(false)
73
+ expect(required.has("ownerId")).toBe(false)
74
+ expect(required.has("total")).toBe(false)
75
+ })
76
+
41
77
  test("nested z.object reports the parent and its required children", () => {
42
78
  const schema = z.object({
43
79
  profile: z.object({
@@ -60,26 +96,32 @@ describe("getRequiredPaths", () => {
60
96
 
61
97
  test("array rows are reported with TanStack bracket syntax", () => {
62
98
  const schema = z.object({
99
+ emails: z.array(z.email()),
63
100
  members: z.array(
64
101
  z.object({
65
102
  name: z.string().min(1),
66
103
  note: z.string().optional(),
67
104
  })
68
105
  ),
106
+ picked: z.array(z.string()).min(1),
69
107
  tags: z.array(z.string()),
70
108
  })
71
109
  const required = getRequiredPaths(schema)
72
- expect(required.has("members")).toBe(true)
73
110
  expect(required.has("members[0].name")).toBe(true)
74
111
  expect(required.has("members.0.name")).toBe(false)
75
112
  expect(required.has("members[0].note")).toBe(false)
76
- expect(required.has("tags[0]")).toBe(true)
113
+ expect(required.has("emails[0]")).toBe(true)
114
+ expect(required.has("picked")).toBe(true)
115
+ // `[]` and `""` are what the form starts them at, and both pass.
116
+ expect(required.has("members")).toBe(false)
117
+ expect(required.has("tags")).toBe(false)
118
+ expect(required.has("tags[0]")).toBe(false)
77
119
  })
78
120
 
79
121
  test("nested arrays descend through every level", () => {
80
122
  const schema = z.object({
81
123
  groups: z.array(
82
- z.object({ members: z.array(z.object({ name: z.string() })) })
124
+ z.object({ members: z.array(z.object({ name: z.string().min(1) })) })
83
125
  ),
84
126
  })
85
127
  const required = getRequiredPaths(schema)