@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.
- package/package.json +23 -11
- package/src/addons/validation-spinner.tsx +3 -4
- package/src/create-form.tsx +59 -0
- package/src/create-zeno-form.tsx +531 -0
- package/src/fields/checkbox-field.tsx +15 -12
- package/src/fields/checkbox-group-field.tsx +160 -0
- package/src/fields/combobox-field.tsx +13 -14
- package/src/fields/date-picker-field.tsx +18 -15
- package/src/fields/index.ts +5 -0
- package/src/fields/input-field.tsx +12 -13
- package/src/fields/money-field.tsx +123 -0
- package/src/fields/multi-select-field.tsx +193 -0
- package/src/fields/number-field.tsx +1 -2
- package/src/fields/otp-field.tsx +15 -12
- package/src/fields/percentage-field.tsx +73 -0
- package/src/fields/radio-group-field.tsx +12 -13
- package/src/fields/reset-button.tsx +2 -3
- package/src/fields/select-field.tsx +12 -13
- package/src/fields/slider-field.tsx +11 -12
- package/src/fields/submit-button.tsx +3 -4
- package/src/fields/switch-field.tsx +15 -12
- package/src/fields/textarea-field.tsx +12 -13
- package/src/fields/year-field.tsx +56 -0
- package/src/form-dialog.test.tsx +161 -0
- package/src/form-dialog.tsx +253 -0
- package/src/form-element.tsx +51 -0
- package/src/formatted-number-fields.test.tsx +236 -0
- package/src/index.ts +27 -0
- package/src/lib/action-result.ts +61 -0
- package/src/lib/apply-validation-error.test.ts +16 -6
- package/src/lib/apply-validation-error.ts +38 -6
- package/src/lib/formatted-number.test.ts +157 -0
- package/src/lib/formatted-number.ts +307 -0
- package/src/lib/required-indicator.tsx +1 -1
- package/src/lib/schema-defaults.test.ts +1 -2
- package/src/lib/schema-required.test.ts +98 -6
- package/src/lib/schema-required.ts +126 -22
- package/src/lib/submit-action.test-d.ts +83 -0
- package/src/lib/submit-action.ts +178 -0
- package/src/lib/use-form-dialog.ts +184 -0
- package/src/lib/use-formatted-number.ts +235 -0
- package/src/lib/use-is-invalid.ts +4 -4
- package/src/lib/use-rebased-default-values.ts +42 -0
- package/src/lib/use-unsaved-changes-warning.ts +3 -3
- package/src/lib/validation-error.test.ts +1 -1
- package/src/lib/validation-logic.test.ts +1 -1
- package/src/lib/validation-modes.test-d.ts +1 -1
- package/src/lib/validation-modes.test.ts +12 -2
- package/src/lib/validation-modes.ts +3 -1
- package/src/multi-value-fields.test.tsx +116 -0
- package/src/submit-action.test.tsx +421 -0
- package/src/use-app-fields.test-d.ts +8 -25
- package/src/use-app-fields.test.tsx +8 -32
- package/src/use-form.cascade.test.tsx +4 -6
- package/src/use-form.focus-invalid.test.tsx +198 -0
- package/src/use-form.reset-defaults.test.tsx +125 -0
- package/src/use-form.test-d.ts +2 -3
- package/src/use-form.test.tsx +12 -14
- package/src/use-form.unsaved-warning.test.tsx +4 -13
- package/src/use-form.validation-modes.test.tsx +10 -8
- package/src/form.tsx +0 -105
- package/src/use-app-fields.tsx +0 -272
- package/src/use-form.tsx +0 -450
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import {
|
|
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 "
|
|
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
|
|
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 "
|
|
18
|
+
} from "@/components/ui/combobox"
|
|
11
19
|
import {
|
|
12
20
|
Field,
|
|
13
21
|
FieldDescription,
|
|
14
22
|
FieldError,
|
|
15
23
|
FieldLabel,
|
|
16
|
-
} from "
|
|
17
|
-
import { InputGroupAddon } from "
|
|
18
|
-
import { Spinner } from "
|
|
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 {
|
|
4
|
-
import {
|
|
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 "
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} from "
|
|
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}
|
package/src/fields/index.ts
CHANGED
|
@@ -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 "
|
|
9
|
-
import { Input } from "
|
|
10
|
-
import { InputGroup, InputGroupInput } from "
|
|
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 }
|