@zeno-lib/forms 0.3.0 → 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 +2 -2
- package/dist/index.mjs +3 -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 +49 -21
- package/dist/lib/submit-action.mjs +1 -1
- 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 +1 -1
- package/src/create-zeno-form.tsx +13 -5
- package/src/fields/checkbox-field.tsx +8 -1
- package/src/fields/combobox-field.tsx +29 -12
- package/src/fields/date-picker-field.tsx +37 -9
- package/src/fields/email-field.tsx +8 -0
- package/src/fields/input-field.tsx +6 -2
- package/src/fields/money-field.tsx +3 -1
- package/src/fields/multi-select-field.tsx +8 -1
- package/src/fields/number-field.tsx +9 -7
- package/src/fields/otp-field.tsx +8 -1
- package/src/fields/percentage-field.tsx +3 -1
- package/src/fields/radio-group-field.tsx +11 -4
- package/src/fields/select-field.tsx +26 -3
- package/src/fields/switch-field.tsx +8 -1
- package/src/fields/textarea-field.tsx +8 -1
- package/src/fields/year-field.tsx +3 -1
- package/src/index.ts +1 -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 +24 -2
- package/src/lib/schema-required.ts +81 -43
- package/src/lib/use-is-invalid.ts +14 -1
- package/src/lib/validation-modes.ts +18 -6
- package/src/use-app-fields.test.tsx +312 -2
|
@@ -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 ComponentProps, type ReactNode, useId } from "react"
|
|
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,6 +70,10 @@ function DatePickerField({
|
|
|
60
70
|
triggerClassName,
|
|
61
71
|
}: DatePickerFieldProps) {
|
|
62
72
|
const field = useFieldContext<Date | undefined>()
|
|
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)
|
|
63
77
|
const id = useId()
|
|
64
78
|
const errorId = `${id}-error`
|
|
65
79
|
const descriptionId = `${id}-description`
|
|
@@ -68,6 +82,8 @@ function DatePickerField({
|
|
|
68
82
|
const showError = isInvalid && !hideErrors
|
|
69
83
|
const schemaRequired = useIsFieldRequired(field)
|
|
70
84
|
const isRequired = required ?? schemaRequired
|
|
85
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
86
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
71
87
|
|
|
72
88
|
const { value } = field.state
|
|
73
89
|
const empty = !value
|
|
@@ -80,7 +96,14 @@ function DatePickerField({
|
|
|
80
96
|
{isRequired && <RequiredIndicator />}
|
|
81
97
|
</FieldLabel>
|
|
82
98
|
)}
|
|
83
|
-
<Popover
|
|
99
|
+
<Popover
|
|
100
|
+
onOpenChange={(open) => {
|
|
101
|
+
isOpen.current = open
|
|
102
|
+
if (!open) {
|
|
103
|
+
field.handleBlur()
|
|
104
|
+
}
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
84
107
|
<PopoverTrigger
|
|
85
108
|
aria-describedby={describedBy(
|
|
86
109
|
[description, descriptionId],
|
|
@@ -93,11 +116,13 @@ function DatePickerField({
|
|
|
93
116
|
triggerClassName
|
|
94
117
|
)}
|
|
95
118
|
data-empty={empty || undefined}
|
|
119
|
+
disabled={disabled}
|
|
96
120
|
id={id}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
121
|
+
onBlur={() => {
|
|
122
|
+
if (!isOpen.current) {
|
|
123
|
+
field.handleBlur()
|
|
124
|
+
}
|
|
125
|
+
}}
|
|
101
126
|
>
|
|
102
127
|
<CalendarIcon />
|
|
103
128
|
{empty ? <span>{placeholder}</span> : formatValue(value)}
|
|
@@ -105,7 +130,10 @@ function DatePickerField({
|
|
|
105
130
|
<PopoverContent className="w-auto p-0">
|
|
106
131
|
<Calendar
|
|
107
132
|
mode="single"
|
|
108
|
-
onSelect={(next) =>
|
|
133
|
+
onSelect={(next: Date | undefined) =>
|
|
134
|
+
field.handleChange(next ?? getEmptyValue(field, undefined))
|
|
135
|
+
}
|
|
136
|
+
required={requiresValue}
|
|
109
137
|
selected={value}
|
|
110
138
|
{...calendarProps}
|
|
111
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,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 { Children, type ComponentProps, type ReactNode, useId } from "react"
|
|
@@ -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
|
}
|
|
@@ -49,6 +50,8 @@ function InputField({
|
|
|
49
50
|
const hasAddons = Children.count(children) > 0
|
|
50
51
|
const schemaRequired = useIsFieldRequired(field)
|
|
51
52
|
const isRequired = required ?? schemaRequired
|
|
53
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
54
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
52
55
|
|
|
53
56
|
const inputProps = {
|
|
54
57
|
"aria-describedby": describedBy(
|
|
@@ -56,6 +59,7 @@ function InputField({
|
|
|
56
59
|
[showError, errorId]
|
|
57
60
|
),
|
|
58
61
|
"aria-invalid": isInvalid || undefined,
|
|
62
|
+
"aria-required": requiresValue || undefined,
|
|
59
63
|
id,
|
|
60
64
|
name: field.name,
|
|
61
65
|
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 { 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,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 { XIcon } from "lucide-react"
|
|
@@ -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
|
|
|
@@ -104,6 +108,8 @@ function MultiSelectField<T = string>({
|
|
|
104
108
|
const showError = isInvalid && !hideErrors
|
|
105
109
|
const schemaRequired = useIsFieldRequired(field)
|
|
106
110
|
const isRequired = required ?? schemaRequired
|
|
111
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
112
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
107
113
|
|
|
108
114
|
const values = field.state.value ?? []
|
|
109
115
|
// Map stored values back to item identities, in selection order, so Base UI
|
|
@@ -144,6 +150,7 @@ function MultiSelectField<T = string>({
|
|
|
144
150
|
[showError, errorId]
|
|
145
151
|
)}
|
|
146
152
|
aria-invalid={isInvalid || undefined}
|
|
153
|
+
aria-required={requiresValue || undefined}
|
|
147
154
|
id={id}
|
|
148
155
|
name={field.name}
|
|
149
156
|
onBlur={field.handleBlur}
|
|
@@ -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,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 ReactNode, useId } from "react"
|
|
@@ -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
|
|
|
@@ -65,6 +69,8 @@ function OtpField({
|
|
|
65
69
|
const showError = isInvalid && !hideErrors
|
|
66
70
|
const schemaRequired = useIsFieldRequired(field)
|
|
67
71
|
const isRequired = required ?? schemaRequired
|
|
72
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
73
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
68
74
|
|
|
69
75
|
return (
|
|
70
76
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
@@ -80,6 +86,7 @@ function OtpField({
|
|
|
80
86
|
[showError, errorId]
|
|
81
87
|
)}
|
|
82
88
|
aria-invalid={isInvalid || undefined}
|
|
89
|
+
aria-required={requiresValue || undefined}
|
|
83
90
|
autoFocus={autoFocus}
|
|
84
91
|
className={className}
|
|
85
92
|
containerClassName={containerClassName}
|
|
@@ -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,7 +39,7 @@ function RadioGroupField({
|
|
|
35
39
|
required,
|
|
36
40
|
...props
|
|
37
41
|
}: RadioGroupFieldProps) {
|
|
38
|
-
const field = useFieldContext
|
|
42
|
+
const field = useFieldContext()
|
|
39
43
|
const id = useId()
|
|
40
44
|
const errorId = `${id}-error`
|
|
41
45
|
const descriptionId = `${id}-description`
|
|
@@ -44,6 +48,8 @@ function RadioGroupField({
|
|
|
44
48
|
const showError = isInvalid && !hideErrors
|
|
45
49
|
const schemaRequired = useIsFieldRequired(field)
|
|
46
50
|
const isRequired = required ?? schemaRequired
|
|
51
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
52
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
47
53
|
|
|
48
54
|
return (
|
|
49
55
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
@@ -59,9 +65,10 @@ function RadioGroupField({
|
|
|
59
65
|
[showError, errorId]
|
|
60
66
|
)}
|
|
61
67
|
aria-invalid={isInvalid || undefined}
|
|
68
|
+
aria-required={requiresValue || undefined}
|
|
62
69
|
name={field.name}
|
|
63
|
-
onValueChange={(value) => field.handleChange(
|
|
64
|
-
value={field.state.value ??
|
|
70
|
+
onValueChange={(value) => field.handleChange(value)}
|
|
71
|
+
value={field.state.value ?? null}
|
|
65
72
|
{...props}
|
|
66
73
|
>
|
|
67
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, useId, 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,6 +53,10 @@ function SelectField({
|
|
|
48
53
|
...props
|
|
49
54
|
}: SelectFieldProps) {
|
|
50
55
|
const field = useFieldContext()
|
|
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)
|
|
51
60
|
const id = useId()
|
|
52
61
|
const errorId = `${id}-error`
|
|
53
62
|
const descriptionId = `${id}-description`
|
|
@@ -56,6 +65,8 @@ function SelectField({
|
|
|
56
65
|
const showError = isInvalid && !hideErrors
|
|
57
66
|
const schemaRequired = useIsFieldRequired(field)
|
|
58
67
|
const isRequired = required ?? schemaRequired
|
|
68
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
69
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
59
70
|
|
|
60
71
|
const items = useMemo(() => {
|
|
61
72
|
const map: Record<string, ReactNode> = {}
|
|
@@ -81,6 +92,13 @@ function SelectField({
|
|
|
81
92
|
<Select
|
|
82
93
|
items={items}
|
|
83
94
|
name={field.name}
|
|
95
|
+
onOpenChange={(open, eventDetails) => {
|
|
96
|
+
isOpen.current = open
|
|
97
|
+
if (!open) {
|
|
98
|
+
field.handleBlur()
|
|
99
|
+
}
|
|
100
|
+
onOpenChange?.(open, eventDetails)
|
|
101
|
+
}}
|
|
84
102
|
onValueChange={(value) => field.handleChange(value)}
|
|
85
103
|
value={field.state.value ?? null}
|
|
86
104
|
{...props}
|
|
@@ -91,9 +109,14 @@ function SelectField({
|
|
|
91
109
|
[showError, errorId]
|
|
92
110
|
)}
|
|
93
111
|
aria-invalid={isInvalid || undefined}
|
|
112
|
+
aria-required={requiresValue || undefined}
|
|
94
113
|
className={triggerClassName}
|
|
95
114
|
id={id}
|
|
96
|
-
onBlur={
|
|
115
|
+
onBlur={() => {
|
|
116
|
+
if (!isOpen.current) {
|
|
117
|
+
field.handleBlur()
|
|
118
|
+
}
|
|
119
|
+
}}
|
|
97
120
|
size={triggerSize}
|
|
98
121
|
>
|
|
99
122
|
<SelectValue placeholder={placeholder} />
|
|
@@ -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 SwitchFieldProps = 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
|
|
|
@@ -43,6 +47,8 @@ function SwitchField({
|
|
|
43
47
|
const showError = isInvalid && !hideErrors
|
|
44
48
|
const schemaRequired = useIsFieldRequired(field)
|
|
45
49
|
const isRequired = required ?? schemaRequired
|
|
50
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
51
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
46
52
|
|
|
47
53
|
return (
|
|
48
54
|
<Field
|
|
@@ -70,6 +76,7 @@ function SwitchField({
|
|
|
70
76
|
[showError, errorId]
|
|
71
77
|
)}
|
|
72
78
|
aria-invalid={isInvalid || undefined}
|
|
79
|
+
aria-required={requiresValue || undefined}
|
|
73
80
|
checked={field.state.value ?? false}
|
|
74
81
|
id={id}
|
|
75
82
|
name={field.name}
|
|
@@ -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 { Children, type ComponentProps, type ReactNode, useId } from "react"
|
|
@@ -25,7 +26,10 @@ type TextAreaFieldProps = Omit<
|
|
|
25
26
|
children?: ReactNode
|
|
26
27
|
description?: ReactNode
|
|
27
28
|
label?: ReactNode
|
|
28
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Mark the field required, or not, over the schema. Drives the `*` and
|
|
31
|
+
* `aria-required`.
|
|
32
|
+
*/
|
|
29
33
|
required?: boolean
|
|
30
34
|
}
|
|
31
35
|
|
|
@@ -46,6 +50,8 @@ function TextAreaField({
|
|
|
46
50
|
const hasAddons = Children.count(children) > 0
|
|
47
51
|
const schemaRequired = useIsFieldRequired(field)
|
|
48
52
|
const isRequired = required ?? schemaRequired
|
|
53
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
54
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
49
55
|
|
|
50
56
|
const textareaProps = {
|
|
51
57
|
"aria-describedby": describedBy(
|
|
@@ -53,6 +59,7 @@ function TextAreaField({
|
|
|
53
59
|
[showError, errorId]
|
|
54
60
|
),
|
|
55
61
|
"aria-invalid": isInvalid || undefined,
|
|
62
|
+
"aria-required": requiresValue || undefined,
|
|
56
63
|
id,
|
|
57
64
|
name: field.name,
|
|
58
65
|
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 { InputField, type InputFieldProps } from "./input-field"
|
|
6
7
|
|
|
@@ -36,7 +37,8 @@ function YearField({ max, min, ...props }: YearFieldProps) {
|
|
|
36
37
|
maximumFractionDigits: 0,
|
|
37
38
|
min,
|
|
38
39
|
onBlur: field.handleBlur,
|
|
39
|
-
onValueChange: (next) =>
|
|
40
|
+
onValueChange: (next) =>
|
|
41
|
+
field.handleChange(next ?? getEmptyValue(field, null)),
|
|
40
42
|
useGrouping: false,
|
|
41
43
|
value: field.state.value,
|
|
42
44
|
})
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
|
|
4
|
+
import { emptyValueFor, withEmptyStartValues } from "./empty-value"
|
|
5
|
+
import { extractZodDefaults } from "./schema-defaults"
|
|
6
|
+
|
|
7
|
+
const FALLBACK = Symbol("fallback")
|
|
8
|
+
|
|
9
|
+
function cleared(field: z.ZodType, name = "field", values: unknown = {}) {
|
|
10
|
+
return emptyValueFor(z.object({ field }), values, name, FALLBACK)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("emptyValueFor", () => {
|
|
14
|
+
test("null, then undefined, then the type's own empty value", () => {
|
|
15
|
+
expect(cleared(z.string().nullable())).toBeNull()
|
|
16
|
+
expect(cleared(z.string().optional())).toBeUndefined()
|
|
17
|
+
expect(cleared(z.string())).toBe("")
|
|
18
|
+
expect(cleared(z.array(z.string()))).toEqual([])
|
|
19
|
+
expect(cleared(z.uuid().nullable())).toBeNull()
|
|
20
|
+
expect(cleared(z.enum(["a", "b"]).optional())).toBeUndefined()
|
|
21
|
+
expect(cleared(z.number().nullable())).toBeNull()
|
|
22
|
+
expect(cleared(z.date().nullish())).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test("a field that accepts no empty value keeps the field's own fallback", () => {
|
|
26
|
+
expect(cleared(z.string().min(1))).toBe(FALLBACK)
|
|
27
|
+
expect(cleared(z.number())).toBe(FALLBACK)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("array rows follow the row schema", () => {
|
|
31
|
+
const schema = z.object({
|
|
32
|
+
members: z.array(
|
|
33
|
+
z.object({
|
|
34
|
+
id: z.uuid().nullable(),
|
|
35
|
+
name: z.string(),
|
|
36
|
+
note: z.string().optional(),
|
|
37
|
+
})
|
|
38
|
+
),
|
|
39
|
+
})
|
|
40
|
+
const values = { members: [{ id: "x" }, { id: "y" }] }
|
|
41
|
+
const at = (name: string) => emptyValueFor(schema, values, name, FALLBACK)
|
|
42
|
+
expect(at("members[1].id")).toBeNull()
|
|
43
|
+
expect(at("members[1].name")).toBe("")
|
|
44
|
+
expect(at("members[1].note")).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test("without a schema, or with an async one, the fallback stands", () => {
|
|
48
|
+
expect(emptyValueFor(undefined, {}, "field", FALLBACK)).toBe(FALLBACK)
|
|
49
|
+
const asyncSchema = z.object({
|
|
50
|
+
field: z.string().refine(async () => true),
|
|
51
|
+
})
|
|
52
|
+
expect(emptyValueFor(asyncSchema, {}, "field", FALLBACK)).toBe(FALLBACK)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe("withEmptyStartValues", () => {
|
|
57
|
+
test('starts a field whose schema rejects `""` at its empty value', () => {
|
|
58
|
+
const schema = z.object({
|
|
59
|
+
email: z.email().optional(),
|
|
60
|
+
name: z.string().min(1),
|
|
61
|
+
note: z.string(),
|
|
62
|
+
ownerId: z.uuid().nullable(),
|
|
63
|
+
})
|
|
64
|
+
const start = withEmptyStartValues(schema, extractZodDefaults(schema))
|
|
65
|
+
expect(start).toEqual({
|
|
66
|
+
email: undefined,
|
|
67
|
+
name: "",
|
|
68
|
+
note: "",
|
|
69
|
+
ownerId: null,
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
})
|