@zeno-lib/forms 0.3.0 → 0.5.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 +9 -3
- package/dist/form-element.mjs +7 -5
- 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/focus-first-invalid.d.mts +23 -0
- package/dist/lib/focus-first-invalid.mjs +37 -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/submit-error.d.mts +36 -0
- package/dist/lib/submit-error.mjs +66 -0
- package/dist/lib/use-form-dialog.d.mts +15 -1
- package/dist/lib/use-form-dialog.mjs +20 -2
- 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-form.tsx +2 -0
- package/src/create-zeno-form.tsx +25 -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/form-error.tsx +61 -0
- package/src/fields/index.ts +1 -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/form-dialog.test.tsx +493 -2
- package/src/form-dialog.tsx +217 -33
- package/src/form-element.tsx +13 -11
- package/src/form-error.test.tsx +255 -0
- 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/focus-first-invalid.test.ts +22 -0
- package/src/lib/focus-first-invalid.ts +52 -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/submit-error.ts +133 -0
- package/src/lib/use-form-dialog.ts +49 -1
- 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
- package/src/use-form.focus-invalid.test.tsx +34 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { StandardSchemaLike } from "./schema-required.mjs";
|
|
1
2
|
import { AnyFormApi } from "@tanstack/react-form";
|
|
2
3
|
//#region src/lib/validation-modes.d.ts
|
|
3
4
|
type ValidationMode = "change" | "blur" | "submit" | "blur-then-change";
|
|
@@ -7,10 +8,13 @@ type ZenoFormState = {
|
|
|
7
8
|
hideFieldErrors: boolean;
|
|
8
9
|
requiredIndicator: boolean;
|
|
9
10
|
requiredFields: ReadonlySet<string>;
|
|
11
|
+
schema: StandardSchemaLike | undefined;
|
|
10
12
|
};
|
|
11
13
|
declare function setFormZenoState(form: AnyFormApi, state: Partial<ZenoFormState>): void;
|
|
12
14
|
declare function getFormValidationMode(form: AnyFormApi): ValidationMode;
|
|
13
15
|
declare function getFormHideFieldErrors(form: AnyFormApi): boolean;
|
|
16
|
+
declare function getFormSchema(form: AnyFormApi): StandardSchemaLike | undefined;
|
|
17
|
+
declare function isFieldRequiredBySchema(form: AnyFormApi, name: string): boolean;
|
|
14
18
|
declare function isFieldRequired(form: AnyFormApi, name: string): boolean;
|
|
15
19
|
//#endregion
|
|
16
|
-
export { DEFAULT_VALIDATION_MODE, type ValidationMode, getFormHideFieldErrors, getFormValidationMode, isFieldRequired, setFormZenoState };
|
|
20
|
+
export { DEFAULT_VALIDATION_MODE, type ValidationMode, getFormHideFieldErrors, getFormSchema, getFormValidationMode, isFieldRequired, isFieldRequiredBySchema, setFormZenoState };
|
|
@@ -6,6 +6,7 @@ const DEFAULT_FORM_STATE = {
|
|
|
6
6
|
hideFieldErrors: false,
|
|
7
7
|
requiredFields: /* @__PURE__ */ new Set(),
|
|
8
8
|
requiredIndicator: true,
|
|
9
|
+
schema: void 0,
|
|
9
10
|
validation: DEFAULT_VALIDATION_MODE
|
|
10
11
|
};
|
|
11
12
|
const formZenoState = /* @__PURE__ */ new WeakMap();
|
|
@@ -29,10 +30,14 @@ function getFormValidationMode(form) {
|
|
|
29
30
|
function getFormHideFieldErrors(form) {
|
|
30
31
|
return getFormZenoState(form).hideFieldErrors;
|
|
31
32
|
}
|
|
33
|
+
function getFormSchema(form) {
|
|
34
|
+
return getFormZenoState(form).schema;
|
|
35
|
+
}
|
|
36
|
+
function isFieldRequiredBySchema(form, name) {
|
|
37
|
+
return getFormZenoState(form).requiredFields.has(toRequiredPathKey(name));
|
|
38
|
+
}
|
|
32
39
|
function isFieldRequired(form, name) {
|
|
33
|
-
|
|
34
|
-
if (!state.requiredIndicator) return false;
|
|
35
|
-
return state.requiredFields.has(toRequiredPathKey(name));
|
|
40
|
+
return getFormZenoState(form).requiredIndicator && isFieldRequiredBySchema(form, name);
|
|
36
41
|
}
|
|
37
42
|
//#endregion
|
|
38
|
-
export { DEFAULT_VALIDATION_MODE, getFormHideFieldErrors, getFormValidationMode, isFieldRequired, setFormZenoState };
|
|
43
|
+
export { DEFAULT_VALIDATION_MODE, getFormHideFieldErrors, getFormSchema, getFormValidationMode, isFieldRequired, isFieldRequiredBySchema, setFormZenoState };
|
package/package.json
CHANGED
package/src/create-form.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { CheckboxGroupField } from "./fields/checkbox-group-field"
|
|
|
8
8
|
import { ComboboxField } from "./fields/combobox-field"
|
|
9
9
|
import { DatePickerField } from "./fields/date-picker-field"
|
|
10
10
|
import { EmailField } from "./fields/email-field"
|
|
11
|
+
import { FormError } from "./fields/form-error"
|
|
11
12
|
import { InputField } from "./fields/input-field"
|
|
12
13
|
import { MoneyField } from "./fields/money-field"
|
|
13
14
|
import { MultiSelectField } from "./fields/multi-select-field"
|
|
@@ -48,6 +49,7 @@ const { useAppForm, useForm, withFieldGroup, withForm } = createZenoForm({
|
|
|
48
49
|
YearField,
|
|
49
50
|
},
|
|
50
51
|
formComponents: {
|
|
52
|
+
FormError,
|
|
51
53
|
ResetButton,
|
|
52
54
|
SubmitButton,
|
|
53
55
|
},
|
package/src/create-zeno-form.tsx
CHANGED
|
@@ -12,8 +12,13 @@ import { createFormHook } from "@tanstack/react-form"
|
|
|
12
12
|
import { type ComponentProps, type ReactNode, useMemo } from "react"
|
|
13
13
|
import { applyValidationError } from "./lib/apply-validation-error"
|
|
14
14
|
import { fieldContext, formContext } from "./lib/contexts"
|
|
15
|
+
import { withEmptyStartValues } from "./lib/empty-value"
|
|
15
16
|
import { deepMergeDefaults, extractZodDefaults } from "./lib/schema-defaults"
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
getRequiredPaths,
|
|
19
|
+
type StandardSchemaLike,
|
|
20
|
+
} from "./lib/schema-required"
|
|
21
|
+
import { setSubmitError } from "./lib/submit-error"
|
|
17
22
|
import { useRebasedDefaultValues } from "./lib/use-rebased-default-values"
|
|
18
23
|
import { useUnsavedChangesWarning } from "./lib/use-unsaved-changes-warning"
|
|
19
24
|
import { ValidationError } from "./lib/validation-error"
|
|
@@ -435,6 +440,7 @@ export function createZenoForm<
|
|
|
435
440
|
|
|
436
441
|
const wrappedOnSubmit = userOnSubmit
|
|
437
442
|
? ((async (props: { formApi: AnyFormApi; value: TFormData }) => {
|
|
443
|
+
const { submissionAttempts } = props.formApi.state
|
|
438
444
|
try {
|
|
439
445
|
await (
|
|
440
446
|
userOnSubmit as unknown as (
|
|
@@ -446,6 +452,16 @@ export function createZenoForm<
|
|
|
446
452
|
applyValidationError(props.formApi, error)
|
|
447
453
|
return
|
|
448
454
|
}
|
|
455
|
+
// Kept for `FormError`, unless an edit, a reset (a dialog closed
|
|
456
|
+
// meanwhile) or a newer submit came first: then it is about
|
|
457
|
+
// values no longer on screen.
|
|
458
|
+
const { state } = props.formApi
|
|
459
|
+
if (
|
|
460
|
+
state.values === props.value &&
|
|
461
|
+
state.submissionAttempts === submissionAttempts
|
|
462
|
+
) {
|
|
463
|
+
setSubmitError(props.formApi, error)
|
|
464
|
+
}
|
|
449
465
|
throw error
|
|
450
466
|
}
|
|
451
467
|
}) as typeof userOnSubmit)
|
|
@@ -453,17 +469,20 @@ export function createZenoForm<
|
|
|
453
469
|
|
|
454
470
|
const requiredFields = useMemo(
|
|
455
471
|
() =>
|
|
456
|
-
schema
|
|
472
|
+
schema
|
|
457
473
|
? getRequiredPaths(schema as Parameters<typeof getRequiredPaths>[0])
|
|
458
474
|
: new Set<string>(),
|
|
459
|
-
[schema
|
|
475
|
+
[schema]
|
|
460
476
|
)
|
|
461
477
|
|
|
462
478
|
const schemaDefaults = useMemo(
|
|
463
479
|
() =>
|
|
464
480
|
schema
|
|
465
|
-
?
|
|
466
|
-
schema as
|
|
481
|
+
? withEmptyStartValues(
|
|
482
|
+
schema as StandardSchemaLike,
|
|
483
|
+
extractZodDefaults(
|
|
484
|
+
schema as Parameters<typeof extractZodDefaults>[0]
|
|
485
|
+
)
|
|
467
486
|
)
|
|
468
487
|
: undefined,
|
|
469
488
|
[schema]
|
|
@@ -515,6 +534,7 @@ export function createZenoForm<
|
|
|
515
534
|
hideFieldErrors,
|
|
516
535
|
requiredFields,
|
|
517
536
|
requiredIndicator,
|
|
537
|
+
schema: schema as StandardSchemaLike | undefined,
|
|
518
538
|
...(schemaMode === undefined ? {} : { validation: schemaMode }),
|
|
519
539
|
})
|
|
520
540
|
|
|
@@ -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 CheckboxFieldProps = 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 CheckboxField({
|
|
|
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
|
|
@@ -56,6 +62,7 @@ function CheckboxField({
|
|
|
56
62
|
[showError, errorId]
|
|
57
63
|
)}
|
|
58
64
|
aria-invalid={isInvalid || undefined}
|
|
65
|
+
aria-required={requiresValue || undefined}
|
|
59
66
|
checked={field.state.value ?? false}
|
|
60
67
|
id={id}
|
|
61
68
|
name={field.name}
|
|
@@ -2,9 +2,11 @@
|
|
|
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
12
|
import { type ReactNode, useId } from "react"
|
|
@@ -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,7 +99,7 @@ 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
105
|
const id = useId()
|
|
@@ -103,13 +110,20 @@ function ComboboxField<T = string>({
|
|
|
103
110
|
const showError = isInvalid && !hideErrors
|
|
104
111
|
const schemaRequired = useIsFieldRequired(field)
|
|
105
112
|
const isRequired = required ?? schemaRequired
|
|
113
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
114
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
115
|
+
const canClear = showClear ?? !requiresValue
|
|
106
116
|
|
|
107
117
|
const fieldValue = field.state.value
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
113
127
|
|
|
114
128
|
return (
|
|
115
129
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
@@ -120,13 +134,14 @@ function ComboboxField<T = string>({
|
|
|
120
134
|
</FieldLabel>
|
|
121
135
|
)}
|
|
122
136
|
<Combobox
|
|
137
|
+
disabled={disabled}
|
|
123
138
|
filter={filter}
|
|
124
139
|
inputValue={inputValue}
|
|
125
140
|
items={items}
|
|
126
141
|
onInputValueChange={onInputValueChange}
|
|
127
142
|
onValueChange={(next) => {
|
|
128
143
|
if (next === null) {
|
|
129
|
-
field.handleChange(undefined)
|
|
144
|
+
field.handleChange(getEmptyValue(field, undefined))
|
|
130
145
|
return
|
|
131
146
|
}
|
|
132
147
|
field.handleChange(isItemObject(next) ? next.value : next)
|
|
@@ -139,12 +154,14 @@ function ComboboxField<T = string>({
|
|
|
139
154
|
[showError, errorId]
|
|
140
155
|
)}
|
|
141
156
|
aria-invalid={isInvalid || undefined}
|
|
157
|
+
aria-required={requiresValue || undefined}
|
|
142
158
|
className={className}
|
|
159
|
+
disabled={disabled}
|
|
143
160
|
id={id}
|
|
144
161
|
name={field.name}
|
|
145
162
|
onBlur={field.handleBlur}
|
|
146
163
|
placeholder={placeholder}
|
|
147
|
-
showClear={
|
|
164
|
+
showClear={canClear && value !== null && value !== "" && !loading}
|
|
148
165
|
>
|
|
149
166
|
{loading && (
|
|
150
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 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
|
/>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useFormContext } from "@zeno-lib/forms/lib/contexts"
|
|
4
|
+
import { useSubmitError } from "@zeno-lib/forms/lib/submit-error"
|
|
5
|
+
import { useSelector } from "@zeno-lib/forms/tanstack"
|
|
6
|
+
import type { ComponentProps, ReactNode } from "react"
|
|
7
|
+
import { FieldError } from "@/components/ui/field"
|
|
8
|
+
|
|
9
|
+
type FormErrorProps = Omit<
|
|
10
|
+
ComponentProps<typeof FieldError>,
|
|
11
|
+
"children" | "errors"
|
|
12
|
+
> & {
|
|
13
|
+
/**
|
|
14
|
+
* Shown when the submit handler throws (a network failure, an unexpected
|
|
15
|
+
* server error). Defaults to `"Something went wrong. Try again."`. A
|
|
16
|
+
* function gets what was thrown.
|
|
17
|
+
*/
|
|
18
|
+
submitErrorMessage?: ReactNode | ((error: unknown) => ReactNode)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The form-level error, as a `role="alert"` message: the `formErrors` an
|
|
23
|
+
* action returns through `submitAction`, a `ValidationError`'s `formError`,
|
|
24
|
+
* or `submitErrorMessage` when the submit throws. Renders nothing otherwise.
|
|
25
|
+
* It clears when the user edits the form, and a thrown one also when they
|
|
26
|
+
* submit again.
|
|
27
|
+
*/
|
|
28
|
+
function FormError({
|
|
29
|
+
submitErrorMessage = "Something went wrong. Try again.",
|
|
30
|
+
...props
|
|
31
|
+
}: FormErrorProps) {
|
|
32
|
+
const form = useFormContext()
|
|
33
|
+
const formError = useSelector(form.store, (state) => state.errorMap.onSubmit)
|
|
34
|
+
const submitError = useSubmitError(form)
|
|
35
|
+
|
|
36
|
+
// `submitAction` joins several messages with a line break. A schema's
|
|
37
|
+
// form-level result is an object, not a message, and isn't shown here. It
|
|
38
|
+
// wins over a thrown submit: it says what went wrong.
|
|
39
|
+
if (typeof formError === "string" && formError !== "") {
|
|
40
|
+
return (
|
|
41
|
+
<FieldError
|
|
42
|
+
data-slot="form-error"
|
|
43
|
+
errors={formError.split("\n").map((message) => ({ message }))}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
if (!submitError) {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
return (
|
|
52
|
+
<FieldError data-slot="form-error" {...props}>
|
|
53
|
+
{typeof submitErrorMessage === "function"
|
|
54
|
+
? submitErrorMessage(submitError.error)
|
|
55
|
+
: submitErrorMessage}
|
|
56
|
+
</FieldError>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type { FormErrorProps }
|
|
61
|
+
export { FormError }
|
package/src/fields/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from "./checkbox-group-field"
|
|
|
4
4
|
export * from "./combobox-field"
|
|
5
5
|
export * from "./date-picker-field"
|
|
6
6
|
export * from "./email-field"
|
|
7
|
+
export * from "./form-error"
|
|
7
8
|
export * from "./input-field"
|
|
8
9
|
export * from "./money-field"
|
|
9
10
|
export * from "./multi-select-field"
|
|
@@ -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}
|