@zeno-lib/forms 0.0.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 (48) hide show
  1. package/package.json +47 -0
  2. package/src/addons/index.ts +2 -0
  3. package/src/addons/validation-spinner.tsx +25 -0
  4. package/src/fields/checkbox-field.tsx +80 -0
  5. package/src/fields/combobox-field.tsx +189 -0
  6. package/src/fields/date-picker-field.tsx +141 -0
  7. package/src/fields/email-field.tsx +21 -0
  8. package/src/fields/index.ts +16 -0
  9. package/src/fields/input-field.tsx +95 -0
  10. package/src/fields/number-field.tsx +33 -0
  11. package/src/fields/otp-field.tsx +111 -0
  12. package/src/fields/password-field.tsx +20 -0
  13. package/src/fields/radio-group-field.tsx +105 -0
  14. package/src/fields/reset-button.tsx +29 -0
  15. package/src/fields/select-field.tsx +113 -0
  16. package/src/fields/slider-field.tsx +103 -0
  17. package/src/fields/submit-button.tsx +31 -0
  18. package/src/fields/switch-field.tsx +80 -0
  19. package/src/fields/textarea-field.tsx +91 -0
  20. package/src/form.tsx +105 -0
  21. package/src/lib/apply-validation-error.test.ts +97 -0
  22. package/src/lib/apply-validation-error.ts +31 -0
  23. package/src/lib/aria.ts +7 -0
  24. package/src/lib/contexts.ts +9 -0
  25. package/src/lib/required-indicator.tsx +22 -0
  26. package/src/lib/schema-defaults.test.ts +195 -0
  27. package/src/lib/schema-defaults.ts +127 -0
  28. package/src/lib/schema-required.test.ts +132 -0
  29. package/src/lib/schema-required.ts +64 -0
  30. package/src/lib/use-is-invalid.ts +109 -0
  31. package/src/lib/use-unsaved-changes-warning.ts +57 -0
  32. package/src/lib/validation-error.test.ts +41 -0
  33. package/src/lib/validation-error.ts +30 -0
  34. package/src/lib/validation-logic.test.ts +261 -0
  35. package/src/lib/validation-logic.ts +192 -0
  36. package/src/lib/validation-modes.test-d.ts +9 -0
  37. package/src/lib/validation-modes.test.ts +72 -0
  38. package/src/lib/validation-modes.ts +73 -0
  39. package/src/tanstack.ts +2 -0
  40. package/src/use-app-fields.test-d.ts +112 -0
  41. package/src/use-app-fields.test.tsx +163 -0
  42. package/src/use-app-fields.tsx +272 -0
  43. package/src/use-form.cascade.test.tsx +83 -0
  44. package/src/use-form.test-d.ts +153 -0
  45. package/src/use-form.test.tsx +348 -0
  46. package/src/use-form.tsx +450 -0
  47. package/src/use-form.unsaved-warning.test.tsx +192 -0
  48. package/src/use-form.validation-modes.test.tsx +149 -0
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@zeno-lib/forms",
3
+ "version": "0.0.0",
4
+ "exports": {
5
+ ".": "./src/form.tsx",
6
+ "./addons": "./src/addons/index.ts",
7
+ "./fields": "./src/fields/index.ts",
8
+ "./tanstack": "./src/tanstack.ts"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "repository": {
17
+ "url": "https://github.com/zeno-lib/zeno.git",
18
+ "directory": "packages/forms"
19
+ },
20
+ "peerDependencies": {
21
+ "@tanstack/react-devtools": ">=0.10",
22
+ "@tanstack/react-form-devtools": ">=0.2",
23
+ "next": ">=16",
24
+ "react": ">=19",
25
+ "react-dom": ">=19",
26
+ "zod": ">=4",
27
+ "@zeno-lib/ui": "^0.0.0"
28
+ },
29
+ "dependencies": {
30
+ "@tanstack/react-form": "1.29.1",
31
+ "@tanstack/react-form-nextjs": "1.29.1"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "25.6.0",
35
+ "@types/react": "19.2.14",
36
+ "@types/react-dom": "19.2.3",
37
+ "typescript": "6.0.3",
38
+ "zod": "^4.3.6",
39
+ "@zeno-lib/typescript": "^1.0.0",
40
+ "@zeno-lib/vitest": "^0.0.0"
41
+ },
42
+ "scripts": {
43
+ "test": "vitest --run",
44
+ "test:watch": "vitest watch",
45
+ "types:check": "tsc --noEmit"
46
+ }
47
+ }
@@ -0,0 +1,2 @@
1
+ /** biome-ignore-all lint/performance/noBarrelFile: single import surface for all addon helpers */
2
+ export * from "./validation-spinner"
@@ -0,0 +1,25 @@
1
+ "use client"
2
+
3
+ import { InputGroupAddon } from "@zeno-lib/ui/input-group"
4
+ import { Spinner } from "@zeno-lib/ui/spinner"
5
+
6
+ import { useFieldContext } from "../lib/contexts"
7
+
8
+ type ValidationSpinnerProps = {
9
+ align?: "inline-start" | "inline-end"
10
+ }
11
+
12
+ function ValidationSpinner({ align = "inline-end" }: ValidationSpinnerProps) {
13
+ const field = useFieldContext()
14
+ if (!field.state.meta.isValidating) {
15
+ return null
16
+ }
17
+ return (
18
+ <InputGroupAddon align={align}>
19
+ <Spinner />
20
+ </InputGroupAddon>
21
+ )
22
+ }
23
+
24
+ export type { ValidationSpinnerProps }
25
+ export { ValidationSpinner }
@@ -0,0 +1,80 @@
1
+ "use client"
2
+
3
+ import { Checkbox } from "@zeno-lib/ui/checkbox"
4
+ import {
5
+ Field,
6
+ FieldContent,
7
+ FieldDescription,
8
+ FieldError,
9
+ 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"
15
+ import { RequiredIndicator } from "../lib/required-indicator"
16
+ import {
17
+ useHideFieldErrors,
18
+ useIsFieldRequired,
19
+ useIsInvalid,
20
+ } from "../lib/use-is-invalid"
21
+
22
+ type CheckboxFieldProps = Omit<
23
+ ComponentProps<typeof Checkbox>,
24
+ "checked" | "id" | "name" | "onBlur" | "onCheckedChange"
25
+ > & {
26
+ description?: ReactNode
27
+ label?: ReactNode
28
+ /** Force the required `*` indicator on or off. Defaults to schema-derived. */
29
+ required?: boolean
30
+ }
31
+
32
+ function CheckboxField({
33
+ description,
34
+ label,
35
+ required,
36
+ ...props
37
+ }: CheckboxFieldProps) {
38
+ const field = useFieldContext<boolean>()
39
+ const errorId = `${field.name}-error`
40
+ const descriptionId = `${field.name}-description`
41
+ const isInvalid = useIsInvalid(field)
42
+ const hideErrors = useHideFieldErrors(field)
43
+ const showError = isInvalid && !hideErrors
44
+ const schemaRequired = useIsFieldRequired(field)
45
+ const isRequired = required ?? schemaRequired
46
+
47
+ return (
48
+ <Field data-invalid={isInvalid} orientation="horizontal">
49
+ <Checkbox
50
+ aria-describedby={describedBy(
51
+ [description, descriptionId],
52
+ [showError, errorId]
53
+ )}
54
+ aria-invalid={isInvalid || undefined}
55
+ checked={field.state.value ?? false}
56
+ id={field.name}
57
+ name={field.name}
58
+ onBlur={field.handleBlur}
59
+ onCheckedChange={(value) => field.handleChange(value === true)}
60
+ {...props}
61
+ />
62
+ <FieldContent>
63
+ {label && (
64
+ <FieldLabel className="font-normal" htmlFor={field.name}>
65
+ {label}
66
+ {isRequired && <RequiredIndicator />}
67
+ </FieldLabel>
68
+ )}
69
+ {description && (
70
+ <FieldDescription id={descriptionId}>{description}</FieldDescription>
71
+ )}
72
+ {showError && (
73
+ <FieldError errors={field.state.meta.errors} id={errorId} />
74
+ )}
75
+ </FieldContent>
76
+ </Field>
77
+ )
78
+ }
79
+
80
+ export { CheckboxField }
@@ -0,0 +1,189 @@
1
+ "use client"
2
+
3
+ import {
4
+ Combobox,
5
+ ComboboxContent,
6
+ ComboboxEmpty,
7
+ ComboboxInput,
8
+ ComboboxItem,
9
+ ComboboxList,
10
+ } from "@zeno-lib/ui/combobox"
11
+ import {
12
+ Field,
13
+ FieldDescription,
14
+ FieldError,
15
+ 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"
23
+ import { RequiredIndicator } from "../lib/required-indicator"
24
+ import {
25
+ useHideFieldErrors,
26
+ useIsFieldRequired,
27
+ useIsInvalid,
28
+ } from "../lib/use-is-invalid"
29
+
30
+ type ComboboxItemObject<V> = { value: V; label: string }
31
+
32
+ type ComboboxFieldProps<T = string> = {
33
+ /**
34
+ * Items shown in the dropdown. Pass plain strings (value === label) or
35
+ * `{ value, label }` objects when the value isn't a string (e.g. numeric
36
+ * database IDs).
37
+ */
38
+ items: readonly T[]
39
+ description?: ReactNode
40
+ label?: ReactNode
41
+ placeholder?: string
42
+ /** Message shown when filtering produces zero matches. */
43
+ emptyMessage?: ReactNode
44
+ /** Override per-row rendering. Default: plain `<ComboboxItem>{label}</ComboboxItem>`. */
45
+ renderItem?: (item: T) => ReactNode
46
+ /**
47
+ * If `true`, show the clear button when a value is selected. Defaults to
48
+ * `true` — pass `false` for required-style comboboxes where clearing is
49
+ * disallowed.
50
+ */
51
+ showClear?: boolean
52
+ className?: string
53
+ /** Force the required `*` indicator on or off. Defaults to schema-derived. */
54
+ required?: boolean
55
+ /**
56
+ * Controlled input text — pair with `onInputValueChange` for server-side
57
+ * search. When omitted, Base UI manages the input value internally.
58
+ */
59
+ inputValue?: string
60
+ /** Fires on every keystroke in the search input. */
61
+ onInputValueChange?: (value: string) => void
62
+ /**
63
+ * Replace Base UI's built-in client-side filter. Pass `null` for
64
+ * server-driven search where `items` is already filtered by the consumer.
65
+ */
66
+ filter?: ((item: T, query: string) => boolean) | null
67
+ /**
68
+ * Show a spinner addon inside the input while options are being fetched.
69
+ * Pair with `onInputValueChange` to drive a debounced server query.
70
+ */
71
+ loading?: boolean
72
+ }
73
+
74
+ function isItemObject(item: unknown): item is ComboboxItemObject<unknown> {
75
+ return (
76
+ typeof item === "object" &&
77
+ item !== null &&
78
+ "value" in item &&
79
+ "label" in item
80
+ )
81
+ }
82
+
83
+ function ComboboxField<T = string>({
84
+ className,
85
+ description,
86
+ emptyMessage = "No results.",
87
+ filter,
88
+ inputValue,
89
+ items,
90
+ label,
91
+ loading,
92
+ onInputValueChange,
93
+ placeholder,
94
+ renderItem,
95
+ required,
96
+ showClear = true,
97
+ }: ComboboxFieldProps<T>) {
98
+ const field = useFieldContext()
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 fieldValue = field.state.value
108
+ const hasObjectItems = items.some(isItemObject)
109
+ const value = hasObjectItems
110
+ ? ((items.find((item) => isItemObject(item) && item.value === fieldValue) ??
111
+ null) as T | null)
112
+ : ((fieldValue ?? null) as T | null)
113
+
114
+ return (
115
+ <Field data-invalid={isInvalid}>
116
+ {label && (
117
+ <FieldLabel htmlFor={field.name}>
118
+ {label}
119
+ {isRequired && <RequiredIndicator />}
120
+ </FieldLabel>
121
+ )}
122
+ <Combobox
123
+ filter={filter}
124
+ inputValue={inputValue}
125
+ items={items}
126
+ onInputValueChange={onInputValueChange}
127
+ onValueChange={(next) => {
128
+ if (next == null) {
129
+ field.handleChange(undefined)
130
+ return
131
+ }
132
+ field.handleChange(isItemObject(next) ? next.value : next)
133
+ }}
134
+ value={value}
135
+ >
136
+ <ComboboxInput
137
+ aria-describedby={describedBy(
138
+ [description, descriptionId],
139
+ [showError, errorId]
140
+ )}
141
+ aria-invalid={isInvalid || undefined}
142
+ className={className}
143
+ id={field.name}
144
+ name={field.name}
145
+ onBlur={field.handleBlur}
146
+ placeholder={placeholder}
147
+ showClear={showClear && value != null && value !== "" && !loading}
148
+ >
149
+ {loading && (
150
+ <InputGroupAddon align="inline-end">
151
+ <Spinner />
152
+ </InputGroupAddon>
153
+ )}
154
+ </ComboboxInput>
155
+ <ComboboxContent>
156
+ <ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
157
+ <ComboboxList>
158
+ {(item: T) => {
159
+ if (renderItem) {
160
+ return renderItem(item)
161
+ }
162
+ if (isItemObject(item)) {
163
+ return (
164
+ <ComboboxItem key={String(item.value)} value={item}>
165
+ {item.label}
166
+ </ComboboxItem>
167
+ )
168
+ }
169
+ return (
170
+ <ComboboxItem key={String(item)} value={item}>
171
+ {String(item)}
172
+ </ComboboxItem>
173
+ )
174
+ }}
175
+ </ComboboxList>
176
+ </ComboboxContent>
177
+ </Combobox>
178
+ {description && (
179
+ <FieldDescription id={descriptionId}>{description}</FieldDescription>
180
+ )}
181
+ {showError && (
182
+ <FieldError errors={field.state.meta.errors} id={errorId} />
183
+ )}
184
+ </Field>
185
+ )
186
+ }
187
+
188
+ export type { ComboboxFieldProps, ComboboxItemObject }
189
+ export { ComboboxField }
@@ -0,0 +1,141 @@
1
+ "use client"
2
+
3
+ import { buttonVariants } from "@zeno-lib/ui/button"
4
+ import { Calendar } from "@zeno-lib/ui/calendar"
5
+ import {
6
+ Field,
7
+ FieldDescription,
8
+ FieldError,
9
+ 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
+ import {
19
+ useHideFieldErrors,
20
+ useIsFieldRequired,
21
+ useIsInvalid,
22
+ } from "../lib/use-is-invalid"
23
+
24
+ type CalendarProps = ComponentProps<typeof Calendar>
25
+
26
+ type DatePickerFieldProps = {
27
+ description?: ReactNode
28
+ label?: ReactNode
29
+ /** Trigger placeholder when the value is empty. */
30
+ placeholder?: ReactNode
31
+ /** Override the default `toLocaleDateString()` formatter. */
32
+ formatValue?: (date: Date) => string
33
+ triggerClassName?: string
34
+ /** Pass-through to the underlying `<Calendar>` (e.g. `disabled`, `locale`). */
35
+ calendarProps?: Omit<CalendarProps, "mode" | "onSelect" | "selected">
36
+ /** Force the required `*` indicator on or off. Defaults to schema-derived. */
37
+ required?: boolean
38
+ }
39
+
40
+ const FALLBACK_PLACEHOLDER = "Pick a date"
41
+
42
+ function defaultFormat(date: Date): string {
43
+ return date.toLocaleDateString(undefined, {
44
+ day: "numeric",
45
+ month: "long",
46
+ year: "numeric",
47
+ })
48
+ }
49
+
50
+ function DatePickerField({
51
+ calendarProps,
52
+ description,
53
+ formatValue = defaultFormat,
54
+ label,
55
+ placeholder = FALLBACK_PLACEHOLDER,
56
+ required,
57
+ triggerClassName,
58
+ }: DatePickerFieldProps) {
59
+ const field = useFieldContext<Date | undefined>()
60
+ const errorId = `${field.name}-error`
61
+ const descriptionId = `${field.name}-description`
62
+ const isInvalid = useIsInvalid(field)
63
+ const hideErrors = useHideFieldErrors(field)
64
+ const showError = isInvalid && !hideErrors
65
+ const schemaRequired = useIsFieldRequired(field)
66
+ const isRequired = required ?? schemaRequired
67
+
68
+ const value = field.state.value
69
+ const empty = !value
70
+
71
+ return (
72
+ <Field data-invalid={isInvalid}>
73
+ {label && (
74
+ <FieldLabel htmlFor={field.name}>
75
+ {label}
76
+ {isRequired && <RequiredIndicator />}
77
+ </FieldLabel>
78
+ )}
79
+ <Popover>
80
+ <PopoverTrigger
81
+ aria-describedby={describedBy(
82
+ [description, descriptionId],
83
+ [showError, errorId]
84
+ )}
85
+ aria-invalid={isInvalid || undefined}
86
+ className={cn(
87
+ buttonVariants({ variant: "outline" }),
88
+ "w-full justify-start text-left font-normal data-[empty=true]:text-muted-foreground",
89
+ triggerClassName
90
+ )}
91
+ data-empty={empty || undefined}
92
+ id={field.name}
93
+ // Base UI's Popover handles open state — we still need blur on the
94
+ // form's reactive store. Passing onBlur here mirrors how `<select>`
95
+ // commits on close.
96
+ onBlur={field.handleBlur}
97
+ >
98
+ <CalendarIcon />
99
+ {empty ? <span>{placeholder}</span> : formatValue(value)}
100
+ </PopoverTrigger>
101
+ <PopoverContent className="w-auto p-0">
102
+ <Calendar
103
+ mode="single"
104
+ onSelect={(next) => field.handleChange(next)}
105
+ selected={value}
106
+ {...calendarProps}
107
+ />
108
+ </PopoverContent>
109
+ </Popover>
110
+ {description && (
111
+ <FieldDescription id={descriptionId}>{description}</FieldDescription>
112
+ )}
113
+ {showError && (
114
+ <FieldError errors={field.state.meta.errors} id={errorId} />
115
+ )}
116
+ </Field>
117
+ )
118
+ }
119
+
120
+ function CalendarIcon() {
121
+ return (
122
+ <svg
123
+ aria-hidden
124
+ className="size-4 shrink-0 opacity-60"
125
+ fill="none"
126
+ role="img"
127
+ stroke="currentColor"
128
+ strokeLinecap="round"
129
+ strokeLinejoin="round"
130
+ strokeWidth="2"
131
+ viewBox="0 0 24 24"
132
+ xmlns="http://www.w3.org/2000/svg"
133
+ >
134
+ <title>Calendar</title>
135
+ <path d="M8 2v3M16 2v3M3 10h18M5 4h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" />
136
+ </svg>
137
+ )
138
+ }
139
+
140
+ export type { DatePickerFieldProps }
141
+ export { DatePickerField }
@@ -0,0 +1,21 @@
1
+ import { InputField, type InputFieldProps } from "./input-field"
2
+
3
+ type EmailFieldProps = Omit<InputFieldProps, "inputMode" | "type">
4
+
5
+ function EmailField({
6
+ autoComplete = "email",
7
+ label = "Email",
8
+ ...props
9
+ }: EmailFieldProps) {
10
+ return (
11
+ <InputField
12
+ autoComplete={autoComplete}
13
+ inputMode="email"
14
+ label={label}
15
+ type="email"
16
+ {...props}
17
+ />
18
+ )
19
+ }
20
+
21
+ export { EmailField }
@@ -0,0 +1,16 @@
1
+ /** biome-ignore-all lint/performance/noBarrelFile: single import surface for all field components */
2
+ export * from "./checkbox-field"
3
+ export * from "./combobox-field"
4
+ export * from "./date-picker-field"
5
+ export * from "./email-field"
6
+ export * from "./input-field"
7
+ export * from "./number-field"
8
+ export * from "./otp-field"
9
+ export * from "./password-field"
10
+ export * from "./radio-group-field"
11
+ export * from "./reset-button"
12
+ export * from "./select-field"
13
+ export * from "./slider-field"
14
+ export * from "./submit-button"
15
+ export * from "./switch-field"
16
+ export * from "./textarea-field"
@@ -0,0 +1,95 @@
1
+ "use client"
2
+
3
+ import {
4
+ Field,
5
+ FieldDescription,
6
+ FieldError,
7
+ 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"
15
+ import { RequiredIndicator } from "../lib/required-indicator"
16
+ import {
17
+ useHideFieldErrors,
18
+ useIsFieldRequired,
19
+ useIsInvalid,
20
+ } from "../lib/use-is-invalid"
21
+
22
+ type InputFieldProps = Omit<
23
+ ComponentProps<typeof Input>,
24
+ "children" | "id" | "name" | "onBlur"
25
+ > & {
26
+ children?: ReactNode
27
+ description?: ReactNode
28
+ label?: ReactNode
29
+ /**
30
+ * Force the required `*` indicator on or off for this field. When omitted,
31
+ * the value is derived from the form's schema.
32
+ */
33
+ required?: boolean
34
+ }
35
+
36
+ function InputField({
37
+ children,
38
+ description,
39
+ label,
40
+ required,
41
+ ...props
42
+ }: InputFieldProps) {
43
+ const field = useFieldContext<string>()
44
+ const errorId = `${field.name}-error`
45
+ const descriptionId = `${field.name}-description`
46
+ const isInvalid = useIsInvalid(field)
47
+ const hideErrors = useHideFieldErrors(field)
48
+ const showError = isInvalid && !hideErrors
49
+ const hasAddons = Children.count(children) > 0
50
+ const schemaRequired = useIsFieldRequired(field)
51
+ const isRequired = required ?? schemaRequired
52
+
53
+ const inputProps = {
54
+ "aria-describedby": describedBy(
55
+ [description, descriptionId],
56
+ [showError, errorId]
57
+ ),
58
+ "aria-invalid": isInvalid || undefined,
59
+ id: field.name,
60
+ name: field.name,
61
+ onBlur: field.handleBlur,
62
+ onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
63
+ field.handleChange(event.target.value),
64
+ value: field.state.value ?? "",
65
+ ...props,
66
+ }
67
+
68
+ return (
69
+ <Field data-invalid={isInvalid}>
70
+ {label && (
71
+ <FieldLabel htmlFor={field.name}>
72
+ {label}
73
+ {isRequired && <RequiredIndicator />}
74
+ </FieldLabel>
75
+ )}
76
+ {hasAddons ? (
77
+ <InputGroup>
78
+ <InputGroupInput {...inputProps} />
79
+ {children}
80
+ </InputGroup>
81
+ ) : (
82
+ <Input {...inputProps} />
83
+ )}
84
+ {description && (
85
+ <FieldDescription id={descriptionId}>{description}</FieldDescription>
86
+ )}
87
+ {showError && (
88
+ <FieldError errors={field.state.meta.errors} id={errorId} />
89
+ )}
90
+ </Field>
91
+ )
92
+ }
93
+
94
+ export type { InputFieldProps }
95
+ export { InputField }
@@ -0,0 +1,33 @@
1
+ "use client"
2
+
3
+ import type { ChangeEvent } from "react"
4
+
5
+ import { useFieldContext } from "../lib/contexts"
6
+ import { InputField, type InputFieldProps } from "./input-field"
7
+
8
+ type NumberFieldProps = Omit<
9
+ InputFieldProps,
10
+ "inputMode" | "onChange" | "type" | "value"
11
+ >
12
+
13
+ function NumberField(props: NumberFieldProps) {
14
+ const field = useFieldContext<number | undefined>()
15
+ const value = field.state.value
16
+ const inputValue =
17
+ typeof value === "number" && !Number.isNaN(value) ? value : ""
18
+
19
+ return (
20
+ <InputField
21
+ {...props}
22
+ inputMode="numeric"
23
+ onChange={(event: ChangeEvent<HTMLInputElement>) => {
24
+ const next = event.target.valueAsNumber
25
+ field.handleChange(Number.isNaN(next) ? undefined : next)
26
+ }}
27
+ type="number"
28
+ value={inputValue}
29
+ />
30
+ )
31
+ }
32
+
33
+ export { NumberField }