@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
@@ -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 { ReactNode } from "react"
12
+ import { type ReactNode, useId } from "react"
11
13
  import {
12
14
  Combobox,
13
15
  ComboboxContent,
@@ -43,13 +45,17 @@ type ComboboxFieldProps<T = string> = {
43
45
  /** Override per-row rendering. Default: plain `<ComboboxItem>{label}</ComboboxItem>`. */
44
46
  renderItem?: (item: T) => ReactNode
45
47
  /**
46
- * If `true`, show the clear button when a value is selected. Defaults to
47
- * `true` — pass `false` for required-style comboboxes where clearing is
48
- * disallowed.
48
+ * Show the clear button when a value is selected. Defaults to `true` on an
49
+ * optional field and `false` on a required one (from the schema or
50
+ * `required`), where clearing could only make the form invalid.
49
51
  */
50
52
  showClear?: boolean
53
+ disabled?: boolean
51
54
  className?: string
52
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
55
+ /**
56
+ * Mark the field required, or not, over the schema. Drives the `*`,
57
+ * `aria-required` and the `showClear` default.
58
+ */
53
59
  required?: boolean
54
60
  /**
55
61
  * Controlled input text — pair with `onInputValueChange` for server-side
@@ -82,6 +88,7 @@ function isItemObject(item: unknown): item is ComboboxItemObject<unknown> {
82
88
  function ComboboxField<T = string>({
83
89
  className,
84
90
  description,
91
+ disabled,
85
92
  emptyMessage = "No results.",
86
93
  filter,
87
94
  inputValue,
@@ -92,40 +99,49 @@ function ComboboxField<T = string>({
92
99
  placeholder,
93
100
  renderItem,
94
101
  required,
95
- showClear = true,
102
+ showClear,
96
103
  }: ComboboxFieldProps<T>) {
97
104
  const field = useFieldContext()
98
- const errorId = `${field.name}-error`
99
- const descriptionId = `${field.name}-description`
105
+ const id = useId()
106
+ const errorId = `${id}-error`
107
+ const descriptionId = `${id}-description`
100
108
  const isInvalid = useIsInvalid(field)
101
109
  const hideErrors = useHideFieldErrors(field)
102
110
  const showError = isInvalid && !hideErrors
103
111
  const schemaRequired = useIsFieldRequired(field)
104
112
  const isRequired = required ?? schemaRequired
113
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
114
+ const requiresValue = required ?? schemaRequiresValue
115
+ const canClear = showClear ?? !requiresValue
105
116
 
106
117
  const fieldValue = field.state.value
107
- const hasObjectItems = items.some(isItemObject)
108
- const value = hasObjectItems
109
- ? ((items.find((item) => isItemObject(item) && item.value === fieldValue) ??
110
- null) as T | null)
111
- : ((fieldValue ?? null) as T | null)
118
+ // Base UI shows the value it gets as text, so a stored id whose item isn't
119
+ // in `items` (options still loading, or a search that dropped it) would
120
+ // show raw. Only a plain string or number item is its own label.
121
+ const match = items.find(
122
+ (item) => (isItemObject(item) ? item.value : item) === fieldValue
123
+ )
124
+ const plainItems = items.length > 0 && !items.some(isItemObject)
125
+ const value = (match ??
126
+ (plainItems ? (fieldValue ?? null) : null)) as T | null
112
127
 
113
128
  return (
114
129
  <Field data-field={field.name} data-invalid={isInvalid}>
115
130
  {label && (
116
- <FieldLabel htmlFor={field.name}>
131
+ <FieldLabel htmlFor={id}>
117
132
  {label}
118
133
  {isRequired && <RequiredIndicator />}
119
134
  </FieldLabel>
120
135
  )}
121
136
  <Combobox
137
+ disabled={disabled}
122
138
  filter={filter}
123
139
  inputValue={inputValue}
124
140
  items={items}
125
141
  onInputValueChange={onInputValueChange}
126
142
  onValueChange={(next) => {
127
143
  if (next === null) {
128
- field.handleChange(undefined)
144
+ field.handleChange(getEmptyValue(field, undefined))
129
145
  return
130
146
  }
131
147
  field.handleChange(isItemObject(next) ? next.value : next)
@@ -138,12 +154,14 @@ function ComboboxField<T = string>({
138
154
  [showError, errorId]
139
155
  )}
140
156
  aria-invalid={isInvalid || undefined}
157
+ aria-required={requiresValue || undefined}
141
158
  className={className}
142
- id={field.name}
159
+ disabled={disabled}
160
+ id={id}
143
161
  name={field.name}
144
162
  onBlur={field.handleBlur}
145
163
  placeholder={placeholder}
146
- showClear={showClear && value !== null && value !== "" && !loading}
164
+ showClear={canClear && value !== null && value !== "" && !loading}
147
165
  >
148
166
  {loading && (
149
167
  <InputGroupAddon align="inline-end">
@@ -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, ReactNode } 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,13 +70,20 @@ function DatePickerField({
60
70
  triggerClassName,
61
71
  }: DatePickerFieldProps) {
62
72
  const field = useFieldContext<Date | undefined>()
63
- const errorId = `${field.name}-error`
64
- const descriptionId = `${field.name}-description`
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)
77
+ const id = useId()
78
+ const errorId = `${id}-error`
79
+ const descriptionId = `${id}-description`
65
80
  const isInvalid = useIsInvalid(field)
66
81
  const hideErrors = useHideFieldErrors(field)
67
82
  const showError = isInvalid && !hideErrors
68
83
  const schemaRequired = useIsFieldRequired(field)
69
84
  const isRequired = required ?? schemaRequired
85
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
86
+ const requiresValue = required ?? schemaRequiresValue
70
87
 
71
88
  const { value } = field.state
72
89
  const empty = !value
@@ -74,12 +91,19 @@ function DatePickerField({
74
91
  return (
75
92
  <Field data-field={field.name} data-invalid={isInvalid}>
76
93
  {label && (
77
- <FieldLabel htmlFor={field.name}>
94
+ <FieldLabel htmlFor={id}>
78
95
  {label}
79
96
  {isRequired && <RequiredIndicator />}
80
97
  </FieldLabel>
81
98
  )}
82
- <Popover>
99
+ <Popover
100
+ onOpenChange={(open) => {
101
+ isOpen.current = open
102
+ if (!open) {
103
+ field.handleBlur()
104
+ }
105
+ }}
106
+ >
83
107
  <PopoverTrigger
84
108
  aria-describedby={describedBy(
85
109
  [description, descriptionId],
@@ -92,11 +116,13 @@ function DatePickerField({
92
116
  triggerClassName
93
117
  )}
94
118
  data-empty={empty || undefined}
95
- id={field.name}
96
- // Base UI's Popover handles open state — we still need blur on the
97
- // form's reactive store. Passing onBlur here mirrors how `<select>`
98
- // commits on close.
99
- onBlur={field.handleBlur}
119
+ disabled={disabled}
120
+ id={id}
121
+ onBlur={() => {
122
+ if (!isOpen.current) {
123
+ field.handleBlur()
124
+ }
125
+ }}
100
126
  >
101
127
  <CalendarIcon />
102
128
  {empty ? <span>{placeholder}</span> : formatValue(value)}
@@ -104,7 +130,10 @@ function DatePickerField({
104
130
  <PopoverContent className="w-auto p-0">
105
131
  <Calendar
106
132
  mode="single"
107
- onSelect={(next) => field.handleChange(next)}
133
+ onSelect={(next: Date | undefined) =>
134
+ field.handleChange(next ?? getEmptyValue(field, undefined))
135
+ }
136
+ required={requiresValue}
108
137
  selected={value}
109
138
  {...calendarProps}
110
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,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,
@@ -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
  }
@@ -40,14 +41,17 @@ function InputField({
40
41
  ...props
41
42
  }: InputFieldProps) {
42
43
  const field = useFieldContext<string>()
43
- const errorId = `${field.name}-error`
44
- const descriptionId = `${field.name}-description`
44
+ const id = useId()
45
+ const errorId = `${id}-error`
46
+ const descriptionId = `${id}-description`
45
47
  const isInvalid = useIsInvalid(field)
46
48
  const hideErrors = useHideFieldErrors(field)
47
49
  const showError = isInvalid && !hideErrors
48
50
  const hasAddons = Children.count(children) > 0
49
51
  const schemaRequired = useIsFieldRequired(field)
50
52
  const isRequired = required ?? schemaRequired
53
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
54
+ const requiresValue = required ?? schemaRequiresValue
51
55
 
52
56
  const inputProps = {
53
57
  "aria-describedby": describedBy(
@@ -55,7 +59,8 @@ function InputField({
55
59
  [showError, errorId]
56
60
  ),
57
61
  "aria-invalid": isInvalid || undefined,
58
- id: field.name,
62
+ "aria-required": requiresValue || undefined,
63
+ id,
59
64
  name: field.name,
60
65
  onBlur: field.handleBlur,
61
66
  onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
@@ -67,7 +72,7 @@ function InputField({
67
72
  return (
68
73
  <Field data-field={field.name} data-invalid={isInvalid}>
69
74
  {label && (
70
- <FieldLabel htmlFor={field.name}>
75
+ <FieldLabel htmlFor={id}>
71
76
  {label}
72
77
  {isRequired && <RequiredIndicator />}
73
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 { 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,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 { XIcon } from "lucide-react"
11
- import type { ReactNode } from "react"
12
+ import { type ReactNode, useId } from "react"
12
13
  import { Button } from "@/components/ui/button"
13
14
  import {
14
15
  Combobox,
@@ -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
 
@@ -96,13 +100,16 @@ function MultiSelectField<T = string>({
96
100
  }: MultiSelectFieldProps<T>) {
97
101
  const field = useFieldContext<MultiSelectValue<T>[] | null | undefined>()
98
102
  const anchor = useComboboxAnchor()
99
- const errorId = `${field.name}-error`
100
- const descriptionId = `${field.name}-description`
103
+ const id = useId()
104
+ const errorId = `${id}-error`
105
+ const descriptionId = `${id}-description`
101
106
  const isInvalid = useIsInvalid(field)
102
107
  const hideErrors = useHideFieldErrors(field)
103
108
  const showError = isInvalid && !hideErrors
104
109
  const schemaRequired = useIsFieldRequired(field)
105
110
  const isRequired = required ?? schemaRequired
111
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
112
+ const requiresValue = required ?? schemaRequiresValue
106
113
 
107
114
  const values = field.state.value ?? []
108
115
  // Map stored values back to item identities, in selection order, so Base UI
@@ -114,7 +121,7 @@ function MultiSelectField<T = string>({
114
121
  return (
115
122
  <Field data-field={field.name} data-invalid={isInvalid}>
116
123
  {label && (
117
- <FieldLabel htmlFor={field.name}>
124
+ <FieldLabel htmlFor={id}>
118
125
  {label}
119
126
  {isRequired && <RequiredIndicator />}
120
127
  </FieldLabel>
@@ -143,7 +150,8 @@ function MultiSelectField<T = string>({
143
150
  [showError, errorId]
144
151
  )}
145
152
  aria-invalid={isInvalid || undefined}
146
- id={field.name}
153
+ aria-required={requiresValue || undefined}
154
+ id={id}
147
155
  name={field.name}
148
156
  onBlur={field.handleBlur}
149
157
  placeholder={chips.length === 0 ? placeholder : undefined}
@@ -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,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 { ReactNode } from "react"
11
+ import { type ReactNode, useId } from "react"
11
12
  import {
12
13
  Field,
13
14
  FieldDescription,
@@ -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
 
@@ -57,18 +61,21 @@ function OtpField({
57
61
  required,
58
62
  }: OtpFieldProps) {
59
63
  const field = useFieldContext<string>()
60
- const errorId = `${field.name}-error`
61
- const descriptionId = `${field.name}-description`
64
+ const id = useId()
65
+ const errorId = `${id}-error`
66
+ const descriptionId = `${id}-description`
62
67
  const isInvalid = useIsInvalid(field)
63
68
  const hideErrors = useHideFieldErrors(field)
64
69
  const showError = isInvalid && !hideErrors
65
70
  const schemaRequired = useIsFieldRequired(field)
66
71
  const isRequired = required ?? schemaRequired
72
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
73
+ const requiresValue = required ?? schemaRequiresValue
67
74
 
68
75
  return (
69
76
  <Field data-field={field.name} data-invalid={isInvalid}>
70
77
  {label && (
71
- <FieldLabel htmlFor={field.name}>
78
+ <FieldLabel htmlFor={id}>
72
79
  {label}
73
80
  {isRequired && <RequiredIndicator />}
74
81
  </FieldLabel>
@@ -79,11 +86,12 @@ function OtpField({
79
86
  [showError, errorId]
80
87
  )}
81
88
  aria-invalid={isInvalid || undefined}
89
+ aria-required={requiresValue || undefined}
82
90
  autoFocus={autoFocus}
83
91
  className={className}
84
92
  containerClassName={containerClassName}
85
93
  disabled={disabled}
86
- id={field.name}
94
+ id={id}
87
95
  maxLength={maxLength}
88
96
  name={field.name}
89
97
  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 { 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,14 +39,17 @@ function RadioGroupField({
35
39
  required,
36
40
  ...props
37
41
  }: RadioGroupFieldProps) {
38
- const field = useFieldContext<string>()
39
- const errorId = `${field.name}-error`
40
- const descriptionId = `${field.name}-description`
42
+ const field = useFieldContext()
43
+ const id = useId()
44
+ const errorId = `${id}-error`
45
+ const descriptionId = `${id}-description`
41
46
  const isInvalid = useIsInvalid(field)
42
47
  const hideErrors = useHideFieldErrors(field)
43
48
  const showError = isInvalid && !hideErrors
44
49
  const schemaRequired = useIsFieldRequired(field)
45
50
  const isRequired = required ?? schemaRequired
51
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
52
+ const requiresValue = required ?? schemaRequiresValue
46
53
 
47
54
  return (
48
55
  <Field data-field={field.name} data-invalid={isInvalid}>
@@ -58,9 +65,10 @@ function RadioGroupField({
58
65
  [showError, errorId]
59
66
  )}
60
67
  aria-invalid={isInvalid || undefined}
68
+ aria-required={requiresValue || undefined}
61
69
  name={field.name}
62
- onValueChange={(value) => field.handleChange(String(value))}
63
- value={field.state.value ?? ""}
70
+ onValueChange={(value) => field.handleChange(value)}
71
+ value={field.state.value ?? null}
64
72
  {...props}
65
73
  >
66
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, 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,13 +53,20 @@ function SelectField({
48
53
  ...props
49
54
  }: SelectFieldProps) {
50
55
  const field = useFieldContext()
51
- const errorId = `${field.name}-error`
52
- const descriptionId = `${field.name}-description`
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)
60
+ const id = useId()
61
+ const errorId = `${id}-error`
62
+ const descriptionId = `${id}-description`
53
63
  const isInvalid = useIsInvalid(field)
54
64
  const hideErrors = useHideFieldErrors(field)
55
65
  const showError = isInvalid && !hideErrors
56
66
  const schemaRequired = useIsFieldRequired(field)
57
67
  const isRequired = required ?? schemaRequired
68
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
69
+ const requiresValue = required ?? schemaRequiresValue
58
70
 
59
71
  const items = useMemo(() => {
60
72
  const map: Record<string, ReactNode> = {}
@@ -72,7 +84,7 @@ function SelectField({
72
84
  return (
73
85
  <Field data-field={field.name} data-invalid={isInvalid}>
74
86
  {label && (
75
- <FieldLabel htmlFor={field.name}>
87
+ <FieldLabel htmlFor={id}>
76
88
  {label}
77
89
  {isRequired && <RequiredIndicator />}
78
90
  </FieldLabel>
@@ -80,6 +92,13 @@ function SelectField({
80
92
  <Select
81
93
  items={items}
82
94
  name={field.name}
95
+ onOpenChange={(open, eventDetails) => {
96
+ isOpen.current = open
97
+ if (!open) {
98
+ field.handleBlur()
99
+ }
100
+ onOpenChange?.(open, eventDetails)
101
+ }}
83
102
  onValueChange={(value) => field.handleChange(value)}
84
103
  value={field.state.value ?? null}
85
104
  {...props}
@@ -90,9 +109,14 @@ function SelectField({
90
109
  [showError, errorId]
91
110
  )}
92
111
  aria-invalid={isInvalid || undefined}
112
+ aria-required={requiresValue || undefined}
93
113
  className={triggerClassName}
94
- id={field.name}
95
- onBlur={field.handleBlur}
114
+ id={id}
115
+ onBlur={() => {
116
+ if (!isOpen.current) {
117
+ field.handleBlur()
118
+ }
119
+ }}
96
120
  size={triggerSize}
97
121
  >
98
122
  <SelectValue placeholder={placeholder} />