@zeno-lib/forms 0.0.1 → 0.2.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 (63) hide show
  1. package/package.json +23 -11
  2. package/src/addons/validation-spinner.tsx +3 -4
  3. package/src/create-form.tsx +59 -0
  4. package/src/create-zeno-form.tsx +531 -0
  5. package/src/fields/checkbox-field.tsx +15 -12
  6. package/src/fields/checkbox-group-field.tsx +160 -0
  7. package/src/fields/combobox-field.tsx +13 -14
  8. package/src/fields/date-picker-field.tsx +18 -15
  9. package/src/fields/index.ts +5 -0
  10. package/src/fields/input-field.tsx +12 -13
  11. package/src/fields/money-field.tsx +123 -0
  12. package/src/fields/multi-select-field.tsx +193 -0
  13. package/src/fields/number-field.tsx +1 -2
  14. package/src/fields/otp-field.tsx +15 -12
  15. package/src/fields/percentage-field.tsx +73 -0
  16. package/src/fields/radio-group-field.tsx +12 -13
  17. package/src/fields/reset-button.tsx +2 -3
  18. package/src/fields/select-field.tsx +12 -13
  19. package/src/fields/slider-field.tsx +11 -12
  20. package/src/fields/submit-button.tsx +3 -4
  21. package/src/fields/switch-field.tsx +15 -12
  22. package/src/fields/textarea-field.tsx +12 -13
  23. package/src/fields/year-field.tsx +56 -0
  24. package/src/form-dialog.test.tsx +161 -0
  25. package/src/form-dialog.tsx +253 -0
  26. package/src/form-element.tsx +51 -0
  27. package/src/formatted-number-fields.test.tsx +236 -0
  28. package/src/index.ts +27 -0
  29. package/src/lib/action-result.ts +61 -0
  30. package/src/lib/apply-validation-error.test.ts +16 -6
  31. package/src/lib/apply-validation-error.ts +38 -6
  32. package/src/lib/formatted-number.test.ts +157 -0
  33. package/src/lib/formatted-number.ts +307 -0
  34. package/src/lib/required-indicator.tsx +1 -1
  35. package/src/lib/schema-defaults.test.ts +1 -2
  36. package/src/lib/schema-required.test.ts +98 -6
  37. package/src/lib/schema-required.ts +126 -22
  38. package/src/lib/submit-action.test-d.ts +83 -0
  39. package/src/lib/submit-action.ts +178 -0
  40. package/src/lib/use-form-dialog.ts +184 -0
  41. package/src/lib/use-formatted-number.ts +235 -0
  42. package/src/lib/use-is-invalid.ts +4 -4
  43. package/src/lib/use-rebased-default-values.ts +42 -0
  44. package/src/lib/use-unsaved-changes-warning.ts +3 -3
  45. package/src/lib/validation-error.test.ts +1 -1
  46. package/src/lib/validation-logic.test.ts +1 -1
  47. package/src/lib/validation-modes.test-d.ts +1 -1
  48. package/src/lib/validation-modes.test.ts +12 -2
  49. package/src/lib/validation-modes.ts +3 -1
  50. package/src/multi-value-fields.test.tsx +116 -0
  51. package/src/submit-action.test.tsx +421 -0
  52. package/src/use-app-fields.test-d.ts +8 -25
  53. package/src/use-app-fields.test.tsx +8 -32
  54. package/src/use-form.cascade.test.tsx +4 -6
  55. package/src/use-form.focus-invalid.test.tsx +198 -0
  56. package/src/use-form.reset-defaults.test.tsx +125 -0
  57. package/src/use-form.test-d.ts +2 -3
  58. package/src/use-form.test.tsx +12 -14
  59. package/src/use-form.unsaved-warning.test.tsx +4 -13
  60. package/src/use-form.validation-modes.test.tsx +10 -8
  61. package/src/form.tsx +0 -105
  62. package/src/use-app-fields.tsx +0 -272
  63. package/src/use-form.tsx +0 -450
@@ -1,23 +1,22 @@
1
1
  "use client"
2
2
 
3
- import { Checkbox } from "@zeno-lib/ui/checkbox"
3
+ import { describedBy } from "@zeno-lib/forms/lib/aria"
4
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
+ import {
6
+ useHideFieldErrors,
7
+ useIsFieldRequired,
8
+ useIsInvalid,
9
+ } from "@zeno-lib/forms/lib/use-is-invalid"
10
+ import type { ComponentProps, ReactNode } from "react"
11
+ import { Checkbox } from "@/components/ui/checkbox"
4
12
  import {
5
13
  Field,
6
14
  FieldContent,
7
15
  FieldDescription,
8
16
  FieldError,
9
17
  FieldLabel,
10
- } from "@zeno-lib/ui/field"
11
- import type { ComponentProps, ReactNode } from "react"
12
-
13
- import { describedBy } from "../lib/aria"
14
- import { useFieldContext } from "../lib/contexts"
18
+ } from "@/components/ui/field"
15
19
  import { RequiredIndicator } from "../lib/required-indicator"
16
- import {
17
- useHideFieldErrors,
18
- useIsFieldRequired,
19
- useIsInvalid,
20
- } from "../lib/use-is-invalid"
21
20
 
22
21
  type CheckboxFieldProps = Omit<
23
22
  ComponentProps<typeof Checkbox>,
@@ -45,7 +44,11 @@ function CheckboxField({
45
44
  const isRequired = required ?? schemaRequired
46
45
 
47
46
  return (
48
- <Field data-invalid={isInvalid} orientation="horizontal">
47
+ <Field
48
+ data-field={field.name}
49
+ data-invalid={isInvalid}
50
+ orientation="horizontal"
51
+ >
49
52
  <Checkbox
50
53
  aria-describedby={describedBy(
51
54
  [description, descriptionId],
@@ -0,0 +1,160 @@
1
+ "use client"
2
+
3
+ import { describedBy } from "@zeno-lib/forms/lib/aria"
4
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
+ import {
6
+ useHideFieldErrors,
7
+ useIsFieldRequired,
8
+ useIsInvalid,
9
+ } from "@zeno-lib/forms/lib/use-is-invalid"
10
+ import type { ReactNode } from "react"
11
+ import { Checkbox } from "@/components/ui/checkbox"
12
+ import {
13
+ Field,
14
+ FieldContent,
15
+ FieldDescription,
16
+ FieldError,
17
+ FieldGroup,
18
+ FieldLabel,
19
+ FieldLegend,
20
+ FieldSet,
21
+ } from "@/components/ui/field"
22
+ import { cn } from "@/lib/utils"
23
+ import { RequiredIndicator } from "../lib/required-indicator"
24
+
25
+ type CheckboxGroupOption<V> = {
26
+ value: V
27
+ label: ReactNode
28
+ description?: ReactNode
29
+ disabled?: boolean
30
+ }
31
+
32
+ type CheckboxGroupFieldProps<V = string> = {
33
+ /**
34
+ * The options. Pass plain strings or numbers (value === label), or
35
+ * `{ value, label, description?, disabled? }` objects. The form value is the
36
+ * array of checked values, in the order of `items`.
37
+ */
38
+ items: readonly (CheckboxGroupOption<V> | V)[]
39
+ description?: ReactNode
40
+ label?: ReactNode
41
+ /** Stack the options (`"vertical"`, default) or wrap them in a row. */
42
+ orientation?: "horizontal" | "vertical"
43
+ disabled?: boolean
44
+ className?: string
45
+ /** Force the required `*` indicator on or off. Defaults to schema-derived. */
46
+ required?: boolean
47
+ }
48
+
49
+ function toOption<V>(item: CheckboxGroupOption<V> | V): CheckboxGroupOption<V> {
50
+ if (typeof item === "object" && item !== null && "value" in item) {
51
+ return item as CheckboxGroupOption<V>
52
+ }
53
+ return { label: String(item), value: item as V }
54
+ }
55
+
56
+ /** A set of checkboxes bound to one array value (`[]` when none checked). */
57
+ function CheckboxGroupField<V = string>({
58
+ className,
59
+ description,
60
+ disabled,
61
+ items,
62
+ label,
63
+ orientation = "vertical",
64
+ required,
65
+ }: CheckboxGroupFieldProps<V>) {
66
+ const field = useFieldContext<V[] | null | undefined>()
67
+ const errorId = `${field.name}-error`
68
+ const descriptionId = `${field.name}-description`
69
+ const isInvalid = useIsInvalid(field)
70
+ const hideErrors = useHideFieldErrors(field)
71
+ const showError = isInvalid && !hideErrors
72
+ const schemaRequired = useIsFieldRequired(field)
73
+ const isRequired = required ?? schemaRequired
74
+
75
+ const options = items.map(toOption)
76
+ const checked = field.state.value ?? []
77
+
78
+ const toggle = (value: V, on: boolean) => {
79
+ const next = new Set(checked)
80
+ if (on) {
81
+ next.add(value)
82
+ } else {
83
+ next.delete(value)
84
+ }
85
+ // Keep the stored order stable: the order of `items`, not click order.
86
+ field.handleChange(
87
+ options.map((option) => option.value).filter((v) => next.has(v))
88
+ )
89
+ }
90
+
91
+ return (
92
+ <FieldSet
93
+ aria-describedby={describedBy(
94
+ [description, descriptionId],
95
+ [showError, errorId]
96
+ )}
97
+ aria-invalid={isInvalid || undefined}
98
+ className={className}
99
+ data-field={field.name}
100
+ data-invalid={isInvalid}
101
+ disabled={disabled}
102
+ >
103
+ {label && (
104
+ <FieldLegend variant="label">
105
+ {label}
106
+ {isRequired && <RequiredIndicator />}
107
+ </FieldLegend>
108
+ )}
109
+ {description && (
110
+ <FieldDescription id={descriptionId}>{description}</FieldDescription>
111
+ )}
112
+ <FieldGroup
113
+ className={cn(
114
+ orientation === "horizontal" && "flex-row flex-wrap gap-x-6 *:w-auto"
115
+ )}
116
+ data-slot="checkbox-group"
117
+ >
118
+ {options.map((option, index) => {
119
+ const id = `${field.name}-${index}`
120
+ return (
121
+ <Field
122
+ data-invalid={isInvalid}
123
+ key={String(option.value)}
124
+ orientation="horizontal"
125
+ >
126
+ <Checkbox
127
+ aria-invalid={isInvalid || undefined}
128
+ checked={checked.includes(option.value)}
129
+ disabled={disabled || option.disabled}
130
+ id={id}
131
+ name={field.name}
132
+ onBlur={field.handleBlur}
133
+ onCheckedChange={(on) => toggle(option.value, on === true)}
134
+ value={String(option.value)}
135
+ />
136
+ {option.description ? (
137
+ <FieldContent>
138
+ <FieldLabel className="font-normal" htmlFor={id}>
139
+ {option.label}
140
+ </FieldLabel>
141
+ <FieldDescription>{option.description}</FieldDescription>
142
+ </FieldContent>
143
+ ) : (
144
+ <FieldLabel className="w-auto font-normal" htmlFor={id}>
145
+ {option.label}
146
+ </FieldLabel>
147
+ )}
148
+ </Field>
149
+ )
150
+ })}
151
+ </FieldGroup>
152
+ {showError && (
153
+ <FieldError errors={field.state.meta.errors} id={errorId} />
154
+ )}
155
+ </FieldSet>
156
+ )
157
+ }
158
+
159
+ export type { CheckboxGroupFieldProps, CheckboxGroupOption }
160
+ export { CheckboxGroupField }
@@ -1,5 +1,13 @@
1
1
  "use client"
2
2
 
3
+ import { describedBy } from "@zeno-lib/forms/lib/aria"
4
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
+ import {
6
+ useHideFieldErrors,
7
+ useIsFieldRequired,
8
+ useIsInvalid,
9
+ } from "@zeno-lib/forms/lib/use-is-invalid"
10
+ import type { ReactNode } from "react"
3
11
  import {
4
12
  Combobox,
5
13
  ComboboxContent,
@@ -7,25 +15,16 @@ import {
7
15
  ComboboxInput,
8
16
  ComboboxItem,
9
17
  ComboboxList,
10
- } from "@zeno-lib/ui/combobox"
18
+ } from "@/components/ui/combobox"
11
19
  import {
12
20
  Field,
13
21
  FieldDescription,
14
22
  FieldError,
15
23
  FieldLabel,
16
- } from "@zeno-lib/ui/field"
17
- import { InputGroupAddon } from "@zeno-lib/ui/input-group"
18
- import { Spinner } from "@zeno-lib/ui/spinner"
19
- import type { ReactNode } from "react"
20
-
21
- import { describedBy } from "../lib/aria"
22
- import { useFieldContext } from "../lib/contexts"
24
+ } from "@/components/ui/field"
25
+ import { InputGroupAddon } from "@/components/ui/input-group"
26
+ import { Spinner } from "@/components/ui/spinner"
23
27
  import { RequiredIndicator } from "../lib/required-indicator"
24
- import {
25
- useHideFieldErrors,
26
- useIsFieldRequired,
27
- useIsInvalid,
28
- } from "../lib/use-is-invalid"
29
28
 
30
29
  type ComboboxItemObject<V> = { value: V; label: string }
31
30
 
@@ -112,7 +111,7 @@ function ComboboxField<T = string>({
112
111
  : ((fieldValue ?? null) as T | null)
113
112
 
114
113
  return (
115
- <Field data-invalid={isInvalid}>
114
+ <Field data-field={field.name} data-invalid={isInvalid}>
116
115
  {label && (
117
116
  <FieldLabel htmlFor={field.name}>
118
117
  {label}
@@ -1,25 +1,28 @@
1
1
  "use client"
2
2
 
3
- import { buttonVariants } from "@zeno-lib/ui/button"
4
- import { Calendar } from "@zeno-lib/ui/calendar"
3
+ import { describedBy } from "@zeno-lib/forms/lib/aria"
4
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
+ import {
6
+ useHideFieldErrors,
7
+ useIsFieldRequired,
8
+ useIsInvalid,
9
+ } from "@zeno-lib/forms/lib/use-is-invalid"
10
+ import type { ComponentProps, ReactNode } from "react"
11
+ import { buttonVariants } from "@/components/ui/button"
12
+ import { Calendar } from "@/components/ui/calendar"
5
13
  import {
6
14
  Field,
7
15
  FieldDescription,
8
16
  FieldError,
9
17
  FieldLabel,
10
- } from "@zeno-lib/ui/field"
11
- import { cn } from "@zeno-lib/ui/lib/utils"
12
- import { Popover, PopoverContent, PopoverTrigger } from "@zeno-lib/ui/popover"
13
- import type { ComponentProps, ReactNode } from "react"
14
-
15
- import { describedBy } from "../lib/aria"
16
- import { useFieldContext } from "../lib/contexts"
17
- import { RequiredIndicator } from "../lib/required-indicator"
18
+ } from "@/components/ui/field"
18
19
  import {
19
- useHideFieldErrors,
20
- useIsFieldRequired,
21
- useIsInvalid,
22
- } from "../lib/use-is-invalid"
20
+ Popover,
21
+ PopoverContent,
22
+ PopoverTrigger,
23
+ } from "@/components/ui/popover"
24
+ import { cn } from "@/lib/utils"
25
+ import { RequiredIndicator } from "../lib/required-indicator"
23
26
 
24
27
  type CalendarProps = ComponentProps<typeof Calendar>
25
28
 
@@ -69,7 +72,7 @@ function DatePickerField({
69
72
  const empty = !value
70
73
 
71
74
  return (
72
- <Field data-invalid={isInvalid}>
75
+ <Field data-field={field.name} data-invalid={isInvalid}>
73
76
  {label && (
74
77
  <FieldLabel htmlFor={field.name}>
75
78
  {label}
@@ -1,12 +1,16 @@
1
1
  /** biome-ignore-all lint/performance/noBarrelFile: single import surface for all field components */
2
2
  export * from "./checkbox-field"
3
+ export * from "./checkbox-group-field"
3
4
  export * from "./combobox-field"
4
5
  export * from "./date-picker-field"
5
6
  export * from "./email-field"
6
7
  export * from "./input-field"
8
+ export * from "./money-field"
9
+ export * from "./multi-select-field"
7
10
  export * from "./number-field"
8
11
  export * from "./otp-field"
9
12
  export * from "./password-field"
13
+ export * from "./percentage-field"
10
14
  export * from "./radio-group-field"
11
15
  export * from "./reset-button"
12
16
  export * from "./select-field"
@@ -14,3 +18,4 @@ export * from "./slider-field"
14
18
  export * from "./submit-button"
15
19
  export * from "./switch-field"
16
20
  export * from "./textarea-field"
21
+ export * from "./year-field"
@@ -1,23 +1,22 @@
1
1
  "use client"
2
2
 
3
+ import { describedBy } from "@zeno-lib/forms/lib/aria"
4
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
+ import {
6
+ useHideFieldErrors,
7
+ useIsFieldRequired,
8
+ useIsInvalid,
9
+ } from "@zeno-lib/forms/lib/use-is-invalid"
10
+ import { Children, type ComponentProps, type ReactNode } from "react"
3
11
  import {
4
12
  Field,
5
13
  FieldDescription,
6
14
  FieldError,
7
15
  FieldLabel,
8
- } from "@zeno-lib/ui/field"
9
- import { Input } from "@zeno-lib/ui/input"
10
- import { InputGroup, InputGroupInput } from "@zeno-lib/ui/input-group"
11
- import { Children, type ComponentProps, type ReactNode } from "react"
12
-
13
- import { describedBy } from "../lib/aria"
14
- import { useFieldContext } from "../lib/contexts"
16
+ } from "@/components/ui/field"
17
+ import { Input } from "@/components/ui/input"
18
+ import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
15
19
  import { RequiredIndicator } from "../lib/required-indicator"
16
- import {
17
- useHideFieldErrors,
18
- useIsFieldRequired,
19
- useIsInvalid,
20
- } from "../lib/use-is-invalid"
21
20
 
22
21
  type InputFieldProps = Omit<
23
22
  ComponentProps<typeof Input>,
@@ -66,7 +65,7 @@ function InputField({
66
65
  }
67
66
 
68
67
  return (
69
- <Field data-invalid={isInvalid}>
68
+ <Field data-field={field.name} data-invalid={isInvalid}>
70
69
  {label && (
71
70
  <FieldLabel htmlFor={field.name}>
72
71
  {label}
@@ -0,0 +1,123 @@
1
+ "use client"
2
+
3
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
4
+ import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
5
+ import { type ReactNode, useMemo } from "react"
6
+ import { InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
7
+ import { InputField, type InputFieldProps } from "./input-field"
8
+
9
+ type MoneyFieldProps = Omit<
10
+ InputFieldProps,
11
+ "defaultValue" | "inputMode" | "onChange" | "onPaste" | "type" | "value"
12
+ > & {
13
+ /** ISO 4217 code shown as an add-on, e.g. `"CHF"`, `"EUR"`, `"USD"`. */
14
+ currency?: string
15
+ /** How the currency add-on reads. Defaults to `"symbol"`. */
16
+ currencyDisplay?: "code" | "name" | "narrowSymbol" | "symbol"
17
+ /**
18
+ * Which side the currency add-on sits on. Defaults to the locale's own
19
+ * convention (`CHF 1’234` in `de-CH`, `1.234 €` in `de-DE`).
20
+ */
21
+ currencyPosition?: "end" | "start"
22
+ /**
23
+ * BCP 47 locale driving the group/decimal separators (`de-CH` →
24
+ * `1’234.50`). Defaults to the runtime locale.
25
+ */
26
+ locale?: string
27
+ /**
28
+ * Digits allowed after the decimal separator. Defaults to the currency's
29
+ * minor units (2 for CHF, 0 for JPY), or 2 without a currency.
30
+ */
31
+ fractionDigits?: number
32
+ /** Accept negative amounts. Defaults to `false`. */
33
+ allowNegative?: boolean
34
+ /** Clamp the amount on blur. */
35
+ min?: number
36
+ /** Clamp the amount on blur. */
37
+ max?: number
38
+ }
39
+
40
+ type CurrencyParts = {
41
+ label: string
42
+ position: "end" | "start"
43
+ fractionDigits: number
44
+ }
45
+
46
+ function getCurrencyParts(
47
+ locale: string | undefined,
48
+ currency: string,
49
+ currencyDisplay: NonNullable<MoneyFieldProps["currencyDisplay"]>
50
+ ): CurrencyParts {
51
+ const format = new Intl.NumberFormat(locale, {
52
+ currency,
53
+ currencyDisplay,
54
+ style: "currency",
55
+ })
56
+ const parts = format.formatToParts(1)
57
+ const currencyIndex = parts.findIndex((part) => part.type === "currency")
58
+ const integerIndex = parts.findIndex((part) => part.type === "integer")
59
+ return {
60
+ fractionDigits: format.resolvedOptions().maximumFractionDigits ?? 2,
61
+ label: parts[currencyIndex]?.value ?? currency,
62
+ position: currencyIndex > integerIndex ? "end" : "start",
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Amount input with locale-aware thousands separators and a currency add-on.
68
+ * The form value is `number | null` (empty input → `null`).
69
+ */
70
+ function MoneyField({
71
+ allowNegative = false,
72
+ children,
73
+ currency,
74
+ currencyDisplay = "symbol",
75
+ currencyPosition,
76
+ fractionDigits,
77
+ locale,
78
+ max,
79
+ min,
80
+ ...props
81
+ }: MoneyFieldProps) {
82
+ const field = useFieldContext<number | null | undefined>()
83
+ const parts = useMemo(
84
+ () =>
85
+ currency ? getCurrencyParts(locale, currency, currencyDisplay) : null,
86
+ [currency, currencyDisplay, locale]
87
+ )
88
+ const digits = fractionDigits ?? parts?.fractionDigits ?? 2
89
+ const position = currencyPosition ?? parts?.position ?? "end"
90
+
91
+ const inputProps = useFormattedNumber({
92
+ allowNegative,
93
+ locale,
94
+ max,
95
+ maximumFractionDigits: digits,
96
+ min,
97
+ onBlur: field.handleBlur,
98
+ onValueChange: (next) => field.handleChange(next),
99
+ padFraction: true,
100
+ value: field.state.value,
101
+ })
102
+
103
+ let addon: ReactNode = null
104
+ if (parts) {
105
+ addon = (
106
+ <InputGroupAddon
107
+ align={position === "start" ? "inline-start" : "inline-end"}
108
+ >
109
+ <InputGroupText>{parts.label}</InputGroupText>
110
+ </InputGroupAddon>
111
+ )
112
+ }
113
+
114
+ return (
115
+ <InputField {...props} {...inputProps}>
116
+ {addon}
117
+ {children}
118
+ </InputField>
119
+ )
120
+ }
121
+
122
+ export type { MoneyFieldProps }
123
+ export { MoneyField }
@@ -0,0 +1,193 @@
1
+ "use client"
2
+
3
+ import { describedBy } from "@zeno-lib/forms/lib/aria"
4
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
+ import {
6
+ useHideFieldErrors,
7
+ useIsFieldRequired,
8
+ useIsInvalid,
9
+ } from "@zeno-lib/forms/lib/use-is-invalid"
10
+ import { XIcon } from "lucide-react"
11
+ import type { ReactNode } from "react"
12
+ import { Button } from "@/components/ui/button"
13
+ import {
14
+ Combobox,
15
+ ComboboxChip,
16
+ ComboboxChips,
17
+ ComboboxChipsInput,
18
+ ComboboxContent,
19
+ ComboboxEmpty,
20
+ ComboboxItem,
21
+ ComboboxList,
22
+ ComboboxValue,
23
+ useComboboxAnchor,
24
+ } from "@/components/ui/combobox"
25
+ import {
26
+ Field,
27
+ FieldDescription,
28
+ FieldError,
29
+ FieldLabel,
30
+ } from "@/components/ui/field"
31
+ import { RequiredIndicator } from "../lib/required-indicator"
32
+
33
+ type MultiSelectItemObject<V> = { value: V; label: string }
34
+
35
+ /** The value stored per selected item: `item.value` for objects, else the item. */
36
+ type MultiSelectValue<T> = T extends MultiSelectItemObject<infer V> ? V : T
37
+
38
+ type MultiSelectFieldProps<T = string> = {
39
+ /**
40
+ * Options shown in the dropdown. Pass plain strings or numbers (value ===
41
+ * label), or `{ value, label }` objects. The form value is an array of the
42
+ * selected values (`item.value` for objects).
43
+ */
44
+ items: readonly T[]
45
+ description?: ReactNode
46
+ label?: ReactNode
47
+ /** Shown in the search input while nothing is selected. */
48
+ placeholder?: string
49
+ /** Message shown when filtering produces zero matches. */
50
+ emptyMessage?: ReactNode
51
+ /** Override per-row rendering. Default: the item's label. */
52
+ renderItem?: (item: T) => ReactNode
53
+ /** Show a button that clears every selection. Defaults to `true`. */
54
+ showClear?: boolean
55
+ /** Accessible label of the clear button. Defaults to `"Clear"`. */
56
+ clearLabel?: string
57
+ disabled?: boolean
58
+ className?: string
59
+ /** Force the required `*` indicator on or off. Defaults to schema-derived. */
60
+ required?: boolean
61
+ }
62
+
63
+ function isItemObject(item: unknown): item is MultiSelectItemObject<unknown> {
64
+ return (
65
+ typeof item === "object" &&
66
+ item !== null &&
67
+ "value" in item &&
68
+ "label" in item
69
+ )
70
+ }
71
+
72
+ function itemValue(item: unknown): unknown {
73
+ return isItemObject(item) ? item.value : item
74
+ }
75
+
76
+ function itemLabel(item: unknown): string {
77
+ return isItemObject(item) ? item.label : String(item)
78
+ }
79
+
80
+ /**
81
+ * Multi-value combobox: the selection renders as removable chips and the form
82
+ * value is an array (`[]` when empty).
83
+ */
84
+ function MultiSelectField<T = string>({
85
+ className,
86
+ clearLabel = "Clear",
87
+ description,
88
+ disabled,
89
+ emptyMessage = "No results.",
90
+ items,
91
+ label,
92
+ placeholder,
93
+ renderItem,
94
+ required,
95
+ showClear = true,
96
+ }: MultiSelectFieldProps<T>) {
97
+ const field = useFieldContext<MultiSelectValue<T>[] | null | undefined>()
98
+ const anchor = useComboboxAnchor()
99
+ const errorId = `${field.name}-error`
100
+ const descriptionId = `${field.name}-description`
101
+ const isInvalid = useIsInvalid(field)
102
+ const hideErrors = useHideFieldErrors(field)
103
+ const showError = isInvalid && !hideErrors
104
+ const schemaRequired = useIsFieldRequired(field)
105
+ const isRequired = required ?? schemaRequired
106
+
107
+ const values = field.state.value ?? []
108
+ // Map stored values back to item identities, in selection order, so Base UI
109
+ // compares by reference and the chips keep the order the user picked.
110
+ const selected = values
111
+ .map((value) => items.find((item) => Object.is(itemValue(item), value)))
112
+ .filter((item): item is T => item !== undefined)
113
+
114
+ return (
115
+ <Field data-field={field.name} data-invalid={isInvalid}>
116
+ {label && (
117
+ <FieldLabel htmlFor={field.name}>
118
+ {label}
119
+ {isRequired && <RequiredIndicator />}
120
+ </FieldLabel>
121
+ )}
122
+ <Combobox
123
+ disabled={disabled}
124
+ items={items}
125
+ multiple
126
+ onValueChange={(next: T[]) =>
127
+ field.handleChange(next.map(itemValue) as MultiSelectValue<T>[])
128
+ }
129
+ value={selected}
130
+ >
131
+ <ComboboxChips className={className} ref={anchor}>
132
+ <ComboboxValue>
133
+ {(chips: T[]) => (
134
+ <>
135
+ {chips.map((item) => (
136
+ <ComboboxChip key={String(itemValue(item))}>
137
+ {itemLabel(item)}
138
+ </ComboboxChip>
139
+ ))}
140
+ <ComboboxChipsInput
141
+ aria-describedby={describedBy(
142
+ [description, descriptionId],
143
+ [showError, errorId]
144
+ )}
145
+ aria-invalid={isInvalid || undefined}
146
+ id={field.name}
147
+ name={field.name}
148
+ onBlur={field.handleBlur}
149
+ placeholder={chips.length === 0 ? placeholder : undefined}
150
+ />
151
+ </>
152
+ )}
153
+ </ComboboxValue>
154
+ {showClear && selected.length > 0 && !disabled && (
155
+ <Button
156
+ aria-label={clearLabel}
157
+ className="ml-auto"
158
+ onClick={() => field.handleChange([])}
159
+ size="icon-xs"
160
+ type="button"
161
+ variant="ghost"
162
+ >
163
+ <XIcon />
164
+ </Button>
165
+ )}
166
+ </ComboboxChips>
167
+ <ComboboxContent anchor={anchor}>
168
+ <ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
169
+ <ComboboxList>
170
+ {(item: T) =>
171
+ renderItem ? (
172
+ renderItem(item)
173
+ ) : (
174
+ <ComboboxItem key={String(itemValue(item))} value={item}>
175
+ {itemLabel(item)}
176
+ </ComboboxItem>
177
+ )
178
+ }
179
+ </ComboboxList>
180
+ </ComboboxContent>
181
+ </Combobox>
182
+ {description && (
183
+ <FieldDescription id={descriptionId}>{description}</FieldDescription>
184
+ )}
185
+ {showError && (
186
+ <FieldError errors={field.state.meta.errors} id={errorId} />
187
+ )}
188
+ </Field>
189
+ )
190
+ }
191
+
192
+ export type { MultiSelectFieldProps, MultiSelectItemObject, MultiSelectValue }
193
+ export { MultiSelectField }