@zeno-lib/forms 0.2.2 → 0.4.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/dist/create-zeno-form.mjs +5 -3
- package/dist/index.d.mts +3 -2
- package/dist/index.mjs +4 -3
- package/dist/lib/empty-value.d.mts +12 -0
- package/dist/lib/empty-value.mjs +68 -0
- package/dist/lib/schema-defaults.d.mts +9 -1
- package/dist/lib/schema-defaults.mjs +33 -1
- package/dist/lib/schema-required.d.mts +2 -1
- package/dist/lib/schema-required.mjs +67 -18
- package/dist/lib/submit-action.mjs +1 -1
- package/dist/lib/use-form-dialog.d.mts +3 -2
- package/dist/lib/use-form-dialog.mjs +7 -3
- package/dist/lib/use-is-invalid.d.mts +2 -1
- package/dist/lib/use-is-invalid.mjs +5 -2
- package/dist/lib/validation-modes.d.mts +5 -1
- package/dist/lib/validation-modes.mjs +9 -4
- package/package.json +5 -3
- package/src/create-zeno-form.tsx +13 -5
- package/src/fields/checkbox-field.tsx +14 -6
- package/src/fields/checkbox-group-field.tsx +8 -7
- package/src/fields/combobox-field.tsx +35 -17
- package/src/fields/date-picker-field.tsx +42 -13
- package/src/fields/email-field.tsx +8 -0
- package/src/fields/input-field.tsx +12 -7
- package/src/fields/money-field.tsx +3 -1
- package/src/fields/multi-select-field.tsx +14 -6
- package/src/fields/number-field.tsx +9 -7
- package/src/fields/otp-field.tsx +14 -6
- package/src/fields/percentage-field.tsx +3 -1
- package/src/fields/radio-group-field.tsx +14 -6
- package/src/fields/select-field.tsx +31 -7
- package/src/fields/slider-field.tsx +6 -5
- package/src/fields/switch-field.tsx +14 -6
- package/src/fields/textarea-field.tsx +14 -6
- package/src/fields/year-field.tsx +3 -1
- package/src/index.ts +2 -0
- package/src/lib/empty-value.test.ts +72 -0
- package/src/lib/empty-value.ts +130 -0
- package/src/lib/schema-defaults.ts +50 -1
- package/src/lib/schema-required.test.ts +45 -3
- package/src/lib/schema-required.ts +113 -29
- package/src/lib/use-form-dialog.ts +11 -3
- package/src/lib/use-is-invalid.ts +14 -1
- package/src/lib/validation-modes.ts +18 -6
- package/src/use-app-fields.test.tsx +341 -4
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import { describedBy } from "@zeno-lib/forms/lib/aria"
|
|
4
4
|
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
5
|
+
import { getEmptyValue } from "@zeno-lib/forms/lib/empty-value"
|
|
5
6
|
import {
|
|
6
7
|
useHideFieldErrors,
|
|
7
8
|
useIsFieldRequired,
|
|
9
|
+
useIsFieldRequiredBySchema,
|
|
8
10
|
useIsInvalid,
|
|
9
11
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import type
|
|
12
|
+
import { type ReactNode, useId } from "react"
|
|
11
13
|
import {
|
|
12
14
|
Combobox,
|
|
13
15
|
ComboboxContent,
|
|
@@ -43,13 +45,17 @@ type ComboboxFieldProps<T = string> = {
|
|
|
43
45
|
/** Override per-row rendering. Default: plain `<ComboboxItem>{label}</ComboboxItem>`. */
|
|
44
46
|
renderItem?: (item: T) => ReactNode
|
|
45
47
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
48
|
+
* Show the clear button when a value is selected. Defaults to `true` on an
|
|
49
|
+
* optional field and `false` on a required one (from the schema or
|
|
50
|
+
* `required`), where clearing could only make the form invalid.
|
|
49
51
|
*/
|
|
50
52
|
showClear?: boolean
|
|
53
|
+
disabled?: boolean
|
|
51
54
|
className?: string
|
|
52
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Mark the field required, or not, over the schema. Drives the `*`,
|
|
57
|
+
* `aria-required` and the `showClear` default.
|
|
58
|
+
*/
|
|
53
59
|
required?: boolean
|
|
54
60
|
/**
|
|
55
61
|
* Controlled input text — pair with `onInputValueChange` for server-side
|
|
@@ -82,6 +88,7 @@ function isItemObject(item: unknown): item is ComboboxItemObject<unknown> {
|
|
|
82
88
|
function ComboboxField<T = string>({
|
|
83
89
|
className,
|
|
84
90
|
description,
|
|
91
|
+
disabled,
|
|
85
92
|
emptyMessage = "No results.",
|
|
86
93
|
filter,
|
|
87
94
|
inputValue,
|
|
@@ -92,40 +99,49 @@ function ComboboxField<T = string>({
|
|
|
92
99
|
placeholder,
|
|
93
100
|
renderItem,
|
|
94
101
|
required,
|
|
95
|
-
showClear
|
|
102
|
+
showClear,
|
|
96
103
|
}: ComboboxFieldProps<T>) {
|
|
97
104
|
const field = useFieldContext()
|
|
98
|
-
const
|
|
99
|
-
const
|
|
105
|
+
const id = useId()
|
|
106
|
+
const errorId = `${id}-error`
|
|
107
|
+
const descriptionId = `${id}-description`
|
|
100
108
|
const isInvalid = useIsInvalid(field)
|
|
101
109
|
const hideErrors = useHideFieldErrors(field)
|
|
102
110
|
const showError = isInvalid && !hideErrors
|
|
103
111
|
const schemaRequired = useIsFieldRequired(field)
|
|
104
112
|
const isRequired = required ?? schemaRequired
|
|
113
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
114
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
115
|
+
const canClear = showClear ?? !requiresValue
|
|
105
116
|
|
|
106
117
|
const fieldValue = field.state.value
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
118
|
+
// Base UI shows the value it gets as text, so a stored id whose item isn't
|
|
119
|
+
// in `items` (options still loading, or a search that dropped it) would
|
|
120
|
+
// show raw. Only a plain string or number item is its own label.
|
|
121
|
+
const match = items.find(
|
|
122
|
+
(item) => (isItemObject(item) ? item.value : item) === fieldValue
|
|
123
|
+
)
|
|
124
|
+
const plainItems = items.length > 0 && !items.some(isItemObject)
|
|
125
|
+
const value = (match ??
|
|
126
|
+
(plainItems ? (fieldValue ?? null) : null)) as T | null
|
|
112
127
|
|
|
113
128
|
return (
|
|
114
129
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
115
130
|
{label && (
|
|
116
|
-
<FieldLabel htmlFor={
|
|
131
|
+
<FieldLabel htmlFor={id}>
|
|
117
132
|
{label}
|
|
118
133
|
{isRequired && <RequiredIndicator />}
|
|
119
134
|
</FieldLabel>
|
|
120
135
|
)}
|
|
121
136
|
<Combobox
|
|
137
|
+
disabled={disabled}
|
|
122
138
|
filter={filter}
|
|
123
139
|
inputValue={inputValue}
|
|
124
140
|
items={items}
|
|
125
141
|
onInputValueChange={onInputValueChange}
|
|
126
142
|
onValueChange={(next) => {
|
|
127
143
|
if (next === null) {
|
|
128
|
-
field.handleChange(undefined)
|
|
144
|
+
field.handleChange(getEmptyValue(field, undefined))
|
|
129
145
|
return
|
|
130
146
|
}
|
|
131
147
|
field.handleChange(isItemObject(next) ? next.value : next)
|
|
@@ -138,12 +154,14 @@ function ComboboxField<T = string>({
|
|
|
138
154
|
[showError, errorId]
|
|
139
155
|
)}
|
|
140
156
|
aria-invalid={isInvalid || undefined}
|
|
157
|
+
aria-required={requiresValue || undefined}
|
|
141
158
|
className={className}
|
|
142
|
-
|
|
159
|
+
disabled={disabled}
|
|
160
|
+
id={id}
|
|
143
161
|
name={field.name}
|
|
144
162
|
onBlur={field.handleBlur}
|
|
145
163
|
placeholder={placeholder}
|
|
146
|
-
showClear={
|
|
164
|
+
showClear={canClear && value !== null && value !== "" && !loading}
|
|
147
165
|
>
|
|
148
166
|
{loading && (
|
|
149
167
|
<InputGroupAddon align="inline-end">
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import { describedBy } from "@zeno-lib/forms/lib/aria"
|
|
4
4
|
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
5
|
+
import { getEmptyValue } from "@zeno-lib/forms/lib/empty-value"
|
|
5
6
|
import {
|
|
6
7
|
useHideFieldErrors,
|
|
7
8
|
useIsFieldRequired,
|
|
9
|
+
useIsFieldRequiredBySchema,
|
|
8
10
|
useIsInvalid,
|
|
9
11
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import type
|
|
12
|
+
import { type ComponentProps, type ReactNode, useId, useRef } from "react"
|
|
11
13
|
import { buttonVariants } from "@/components/ui/button"
|
|
12
14
|
import { Calendar } from "@/components/ui/calendar"
|
|
13
15
|
import {
|
|
@@ -35,8 +37,15 @@ type DatePickerFieldProps = {
|
|
|
35
37
|
formatValue?: (date: Date) => string
|
|
36
38
|
triggerClassName?: string
|
|
37
39
|
/** Pass-through to the underlying `<Calendar>` (e.g. `disabled`, `locale`). */
|
|
38
|
-
calendarProps?: Omit<
|
|
39
|
-
|
|
40
|
+
calendarProps?: Omit<
|
|
41
|
+
CalendarProps,
|
|
42
|
+
"mode" | "onSelect" | "required" | "selected"
|
|
43
|
+
>
|
|
44
|
+
disabled?: boolean
|
|
45
|
+
/**
|
|
46
|
+
* Mark the field required, or not, over the schema. A required date shows
|
|
47
|
+
* the `*`, and picking its selected day again keeps it instead of clearing.
|
|
48
|
+
*/
|
|
40
49
|
required?: boolean
|
|
41
50
|
}
|
|
42
51
|
|
|
@@ -53,6 +62,7 @@ function defaultFormat(date: Date): string {
|
|
|
53
62
|
function DatePickerField({
|
|
54
63
|
calendarProps,
|
|
55
64
|
description,
|
|
65
|
+
disabled,
|
|
56
66
|
formatValue = defaultFormat,
|
|
57
67
|
label,
|
|
58
68
|
placeholder = FALLBACK_PLACEHOLDER,
|
|
@@ -60,13 +70,20 @@ function DatePickerField({
|
|
|
60
70
|
triggerClassName,
|
|
61
71
|
}: DatePickerFieldProps) {
|
|
62
72
|
const field = useFieldContext<Date | undefined>()
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
// Opening the popover moves focus into it, so the trigger's blur would mark
|
|
74
|
+
// the field touched, and show a required error, before a day is picked.
|
|
75
|
+
// The field is touched once the popover closes, or focus leaves it closed.
|
|
76
|
+
const isOpen = useRef<boolean>(false)
|
|
77
|
+
const id = useId()
|
|
78
|
+
const errorId = `${id}-error`
|
|
79
|
+
const descriptionId = `${id}-description`
|
|
65
80
|
const isInvalid = useIsInvalid(field)
|
|
66
81
|
const hideErrors = useHideFieldErrors(field)
|
|
67
82
|
const showError = isInvalid && !hideErrors
|
|
68
83
|
const schemaRequired = useIsFieldRequired(field)
|
|
69
84
|
const isRequired = required ?? schemaRequired
|
|
85
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
86
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
70
87
|
|
|
71
88
|
const { value } = field.state
|
|
72
89
|
const empty = !value
|
|
@@ -74,12 +91,19 @@ function DatePickerField({
|
|
|
74
91
|
return (
|
|
75
92
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
76
93
|
{label && (
|
|
77
|
-
<FieldLabel htmlFor={
|
|
94
|
+
<FieldLabel htmlFor={id}>
|
|
78
95
|
{label}
|
|
79
96
|
{isRequired && <RequiredIndicator />}
|
|
80
97
|
</FieldLabel>
|
|
81
98
|
)}
|
|
82
|
-
<Popover
|
|
99
|
+
<Popover
|
|
100
|
+
onOpenChange={(open) => {
|
|
101
|
+
isOpen.current = open
|
|
102
|
+
if (!open) {
|
|
103
|
+
field.handleBlur()
|
|
104
|
+
}
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
83
107
|
<PopoverTrigger
|
|
84
108
|
aria-describedby={describedBy(
|
|
85
109
|
[description, descriptionId],
|
|
@@ -92,11 +116,13 @@ function DatePickerField({
|
|
|
92
116
|
triggerClassName
|
|
93
117
|
)}
|
|
94
118
|
data-empty={empty || undefined}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
119
|
+
disabled={disabled}
|
|
120
|
+
id={id}
|
|
121
|
+
onBlur={() => {
|
|
122
|
+
if (!isOpen.current) {
|
|
123
|
+
field.handleBlur()
|
|
124
|
+
}
|
|
125
|
+
}}
|
|
100
126
|
>
|
|
101
127
|
<CalendarIcon />
|
|
102
128
|
{empty ? <span>{placeholder}</span> : formatValue(value)}
|
|
@@ -104,7 +130,10 @@ function DatePickerField({
|
|
|
104
130
|
<PopoverContent className="w-auto p-0">
|
|
105
131
|
<Calendar
|
|
106
132
|
mode="single"
|
|
107
|
-
onSelect={(next) =>
|
|
133
|
+
onSelect={(next: Date | undefined) =>
|
|
134
|
+
field.handleChange(next ?? getEmptyValue(field, undefined))
|
|
135
|
+
}
|
|
136
|
+
required={requiresValue}
|
|
108
137
|
selected={value}
|
|
109
138
|
{...calendarProps}
|
|
110
139
|
/>
|
|
@@ -2,16 +2,24 @@ import { InputField, type InputFieldProps } from "./input-field"
|
|
|
2
2
|
|
|
3
3
|
type EmailFieldProps = Omit<InputFieldProps, "inputMode" | "type">
|
|
4
4
|
|
|
5
|
+
// An address is typed as is: no capital first letter, autocorrect or spell
|
|
6
|
+
// check.
|
|
5
7
|
function EmailField({
|
|
8
|
+
autoCapitalize = "none",
|
|
6
9
|
autoComplete = "email",
|
|
10
|
+
autoCorrect = "off",
|
|
7
11
|
label = "Email",
|
|
12
|
+
spellCheck = false,
|
|
8
13
|
...props
|
|
9
14
|
}: EmailFieldProps) {
|
|
10
15
|
return (
|
|
11
16
|
<InputField
|
|
17
|
+
autoCapitalize={autoCapitalize}
|
|
12
18
|
autoComplete={autoComplete}
|
|
19
|
+
autoCorrect={autoCorrect}
|
|
13
20
|
inputMode="email"
|
|
14
21
|
label={label}
|
|
22
|
+
spellCheck={spellCheck}
|
|
15
23
|
type="email"
|
|
16
24
|
{...props}
|
|
17
25
|
/>
|
|
@@ -5,9 +5,10 @@ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
|
5
5
|
import {
|
|
6
6
|
useHideFieldErrors,
|
|
7
7
|
useIsFieldRequired,
|
|
8
|
+
useIsFieldRequiredBySchema,
|
|
8
9
|
useIsInvalid,
|
|
9
10
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import { Children, type ComponentProps, type ReactNode } from "react"
|
|
11
|
+
import { Children, type ComponentProps, type ReactNode, useId } from "react"
|
|
11
12
|
import {
|
|
12
13
|
Field,
|
|
13
14
|
FieldDescription,
|
|
@@ -26,8 +27,8 @@ type InputFieldProps = Omit<
|
|
|
26
27
|
description?: ReactNode
|
|
27
28
|
label?: ReactNode
|
|
28
29
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
30
|
+
* Mark the field required, or not, over the schema. Drives the `*` and
|
|
31
|
+
* `aria-required`.
|
|
31
32
|
*/
|
|
32
33
|
required?: boolean
|
|
33
34
|
}
|
|
@@ -40,14 +41,17 @@ function InputField({
|
|
|
40
41
|
...props
|
|
41
42
|
}: InputFieldProps) {
|
|
42
43
|
const field = useFieldContext<string>()
|
|
43
|
-
const
|
|
44
|
-
const
|
|
44
|
+
const id = useId()
|
|
45
|
+
const errorId = `${id}-error`
|
|
46
|
+
const descriptionId = `${id}-description`
|
|
45
47
|
const isInvalid = useIsInvalid(field)
|
|
46
48
|
const hideErrors = useHideFieldErrors(field)
|
|
47
49
|
const showError = isInvalid && !hideErrors
|
|
48
50
|
const hasAddons = Children.count(children) > 0
|
|
49
51
|
const schemaRequired = useIsFieldRequired(field)
|
|
50
52
|
const isRequired = required ?? schemaRequired
|
|
53
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
54
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
51
55
|
|
|
52
56
|
const inputProps = {
|
|
53
57
|
"aria-describedby": describedBy(
|
|
@@ -55,7 +59,8 @@ function InputField({
|
|
|
55
59
|
[showError, errorId]
|
|
56
60
|
),
|
|
57
61
|
"aria-invalid": isInvalid || undefined,
|
|
58
|
-
|
|
62
|
+
"aria-required": requiresValue || undefined,
|
|
63
|
+
id,
|
|
59
64
|
name: field.name,
|
|
60
65
|
onBlur: field.handleBlur,
|
|
61
66
|
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
|
@@ -67,7 +72,7 @@ function InputField({
|
|
|
67
72
|
return (
|
|
68
73
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
69
74
|
{label && (
|
|
70
|
-
<FieldLabel htmlFor={
|
|
75
|
+
<FieldLabel htmlFor={id}>
|
|
71
76
|
{label}
|
|
72
77
|
{isRequired && <RequiredIndicator />}
|
|
73
78
|
</FieldLabel>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
4
|
+
import { getEmptyValue } from "@zeno-lib/forms/lib/empty-value"
|
|
4
5
|
import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
|
|
5
6
|
import { type ReactNode, useMemo } from "react"
|
|
6
7
|
import { InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
|
|
@@ -95,7 +96,8 @@ function MoneyField({
|
|
|
95
96
|
maximumFractionDigits: digits,
|
|
96
97
|
min,
|
|
97
98
|
onBlur: field.handleBlur,
|
|
98
|
-
onValueChange: (next) =>
|
|
99
|
+
onValueChange: (next) =>
|
|
100
|
+
field.handleChange(next ?? getEmptyValue(field, null)),
|
|
99
101
|
padFraction: true,
|
|
100
102
|
value: field.state.value,
|
|
101
103
|
})
|
|
@@ -5,10 +5,11 @@ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
|
5
5
|
import {
|
|
6
6
|
useHideFieldErrors,
|
|
7
7
|
useIsFieldRequired,
|
|
8
|
+
useIsFieldRequiredBySchema,
|
|
8
9
|
useIsInvalid,
|
|
9
10
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
11
|
import { XIcon } from "lucide-react"
|
|
11
|
-
import type
|
|
12
|
+
import { type ReactNode, useId } from "react"
|
|
12
13
|
import { Button } from "@/components/ui/button"
|
|
13
14
|
import {
|
|
14
15
|
Combobox,
|
|
@@ -56,7 +57,10 @@ type MultiSelectFieldProps<T = string> = {
|
|
|
56
57
|
clearLabel?: string
|
|
57
58
|
disabled?: boolean
|
|
58
59
|
className?: string
|
|
59
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Mark the field required, or not, over the schema. Drives the `*` and
|
|
62
|
+
* `aria-required`.
|
|
63
|
+
*/
|
|
60
64
|
required?: boolean
|
|
61
65
|
}
|
|
62
66
|
|
|
@@ -96,13 +100,16 @@ function MultiSelectField<T = string>({
|
|
|
96
100
|
}: MultiSelectFieldProps<T>) {
|
|
97
101
|
const field = useFieldContext<MultiSelectValue<T>[] | null | undefined>()
|
|
98
102
|
const anchor = useComboboxAnchor()
|
|
99
|
-
const
|
|
100
|
-
const
|
|
103
|
+
const id = useId()
|
|
104
|
+
const errorId = `${id}-error`
|
|
105
|
+
const descriptionId = `${id}-description`
|
|
101
106
|
const isInvalid = useIsInvalid(field)
|
|
102
107
|
const hideErrors = useHideFieldErrors(field)
|
|
103
108
|
const showError = isInvalid && !hideErrors
|
|
104
109
|
const schemaRequired = useIsFieldRequired(field)
|
|
105
110
|
const isRequired = required ?? schemaRequired
|
|
111
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
112
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
106
113
|
|
|
107
114
|
const values = field.state.value ?? []
|
|
108
115
|
// Map stored values back to item identities, in selection order, so Base UI
|
|
@@ -114,7 +121,7 @@ function MultiSelectField<T = string>({
|
|
|
114
121
|
return (
|
|
115
122
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
116
123
|
{label && (
|
|
117
|
-
<FieldLabel htmlFor={
|
|
124
|
+
<FieldLabel htmlFor={id}>
|
|
118
125
|
{label}
|
|
119
126
|
{isRequired && <RequiredIndicator />}
|
|
120
127
|
</FieldLabel>
|
|
@@ -143,7 +150,8 @@ function MultiSelectField<T = string>({
|
|
|
143
150
|
[showError, errorId]
|
|
144
151
|
)}
|
|
145
152
|
aria-invalid={isInvalid || undefined}
|
|
146
|
-
|
|
153
|
+
aria-required={requiresValue || undefined}
|
|
154
|
+
id={id}
|
|
147
155
|
name={field.name}
|
|
148
156
|
onBlur={field.handleBlur}
|
|
149
157
|
placeholder={chips.length === 0 ? placeholder : undefined}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
4
|
+
import { getEmptyValue } from "@zeno-lib/forms/lib/empty-value"
|
|
4
5
|
import type { ChangeEvent } from "react"
|
|
5
6
|
import { InputField, type InputFieldProps } from "./input-field"
|
|
6
7
|
|
|
7
|
-
type NumberFieldProps = Omit<
|
|
8
|
-
InputFieldProps,
|
|
9
|
-
"inputMode" | "onChange" | "type" | "value"
|
|
10
|
-
>
|
|
8
|
+
type NumberFieldProps = Omit<InputFieldProps, "onChange" | "type" | "value">
|
|
11
9
|
|
|
12
|
-
|
|
10
|
+
// `decimal` keeps the decimal key on phone keypads; pass `numeric` for whole
|
|
11
|
+
// numbers.
|
|
12
|
+
function NumberField({ inputMode = "decimal", ...props }: NumberFieldProps) {
|
|
13
13
|
const field = useFieldContext<number | undefined>()
|
|
14
14
|
const { value } = field.state
|
|
15
15
|
const inputValue =
|
|
@@ -18,10 +18,12 @@ function NumberField(props: NumberFieldProps) {
|
|
|
18
18
|
return (
|
|
19
19
|
<InputField
|
|
20
20
|
{...props}
|
|
21
|
-
inputMode=
|
|
21
|
+
inputMode={inputMode}
|
|
22
22
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
|
23
23
|
const next = event.target.valueAsNumber
|
|
24
|
-
field.handleChange(
|
|
24
|
+
field.handleChange(
|
|
25
|
+
Number.isNaN(next) ? getEmptyValue(field, undefined) : next
|
|
26
|
+
)
|
|
25
27
|
}}
|
|
26
28
|
type="number"
|
|
27
29
|
value={inputValue}
|
package/src/fields/otp-field.tsx
CHANGED
|
@@ -5,9 +5,10 @@ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
|
5
5
|
import {
|
|
6
6
|
useHideFieldErrors,
|
|
7
7
|
useIsFieldRequired,
|
|
8
|
+
useIsFieldRequiredBySchema,
|
|
8
9
|
useIsInvalid,
|
|
9
10
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import type
|
|
11
|
+
import { type ReactNode, useId } from "react"
|
|
11
12
|
import {
|
|
12
13
|
Field,
|
|
13
14
|
FieldDescription,
|
|
@@ -40,7 +41,10 @@ type OtpFieldProps = {
|
|
|
40
41
|
* you need a separator between segments.
|
|
41
42
|
*/
|
|
42
43
|
children?: ReactNode
|
|
43
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Mark the field required, or not, over the schema. Drives the `*` and
|
|
46
|
+
* `aria-required`.
|
|
47
|
+
*/
|
|
44
48
|
required?: boolean
|
|
45
49
|
}
|
|
46
50
|
|
|
@@ -57,18 +61,21 @@ function OtpField({
|
|
|
57
61
|
required,
|
|
58
62
|
}: OtpFieldProps) {
|
|
59
63
|
const field = useFieldContext<string>()
|
|
60
|
-
const
|
|
61
|
-
const
|
|
64
|
+
const id = useId()
|
|
65
|
+
const errorId = `${id}-error`
|
|
66
|
+
const descriptionId = `${id}-description`
|
|
62
67
|
const isInvalid = useIsInvalid(field)
|
|
63
68
|
const hideErrors = useHideFieldErrors(field)
|
|
64
69
|
const showError = isInvalid && !hideErrors
|
|
65
70
|
const schemaRequired = useIsFieldRequired(field)
|
|
66
71
|
const isRequired = required ?? schemaRequired
|
|
72
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
73
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
67
74
|
|
|
68
75
|
return (
|
|
69
76
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
70
77
|
{label && (
|
|
71
|
-
<FieldLabel htmlFor={
|
|
78
|
+
<FieldLabel htmlFor={id}>
|
|
72
79
|
{label}
|
|
73
80
|
{isRequired && <RequiredIndicator />}
|
|
74
81
|
</FieldLabel>
|
|
@@ -79,11 +86,12 @@ function OtpField({
|
|
|
79
86
|
[showError, errorId]
|
|
80
87
|
)}
|
|
81
88
|
aria-invalid={isInvalid || undefined}
|
|
89
|
+
aria-required={requiresValue || undefined}
|
|
82
90
|
autoFocus={autoFocus}
|
|
83
91
|
className={className}
|
|
84
92
|
containerClassName={containerClassName}
|
|
85
93
|
disabled={disabled}
|
|
86
|
-
id={
|
|
94
|
+
id={id}
|
|
87
95
|
maxLength={maxLength}
|
|
88
96
|
name={field.name}
|
|
89
97
|
onBlur={field.handleBlur}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
4
|
+
import { getEmptyValue } from "@zeno-lib/forms/lib/empty-value"
|
|
4
5
|
import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
|
|
5
6
|
import { InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
|
|
6
7
|
import { InputField, type InputFieldProps } from "./input-field"
|
|
@@ -54,7 +55,8 @@ function PercentageField({
|
|
|
54
55
|
maximumFractionDigits: fractionDigits,
|
|
55
56
|
min,
|
|
56
57
|
onBlur: field.handleBlur,
|
|
57
|
-
onValueChange: (next) =>
|
|
58
|
+
onValueChange: (next) =>
|
|
59
|
+
field.handleChange(next ?? getEmptyValue(field, null)),
|
|
58
60
|
value: field.state.value,
|
|
59
61
|
...(isFraction ? { fromDisplay: fromPercent, toDisplay: toPercent } : {}),
|
|
60
62
|
})
|
|
@@ -5,6 +5,7 @@ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
|
5
5
|
import {
|
|
6
6
|
useHideFieldErrors,
|
|
7
7
|
useIsFieldRequired,
|
|
8
|
+
useIsFieldRequiredBySchema,
|
|
8
9
|
useIsInvalid,
|
|
9
10
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
11
|
import { type ComponentProps, type ReactNode, useId } from "react"
|
|
@@ -24,7 +25,10 @@ type RadioGroupFieldProps = Omit<
|
|
|
24
25
|
> & {
|
|
25
26
|
description?: ReactNode
|
|
26
27
|
label?: ReactNode
|
|
27
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Mark the field required, or not, over the schema. Drives the `*` and
|
|
30
|
+
* `aria-required`.
|
|
31
|
+
*/
|
|
28
32
|
required?: boolean
|
|
29
33
|
}
|
|
30
34
|
|
|
@@ -35,14 +39,17 @@ function RadioGroupField({
|
|
|
35
39
|
required,
|
|
36
40
|
...props
|
|
37
41
|
}: RadioGroupFieldProps) {
|
|
38
|
-
const field = useFieldContext
|
|
39
|
-
const
|
|
40
|
-
const
|
|
42
|
+
const field = useFieldContext()
|
|
43
|
+
const id = useId()
|
|
44
|
+
const errorId = `${id}-error`
|
|
45
|
+
const descriptionId = `${id}-description`
|
|
41
46
|
const isInvalid = useIsInvalid(field)
|
|
42
47
|
const hideErrors = useHideFieldErrors(field)
|
|
43
48
|
const showError = isInvalid && !hideErrors
|
|
44
49
|
const schemaRequired = useIsFieldRequired(field)
|
|
45
50
|
const isRequired = required ?? schemaRequired
|
|
51
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
52
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
46
53
|
|
|
47
54
|
return (
|
|
48
55
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
@@ -58,9 +65,10 @@ function RadioGroupField({
|
|
|
58
65
|
[showError, errorId]
|
|
59
66
|
)}
|
|
60
67
|
aria-invalid={isInvalid || undefined}
|
|
68
|
+
aria-required={requiresValue || undefined}
|
|
61
69
|
name={field.name}
|
|
62
|
-
onValueChange={(value) => field.handleChange(
|
|
63
|
-
value={field.state.value ??
|
|
70
|
+
onValueChange={(value) => field.handleChange(value)}
|
|
71
|
+
value={field.state.value ?? null}
|
|
64
72
|
{...props}
|
|
65
73
|
>
|
|
66
74
|
{children}
|
|
@@ -5,10 +5,11 @@ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
|
5
5
|
import {
|
|
6
6
|
useHideFieldErrors,
|
|
7
7
|
useIsFieldRequired,
|
|
8
|
+
useIsFieldRequiredBySchema,
|
|
8
9
|
useIsInvalid,
|
|
9
10
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
11
|
import type { ComponentProps, ReactNode } from "react"
|
|
11
|
-
import { Children, isValidElement, useMemo } from "react"
|
|
12
|
+
import { Children, isValidElement, useId, useMemo, useRef } from "react"
|
|
12
13
|
import {
|
|
13
14
|
Field,
|
|
14
15
|
FieldDescription,
|
|
@@ -31,7 +32,10 @@ type SelectFieldProps = Omit<
|
|
|
31
32
|
description?: ReactNode
|
|
32
33
|
label?: ReactNode
|
|
33
34
|
placeholder?: string
|
|
34
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Mark the field required, or not, over the schema. Drives the `*` and
|
|
37
|
+
* `aria-required`.
|
|
38
|
+
*/
|
|
35
39
|
required?: boolean
|
|
36
40
|
triggerClassName?: string
|
|
37
41
|
triggerSize?: ComponentProps<typeof SelectTrigger>["size"]
|
|
@@ -41,6 +45,7 @@ function SelectField({
|
|
|
41
45
|
children,
|
|
42
46
|
description,
|
|
43
47
|
label,
|
|
48
|
+
onOpenChange,
|
|
44
49
|
placeholder,
|
|
45
50
|
required,
|
|
46
51
|
triggerClassName,
|
|
@@ -48,13 +53,20 @@ function SelectField({
|
|
|
48
53
|
...props
|
|
49
54
|
}: SelectFieldProps) {
|
|
50
55
|
const field = useFieldContext()
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
// Opening the popup moves focus into it, so the trigger's blur would mark
|
|
57
|
+
// the field touched, and show a required error, before anything is picked.
|
|
58
|
+
// The field is touched once the popup closes, or focus leaves it closed.
|
|
59
|
+
const isOpen = useRef<boolean>(false)
|
|
60
|
+
const id = useId()
|
|
61
|
+
const errorId = `${id}-error`
|
|
62
|
+
const descriptionId = `${id}-description`
|
|
53
63
|
const isInvalid = useIsInvalid(field)
|
|
54
64
|
const hideErrors = useHideFieldErrors(field)
|
|
55
65
|
const showError = isInvalid && !hideErrors
|
|
56
66
|
const schemaRequired = useIsFieldRequired(field)
|
|
57
67
|
const isRequired = required ?? schemaRequired
|
|
68
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
69
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
58
70
|
|
|
59
71
|
const items = useMemo(() => {
|
|
60
72
|
const map: Record<string, ReactNode> = {}
|
|
@@ -72,7 +84,7 @@ function SelectField({
|
|
|
72
84
|
return (
|
|
73
85
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
74
86
|
{label && (
|
|
75
|
-
<FieldLabel htmlFor={
|
|
87
|
+
<FieldLabel htmlFor={id}>
|
|
76
88
|
{label}
|
|
77
89
|
{isRequired && <RequiredIndicator />}
|
|
78
90
|
</FieldLabel>
|
|
@@ -80,6 +92,13 @@ function SelectField({
|
|
|
80
92
|
<Select
|
|
81
93
|
items={items}
|
|
82
94
|
name={field.name}
|
|
95
|
+
onOpenChange={(open, eventDetails) => {
|
|
96
|
+
isOpen.current = open
|
|
97
|
+
if (!open) {
|
|
98
|
+
field.handleBlur()
|
|
99
|
+
}
|
|
100
|
+
onOpenChange?.(open, eventDetails)
|
|
101
|
+
}}
|
|
83
102
|
onValueChange={(value) => field.handleChange(value)}
|
|
84
103
|
value={field.state.value ?? null}
|
|
85
104
|
{...props}
|
|
@@ -90,9 +109,14 @@ function SelectField({
|
|
|
90
109
|
[showError, errorId]
|
|
91
110
|
)}
|
|
92
111
|
aria-invalid={isInvalid || undefined}
|
|
112
|
+
aria-required={requiresValue || undefined}
|
|
93
113
|
className={triggerClassName}
|
|
94
|
-
id={
|
|
95
|
-
onBlur={
|
|
114
|
+
id={id}
|
|
115
|
+
onBlur={() => {
|
|
116
|
+
if (!isOpen.current) {
|
|
117
|
+
field.handleBlur()
|
|
118
|
+
}
|
|
119
|
+
}}
|
|
96
120
|
size={triggerSize}
|
|
97
121
|
>
|
|
98
122
|
<SelectValue placeholder={placeholder} />
|