@zeno-lib/forms 0.3.0 → 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 (40) hide show
  1. package/dist/create-zeno-form.mjs +5 -3
  2. package/dist/index.d.mts +2 -2
  3. package/dist/index.mjs +3 -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 +49 -21
  10. package/dist/lib/submit-action.mjs +1 -1
  11. package/dist/lib/use-is-invalid.d.mts +2 -1
  12. package/dist/lib/use-is-invalid.mjs +5 -2
  13. package/dist/lib/validation-modes.d.mts +5 -1
  14. package/dist/lib/validation-modes.mjs +9 -4
  15. package/package.json +1 -1
  16. package/src/create-zeno-form.tsx +13 -5
  17. package/src/fields/checkbox-field.tsx +8 -1
  18. package/src/fields/combobox-field.tsx +29 -12
  19. package/src/fields/date-picker-field.tsx +37 -9
  20. package/src/fields/email-field.tsx +8 -0
  21. package/src/fields/input-field.tsx +6 -2
  22. package/src/fields/money-field.tsx +3 -1
  23. package/src/fields/multi-select-field.tsx +8 -1
  24. package/src/fields/number-field.tsx +9 -7
  25. package/src/fields/otp-field.tsx +8 -1
  26. package/src/fields/percentage-field.tsx +3 -1
  27. package/src/fields/radio-group-field.tsx +11 -4
  28. package/src/fields/select-field.tsx +26 -3
  29. package/src/fields/switch-field.tsx +8 -1
  30. package/src/fields/textarea-field.tsx +8 -1
  31. package/src/fields/year-field.tsx +3 -1
  32. package/src/index.ts +1 -0
  33. package/src/lib/empty-value.test.ts +72 -0
  34. package/src/lib/empty-value.ts +130 -0
  35. package/src/lib/schema-defaults.ts +50 -1
  36. package/src/lib/schema-required.test.ts +24 -2
  37. package/src/lib/schema-required.ts +81 -43
  38. package/src/lib/use-is-invalid.ts +14 -1
  39. package/src/lib/validation-modes.ts +18 -6
  40. package/src/use-app-fields.test.tsx +312 -2
@@ -2,12 +2,14 @@
2
2
 
3
3
  import { describedBy } from "@zeno-lib/forms/lib/aria"
4
4
  import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
+ import { getEmptyValue } from "@zeno-lib/forms/lib/empty-value"
5
6
  import {
6
7
  useHideFieldErrors,
7
8
  useIsFieldRequired,
9
+ useIsFieldRequiredBySchema,
8
10
  useIsInvalid,
9
11
  } from "@zeno-lib/forms/lib/use-is-invalid"
10
- import { type ComponentProps, type ReactNode, useId } from "react"
12
+ import { type ComponentProps, type ReactNode, useId, useRef } from "react"
11
13
  import { buttonVariants } from "@/components/ui/button"
12
14
  import { Calendar } from "@/components/ui/calendar"
13
15
  import {
@@ -35,8 +37,15 @@ type DatePickerFieldProps = {
35
37
  formatValue?: (date: Date) => string
36
38
  triggerClassName?: string
37
39
  /** Pass-through to the underlying `<Calendar>` (e.g. `disabled`, `locale`). */
38
- calendarProps?: Omit<CalendarProps, "mode" | "onSelect" | "selected">
39
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
40
+ calendarProps?: Omit<
41
+ CalendarProps,
42
+ "mode" | "onSelect" | "required" | "selected"
43
+ >
44
+ disabled?: boolean
45
+ /**
46
+ * Mark the field required, or not, over the schema. A required date shows
47
+ * the `*`, and picking its selected day again keeps it instead of clearing.
48
+ */
40
49
  required?: boolean
41
50
  }
42
51
 
@@ -53,6 +62,7 @@ function defaultFormat(date: Date): string {
53
62
  function DatePickerField({
54
63
  calendarProps,
55
64
  description,
65
+ disabled,
56
66
  formatValue = defaultFormat,
57
67
  label,
58
68
  placeholder = FALLBACK_PLACEHOLDER,
@@ -60,6 +70,10 @@ function DatePickerField({
60
70
  triggerClassName,
61
71
  }: DatePickerFieldProps) {
62
72
  const field = useFieldContext<Date | undefined>()
73
+ // Opening the popover moves focus into it, so the trigger's blur would mark
74
+ // the field touched, and show a required error, before a day is picked.
75
+ // The field is touched once the popover closes, or focus leaves it closed.
76
+ const isOpen = useRef<boolean>(false)
63
77
  const id = useId()
64
78
  const errorId = `${id}-error`
65
79
  const descriptionId = `${id}-description`
@@ -68,6 +82,8 @@ function DatePickerField({
68
82
  const showError = isInvalid && !hideErrors
69
83
  const schemaRequired = useIsFieldRequired(field)
70
84
  const isRequired = required ?? schemaRequired
85
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
86
+ const requiresValue = required ?? schemaRequiresValue
71
87
 
72
88
  const { value } = field.state
73
89
  const empty = !value
@@ -80,7 +96,14 @@ function DatePickerField({
80
96
  {isRequired && <RequiredIndicator />}
81
97
  </FieldLabel>
82
98
  )}
83
- <Popover>
99
+ <Popover
100
+ onOpenChange={(open) => {
101
+ isOpen.current = open
102
+ if (!open) {
103
+ field.handleBlur()
104
+ }
105
+ }}
106
+ >
84
107
  <PopoverTrigger
85
108
  aria-describedby={describedBy(
86
109
  [description, descriptionId],
@@ -93,11 +116,13 @@ function DatePickerField({
93
116
  triggerClassName
94
117
  )}
95
118
  data-empty={empty || undefined}
119
+ disabled={disabled}
96
120
  id={id}
97
- // Base UI's Popover handles open state — we still need blur on the
98
- // form's reactive store. Passing onBlur here mirrors how `<select>`
99
- // commits on close.
100
- onBlur={field.handleBlur}
121
+ onBlur={() => {
122
+ if (!isOpen.current) {
123
+ field.handleBlur()
124
+ }
125
+ }}
101
126
  >
102
127
  <CalendarIcon />
103
128
  {empty ? <span>{placeholder}</span> : formatValue(value)}
@@ -105,7 +130,10 @@ function DatePickerField({
105
130
  <PopoverContent className="w-auto p-0">
106
131
  <Calendar
107
132
  mode="single"
108
- onSelect={(next) => field.handleChange(next)}
133
+ onSelect={(next: Date | undefined) =>
134
+ field.handleChange(next ?? getEmptyValue(field, undefined))
135
+ }
136
+ required={requiresValue}
109
137
  selected={value}
110
138
  {...calendarProps}
111
139
  />
@@ -2,16 +2,24 @@ import { InputField, type InputFieldProps } from "./input-field"
2
2
 
3
3
  type EmailFieldProps = Omit<InputFieldProps, "inputMode" | "type">
4
4
 
5
+ // An address is typed as is: no capital first letter, autocorrect or spell
6
+ // check.
5
7
  function EmailField({
8
+ autoCapitalize = "none",
6
9
  autoComplete = "email",
10
+ autoCorrect = "off",
7
11
  label = "Email",
12
+ spellCheck = false,
8
13
  ...props
9
14
  }: EmailFieldProps) {
10
15
  return (
11
16
  <InputField
17
+ autoCapitalize={autoCapitalize}
12
18
  autoComplete={autoComplete}
19
+ autoCorrect={autoCorrect}
13
20
  inputMode="email"
14
21
  label={label}
22
+ spellCheck={spellCheck}
15
23
  type="email"
16
24
  {...props}
17
25
  />
@@ -5,6 +5,7 @@ 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
11
  import { Children, type ComponentProps, type ReactNode, useId } from "react"
@@ -26,8 +27,8 @@ type InputFieldProps = Omit<
26
27
  description?: ReactNode
27
28
  label?: ReactNode
28
29
  /**
29
- * Force the required `*` indicator on or off for this field. When omitted,
30
- * the value is derived from the form's schema.
30
+ * Mark the field required, or not, over the schema. Drives the `*` and
31
+ * `aria-required`.
31
32
  */
32
33
  required?: boolean
33
34
  }
@@ -49,6 +50,8 @@ function InputField({
49
50
  const hasAddons = Children.count(children) > 0
50
51
  const schemaRequired = useIsFieldRequired(field)
51
52
  const isRequired = required ?? schemaRequired
53
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
54
+ const requiresValue = required ?? schemaRequiresValue
52
55
 
53
56
  const inputProps = {
54
57
  "aria-describedby": describedBy(
@@ -56,6 +59,7 @@ function InputField({
56
59
  [showError, errorId]
57
60
  ),
58
61
  "aria-invalid": isInvalid || undefined,
62
+ "aria-required": requiresValue || undefined,
59
63
  id,
60
64
  name: field.name,
61
65
  onBlur: field.handleBlur,
@@ -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 { type ReactNode, useMemo } from "react"
6
7
  import { InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
@@ -95,7 +96,8 @@ function MoneyField({
95
96
  maximumFractionDigits: digits,
96
97
  min,
97
98
  onBlur: field.handleBlur,
98
- onValueChange: (next) => field.handleChange(next),
99
+ onValueChange: (next) =>
100
+ field.handleChange(next ?? getEmptyValue(field, null)),
99
101
  padFraction: true,
100
102
  value: field.state.value,
101
103
  })
@@ -5,6 +5,7 @@ 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
11
  import { XIcon } from "lucide-react"
@@ -56,7 +57,10 @@ type MultiSelectFieldProps<T = string> = {
56
57
  clearLabel?: string
57
58
  disabled?: boolean
58
59
  className?: string
59
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
60
+ /**
61
+ * Mark the field required, or not, over the schema. Drives the `*` and
62
+ * `aria-required`.
63
+ */
60
64
  required?: boolean
61
65
  }
62
66
 
@@ -104,6 +108,8 @@ function MultiSelectField<T = string>({
104
108
  const showError = isInvalid && !hideErrors
105
109
  const schemaRequired = useIsFieldRequired(field)
106
110
  const isRequired = required ?? schemaRequired
111
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
112
+ const requiresValue = required ?? schemaRequiresValue
107
113
 
108
114
  const values = field.state.value ?? []
109
115
  // Map stored values back to item identities, in selection order, so Base UI
@@ -144,6 +150,7 @@ function MultiSelectField<T = string>({
144
150
  [showError, errorId]
145
151
  )}
146
152
  aria-invalid={isInvalid || undefined}
153
+ aria-required={requiresValue || undefined}
147
154
  id={id}
148
155
  name={field.name}
149
156
  onBlur={field.handleBlur}
@@ -1,15 +1,15 @@
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 type { ChangeEvent } from "react"
5
6
  import { InputField, type InputFieldProps } from "./input-field"
6
7
 
7
- type NumberFieldProps = Omit<
8
- InputFieldProps,
9
- "inputMode" | "onChange" | "type" | "value"
10
- >
8
+ type NumberFieldProps = Omit<InputFieldProps, "onChange" | "type" | "value">
11
9
 
12
- function NumberField(props: NumberFieldProps) {
10
+ // `decimal` keeps the decimal key on phone keypads; pass `numeric` for whole
11
+ // numbers.
12
+ function NumberField({ inputMode = "decimal", ...props }: NumberFieldProps) {
13
13
  const field = useFieldContext<number | undefined>()
14
14
  const { value } = field.state
15
15
  const inputValue =
@@ -18,10 +18,12 @@ function NumberField(props: NumberFieldProps) {
18
18
  return (
19
19
  <InputField
20
20
  {...props}
21
- inputMode="numeric"
21
+ inputMode={inputMode}
22
22
  onChange={(event: ChangeEvent<HTMLInputElement>) => {
23
23
  const next = event.target.valueAsNumber
24
- field.handleChange(Number.isNaN(next) ? undefined : next)
24
+ field.handleChange(
25
+ Number.isNaN(next) ? getEmptyValue(field, undefined) : next
26
+ )
25
27
  }}
26
28
  type="number"
27
29
  value={inputValue}
@@ -5,6 +5,7 @@ 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
11
  import { type ReactNode, useId } from "react"
@@ -40,7 +41,10 @@ type OtpFieldProps = {
40
41
  * you need a separator between segments.
41
42
  */
42
43
  children?: ReactNode
43
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
44
+ /**
45
+ * Mark the field required, or not, over the schema. Drives the `*` and
46
+ * `aria-required`.
47
+ */
44
48
  required?: boolean
45
49
  }
46
50
 
@@ -65,6 +69,8 @@ function OtpField({
65
69
  const showError = isInvalid && !hideErrors
66
70
  const schemaRequired = useIsFieldRequired(field)
67
71
  const isRequired = required ?? schemaRequired
72
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
73
+ const requiresValue = required ?? schemaRequiresValue
68
74
 
69
75
  return (
70
76
  <Field data-field={field.name} data-invalid={isInvalid}>
@@ -80,6 +86,7 @@ function OtpField({
80
86
  [showError, errorId]
81
87
  )}
82
88
  aria-invalid={isInvalid || undefined}
89
+ aria-required={requiresValue || undefined}
83
90
  autoFocus={autoFocus}
84
91
  className={className}
85
92
  containerClassName={containerClassName}
@@ -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 { InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
6
7
  import { InputField, type InputFieldProps } from "./input-field"
@@ -54,7 +55,8 @@ function PercentageField({
54
55
  maximumFractionDigits: fractionDigits,
55
56
  min,
56
57
  onBlur: field.handleBlur,
57
- onValueChange: (next) => field.handleChange(next),
58
+ onValueChange: (next) =>
59
+ field.handleChange(next ?? getEmptyValue(field, null)),
58
60
  value: field.state.value,
59
61
  ...(isFraction ? { fromDisplay: fromPercent, toDisplay: toPercent } : {}),
60
62
  })
@@ -5,6 +5,7 @@ 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
11
  import { type ComponentProps, type ReactNode, useId } from "react"
@@ -24,7 +25,10 @@ type RadioGroupFieldProps = 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,7 +39,7 @@ function RadioGroupField({
35
39
  required,
36
40
  ...props
37
41
  }: RadioGroupFieldProps) {
38
- const field = useFieldContext<string>()
42
+ const field = useFieldContext()
39
43
  const id = useId()
40
44
  const errorId = `${id}-error`
41
45
  const descriptionId = `${id}-description`
@@ -44,6 +48,8 @@ function RadioGroupField({
44
48
  const showError = isInvalid && !hideErrors
45
49
  const schemaRequired = useIsFieldRequired(field)
46
50
  const isRequired = required ?? schemaRequired
51
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
52
+ const requiresValue = required ?? schemaRequiresValue
47
53
 
48
54
  return (
49
55
  <Field data-field={field.name} data-invalid={isInvalid}>
@@ -59,9 +65,10 @@ function RadioGroupField({
59
65
  [showError, errorId]
60
66
  )}
61
67
  aria-invalid={isInvalid || undefined}
68
+ aria-required={requiresValue || undefined}
62
69
  name={field.name}
63
- onValueChange={(value) => field.handleChange(String(value))}
64
- value={field.state.value ?? ""}
70
+ onValueChange={(value) => field.handleChange(value)}
71
+ value={field.state.value ?? null}
65
72
  {...props}
66
73
  >
67
74
  {children}
@@ -5,10 +5,11 @@ 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
11
  import type { ComponentProps, ReactNode } from "react"
11
- import { Children, isValidElement, useId, useMemo } from "react"
12
+ import { Children, isValidElement, useId, useMemo, useRef } from "react"
12
13
  import {
13
14
  Field,
14
15
  FieldDescription,
@@ -31,7 +32,10 @@ type SelectFieldProps = Omit<
31
32
  description?: ReactNode
32
33
  label?: ReactNode
33
34
  placeholder?: string
34
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
35
+ /**
36
+ * Mark the field required, or not, over the schema. Drives the `*` and
37
+ * `aria-required`.
38
+ */
35
39
  required?: boolean
36
40
  triggerClassName?: string
37
41
  triggerSize?: ComponentProps<typeof SelectTrigger>["size"]
@@ -41,6 +45,7 @@ function SelectField({
41
45
  children,
42
46
  description,
43
47
  label,
48
+ onOpenChange,
44
49
  placeholder,
45
50
  required,
46
51
  triggerClassName,
@@ -48,6 +53,10 @@ function SelectField({
48
53
  ...props
49
54
  }: SelectFieldProps) {
50
55
  const field = useFieldContext()
56
+ // Opening the popup moves focus into it, so the trigger's blur would mark
57
+ // the field touched, and show a required error, before anything is picked.
58
+ // The field is touched once the popup closes, or focus leaves it closed.
59
+ const isOpen = useRef<boolean>(false)
51
60
  const id = useId()
52
61
  const errorId = `${id}-error`
53
62
  const descriptionId = `${id}-description`
@@ -56,6 +65,8 @@ function SelectField({
56
65
  const showError = isInvalid && !hideErrors
57
66
  const schemaRequired = useIsFieldRequired(field)
58
67
  const isRequired = required ?? schemaRequired
68
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
69
+ const requiresValue = required ?? schemaRequiresValue
59
70
 
60
71
  const items = useMemo(() => {
61
72
  const map: Record<string, ReactNode> = {}
@@ -81,6 +92,13 @@ function SelectField({
81
92
  <Select
82
93
  items={items}
83
94
  name={field.name}
95
+ onOpenChange={(open, eventDetails) => {
96
+ isOpen.current = open
97
+ if (!open) {
98
+ field.handleBlur()
99
+ }
100
+ onOpenChange?.(open, eventDetails)
101
+ }}
84
102
  onValueChange={(value) => field.handleChange(value)}
85
103
  value={field.state.value ?? null}
86
104
  {...props}
@@ -91,9 +109,14 @@ function SelectField({
91
109
  [showError, errorId]
92
110
  )}
93
111
  aria-invalid={isInvalid || undefined}
112
+ aria-required={requiresValue || undefined}
94
113
  className={triggerClassName}
95
114
  id={id}
96
- onBlur={field.handleBlur}
115
+ onBlur={() => {
116
+ if (!isOpen.current) {
117
+ field.handleBlur()
118
+ }
119
+ }}
97
120
  size={triggerSize}
98
121
  >
99
122
  <SelectValue placeholder={placeholder} />
@@ -5,6 +5,7 @@ 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
11
  import { type ComponentProps, type ReactNode, useId } from "react"
@@ -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
 
@@ -43,6 +47,8 @@ function SwitchField({
43
47
  const showError = isInvalid && !hideErrors
44
48
  const schemaRequired = useIsFieldRequired(field)
45
49
  const isRequired = required ?? schemaRequired
50
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
51
+ const requiresValue = required ?? schemaRequiresValue
46
52
 
47
53
  return (
48
54
  <Field
@@ -70,6 +76,7 @@ function SwitchField({
70
76
  [showError, errorId]
71
77
  )}
72
78
  aria-invalid={isInvalid || undefined}
79
+ aria-required={requiresValue || undefined}
73
80
  checked={field.state.value ?? false}
74
81
  id={id}
75
82
  name={field.name}
@@ -5,6 +5,7 @@ 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
11
  import { Children, type ComponentProps, type ReactNode, useId } from "react"
@@ -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
 
@@ -46,6 +50,8 @@ function TextAreaField({
46
50
  const hasAddons = Children.count(children) > 0
47
51
  const schemaRequired = useIsFieldRequired(field)
48
52
  const isRequired = required ?? schemaRequired
53
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
54
+ const requiresValue = required ?? schemaRequiresValue
49
55
 
50
56
  const textareaProps = {
51
57
  "aria-describedby": describedBy(
@@ -53,6 +59,7 @@ function TextAreaField({
53
59
  [showError, errorId]
54
60
  ),
55
61
  "aria-invalid": isInvalid || undefined,
62
+ "aria-required": requiresValue || undefined,
56
63
  id,
57
64
  name: field.name,
58
65
  onBlur: field.handleBlur,
@@ -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
@@ -21,6 +21,7 @@ export {
21
21
  export {
22
22
  useHideFieldErrors,
23
23
  useIsFieldRequired,
24
+ useIsFieldRequiredBySchema,
24
25
  useIsInvalid,
25
26
  } from "./lib/use-is-invalid"
26
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
+ })