@zeno-lib/forms 0.1.0 → 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 CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@zeno-lib/forms",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "exports": {
5
5
  ".": "./src/index.ts",
6
6
  "./create-form": "./src/create-form.tsx",
7
+ "./form-dialog": "./src/form-dialog.tsx",
7
8
  "./lib/*": "./src/lib/*.ts",
8
9
  "./tanstack": "./src/tanstack.ts"
9
10
  },
@@ -41,6 +42,7 @@
41
42
  "@types/node": "25.6.0",
42
43
  "@types/react": "19.2.14",
43
44
  "@types/react-dom": "19.2.3",
45
+ "lucide-react": "^1.23.0",
44
46
  "typescript": "7.0.2",
45
47
  "vite": "8.0.16",
46
48
  "vitest": "4.1.11",
@@ -4,13 +4,17 @@
4
4
  import { createZenoForm } from "@zeno-lib/forms"
5
5
 
6
6
  import { CheckboxField } from "./fields/checkbox-field"
7
+ import { CheckboxGroupField } from "./fields/checkbox-group-field"
7
8
  import { ComboboxField } from "./fields/combobox-field"
8
9
  import { DatePickerField } from "./fields/date-picker-field"
9
10
  import { EmailField } from "./fields/email-field"
10
11
  import { InputField } from "./fields/input-field"
12
+ import { MoneyField } from "./fields/money-field"
13
+ import { MultiSelectField } from "./fields/multi-select-field"
11
14
  import { NumberField } from "./fields/number-field"
12
15
  import { OtpField } from "./fields/otp-field"
13
16
  import { PasswordField } from "./fields/password-field"
17
+ import { PercentageField } from "./fields/percentage-field"
14
18
  import { RadioGroupField } from "./fields/radio-group-field"
15
19
  import { ResetButton } from "./fields/reset-button"
16
20
  import { SelectField } from "./fields/select-field"
@@ -18,24 +22,30 @@ import { SliderField } from "./fields/slider-field"
18
22
  import { SubmitButton } from "./fields/submit-button"
19
23
  import { SwitchField } from "./fields/switch-field"
20
24
  import { TextAreaField } from "./fields/textarea-field"
25
+ import { YearField } from "./fields/year-field"
21
26
 
22
27
  // Wire the dropped-in field components into the headless factory. Edit the
23
28
  // field files under `./fields` freely — they use your own shadcn primitives.
24
29
  const { useAppForm, useForm, withFieldGroup, withForm } = createZenoForm({
25
30
  fieldComponents: {
26
31
  CheckboxField,
32
+ CheckboxGroupField,
27
33
  ComboboxField,
28
34
  DatePickerField,
29
35
  EmailField,
30
36
  InputField,
37
+ MoneyField,
38
+ MultiSelectField,
31
39
  NumberField,
32
40
  OtpField,
33
41
  PasswordField,
42
+ PercentageField,
34
43
  RadioGroupField,
35
44
  SelectField,
36
45
  SliderField,
37
46
  SwitchField,
38
47
  TextAreaField,
48
+ YearField,
39
49
  },
40
50
  formComponents: {
41
51
  ResetButton,
@@ -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,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"
@@ -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 }
@@ -0,0 +1,73 @@
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 { InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
6
+ import { InputField, type InputFieldProps } from "./input-field"
7
+
8
+ type PercentageFieldProps = Omit<
9
+ InputFieldProps,
10
+ "defaultValue" | "inputMode" | "onChange" | "onPaste" | "type" | "value"
11
+ > & {
12
+ /**
13
+ * How the form value relates to what the user types:
14
+ * - `"percent"` (default): the value is the number shown, `12.5` ↔ `12.5 %`.
15
+ * - `"fraction"`: the value is a ratio, `0.125` ↔ `12.5 %`.
16
+ */
17
+ scale?: "fraction" | "percent"
18
+ /** BCP 47 locale driving the decimal separator. Defaults to the runtime locale. */
19
+ locale?: string
20
+ /** Digits allowed after the decimal separator (of the shown percentage). Defaults to `2`. */
21
+ fractionDigits?: number
22
+ /** Accept negative percentages. Defaults to `false`. */
23
+ allowNegative?: boolean
24
+ /** Clamp on blur, in the form value's scale. */
25
+ min?: number
26
+ /** Clamp on blur, in the form value's scale. */
27
+ max?: number
28
+ }
29
+
30
+ const toPercent = (value: number) => value * 100
31
+ const fromPercent = (value: number) => value / 100
32
+
33
+ /**
34
+ * Percentage input with a `%` add-on. The form value is `number | null`, on
35
+ * the `scale` you pick (`0–100` by default, `0–1` with `scale="fraction"`).
36
+ */
37
+ function PercentageField({
38
+ allowNegative = false,
39
+ children,
40
+ fractionDigits = 2,
41
+ locale,
42
+ max,
43
+ min,
44
+ scale = "percent",
45
+ ...props
46
+ }: PercentageFieldProps) {
47
+ const field = useFieldContext<number | null | undefined>()
48
+ const isFraction = scale === "fraction"
49
+
50
+ const inputProps = useFormattedNumber({
51
+ allowNegative,
52
+ locale,
53
+ max,
54
+ maximumFractionDigits: fractionDigits,
55
+ min,
56
+ onBlur: field.handleBlur,
57
+ onValueChange: (next) => field.handleChange(next),
58
+ value: field.state.value,
59
+ ...(isFraction ? { fromDisplay: fromPercent, toDisplay: toPercent } : {}),
60
+ })
61
+
62
+ return (
63
+ <InputField {...props} {...inputProps}>
64
+ <InputGroupAddon align="inline-end">
65
+ <InputGroupText>%</InputGroupText>
66
+ </InputGroupAddon>
67
+ {children}
68
+ </InputField>
69
+ )
70
+ }
71
+
72
+ export type { PercentageFieldProps }
73
+ export { PercentageField }
@@ -0,0 +1,56 @@
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 { InputField, type InputFieldProps } from "./input-field"
6
+
7
+ type YearFieldProps = Omit<
8
+ InputFieldProps,
9
+ | "defaultValue"
10
+ | "inputMode"
11
+ | "max"
12
+ | "maxLength"
13
+ | "min"
14
+ | "onChange"
15
+ | "onPaste"
16
+ | "type"
17
+ | "value"
18
+ > & {
19
+ /** Earliest accepted year. The value is clamped to it on blur. */
20
+ min?: number
21
+ /** Latest accepted year. The value is clamped to it on blur. */
22
+ max?: number
23
+ }
24
+
25
+ /**
26
+ * Four-digit year input. The form value is an integer year or `null`. No
27
+ * thousands separator, digits only, clamped to `min`/`max` on blur.
28
+ */
29
+ function YearField({ max, min, ...props }: YearFieldProps) {
30
+ const field = useFieldContext<number | null | undefined>()
31
+
32
+ const inputProps = useFormattedNumber({
33
+ allowNegative: false,
34
+ max,
35
+ maxIntegerDigits: 4,
36
+ maximumFractionDigits: 0,
37
+ min,
38
+ onBlur: field.handleBlur,
39
+ onValueChange: (next) => field.handleChange(next),
40
+ useGrouping: false,
41
+ value: field.state.value,
42
+ })
43
+
44
+ return (
45
+ <InputField
46
+ autoComplete="off"
47
+ maxLength={4}
48
+ placeholder="YYYY"
49
+ {...props}
50
+ {...inputProps}
51
+ />
52
+ )
53
+ }
54
+
55
+ export type { YearFieldProps }
56
+ export { YearField }