@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.
- package/package.json +47 -0
- package/src/addons/index.ts +2 -0
- package/src/addons/validation-spinner.tsx +25 -0
- package/src/fields/checkbox-field.tsx +80 -0
- package/src/fields/combobox-field.tsx +189 -0
- package/src/fields/date-picker-field.tsx +141 -0
- package/src/fields/email-field.tsx +21 -0
- package/src/fields/index.ts +16 -0
- package/src/fields/input-field.tsx +95 -0
- package/src/fields/number-field.tsx +33 -0
- package/src/fields/otp-field.tsx +111 -0
- package/src/fields/password-field.tsx +20 -0
- package/src/fields/radio-group-field.tsx +105 -0
- package/src/fields/reset-button.tsx +29 -0
- package/src/fields/select-field.tsx +113 -0
- package/src/fields/slider-field.tsx +103 -0
- package/src/fields/submit-button.tsx +31 -0
- package/src/fields/switch-field.tsx +80 -0
- package/src/fields/textarea-field.tsx +91 -0
- package/src/form.tsx +105 -0
- package/src/lib/apply-validation-error.test.ts +97 -0
- package/src/lib/apply-validation-error.ts +31 -0
- package/src/lib/aria.ts +7 -0
- package/src/lib/contexts.ts +9 -0
- package/src/lib/required-indicator.tsx +22 -0
- package/src/lib/schema-defaults.test.ts +195 -0
- package/src/lib/schema-defaults.ts +127 -0
- package/src/lib/schema-required.test.ts +132 -0
- package/src/lib/schema-required.ts +64 -0
- package/src/lib/use-is-invalid.ts +109 -0
- package/src/lib/use-unsaved-changes-warning.ts +57 -0
- package/src/lib/validation-error.test.ts +41 -0
- package/src/lib/validation-error.ts +30 -0
- package/src/lib/validation-logic.test.ts +261 -0
- package/src/lib/validation-logic.ts +192 -0
- package/src/lib/validation-modes.test-d.ts +9 -0
- package/src/lib/validation-modes.test.ts +72 -0
- package/src/lib/validation-modes.ts +73 -0
- package/src/tanstack.ts +2 -0
- package/src/use-app-fields.test-d.ts +112 -0
- package/src/use-app-fields.test.tsx +163 -0
- package/src/use-app-fields.tsx +272 -0
- package/src/use-form.cascade.test.tsx +83 -0
- package/src/use-form.test-d.ts +153 -0
- package/src/use-form.test.tsx +348 -0
- package/src/use-form.tsx +450 -0
- package/src/use-form.unsaved-warning.test.tsx +192 -0
- 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,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 }
|