@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.
Files changed (59) hide show
  1. package/dist/create-zeno-form.mjs +9 -3
  2. package/dist/form-element.mjs +7 -5
  3. package/dist/index.d.mts +2 -2
  4. package/dist/index.mjs +3 -3
  5. package/dist/lib/empty-value.d.mts +12 -0
  6. package/dist/lib/empty-value.mjs +68 -0
  7. package/dist/lib/focus-first-invalid.d.mts +23 -0
  8. package/dist/lib/focus-first-invalid.mjs +37 -0
  9. package/dist/lib/schema-defaults.d.mts +9 -1
  10. package/dist/lib/schema-defaults.mjs +33 -1
  11. package/dist/lib/schema-required.d.mts +2 -1
  12. package/dist/lib/schema-required.mjs +49 -21
  13. package/dist/lib/submit-action.mjs +1 -1
  14. package/dist/lib/submit-error.d.mts +36 -0
  15. package/dist/lib/submit-error.mjs +66 -0
  16. package/dist/lib/use-form-dialog.d.mts +15 -1
  17. package/dist/lib/use-form-dialog.mjs +20 -2
  18. package/dist/lib/use-is-invalid.d.mts +2 -1
  19. package/dist/lib/use-is-invalid.mjs +5 -2
  20. package/dist/lib/validation-modes.d.mts +5 -1
  21. package/dist/lib/validation-modes.mjs +9 -4
  22. package/package.json +1 -1
  23. package/src/create-form.tsx +2 -0
  24. package/src/create-zeno-form.tsx +25 -5
  25. package/src/fields/checkbox-field.tsx +8 -1
  26. package/src/fields/combobox-field.tsx +29 -12
  27. package/src/fields/date-picker-field.tsx +37 -9
  28. package/src/fields/email-field.tsx +8 -0
  29. package/src/fields/form-error.tsx +61 -0
  30. package/src/fields/index.ts +1 -0
  31. package/src/fields/input-field.tsx +6 -2
  32. package/src/fields/money-field.tsx +3 -1
  33. package/src/fields/multi-select-field.tsx +8 -1
  34. package/src/fields/number-field.tsx +9 -7
  35. package/src/fields/otp-field.tsx +8 -1
  36. package/src/fields/percentage-field.tsx +3 -1
  37. package/src/fields/radio-group-field.tsx +11 -4
  38. package/src/fields/select-field.tsx +26 -3
  39. package/src/fields/switch-field.tsx +8 -1
  40. package/src/fields/textarea-field.tsx +8 -1
  41. package/src/fields/year-field.tsx +3 -1
  42. package/src/form-dialog.test.tsx +493 -2
  43. package/src/form-dialog.tsx +217 -33
  44. package/src/form-element.tsx +13 -11
  45. package/src/form-error.test.tsx +255 -0
  46. package/src/index.ts +1 -0
  47. package/src/lib/empty-value.test.ts +72 -0
  48. package/src/lib/empty-value.ts +130 -0
  49. package/src/lib/focus-first-invalid.test.ts +22 -0
  50. package/src/lib/focus-first-invalid.ts +52 -0
  51. package/src/lib/schema-defaults.ts +50 -1
  52. package/src/lib/schema-required.test.ts +24 -2
  53. package/src/lib/schema-required.ts +81 -43
  54. package/src/lib/submit-error.ts +133 -0
  55. package/src/lib/use-form-dialog.ts +49 -1
  56. package/src/lib/use-is-invalid.ts +14 -1
  57. package/src/lib/validation-modes.ts +18 -6
  58. package/src/use-app-fields.test.tsx +312 -2
  59. 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
- const state = getFormZenoState(form);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeno-lib/forms",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "repository": {
5
5
  "url": "https://github.com/zeno-lib/zeno.git",
6
6
  "directory": "packages/forms"
@@ -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
  },
@@ -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 { getRequiredPaths } from "./lib/schema-required"
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 && requiredIndicator
472
+ schema
457
473
  ? getRequiredPaths(schema as Parameters<typeof getRequiredPaths>[0])
458
474
  : new Set<string>(),
459
- [schema, requiredIndicator]
475
+ [schema]
460
476
  )
461
477
 
462
478
  const schemaDefaults = useMemo(
463
479
  () =>
464
480
  schema
465
- ? extractZodDefaults(
466
- schema as Parameters<typeof extractZodDefaults>[0]
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
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
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
- * If `true`, show the clear button when a value is selected. Defaults to
47
- * `true` — pass `false` for required-style comboboxes where clearing is
48
- * disallowed.
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
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
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 = true,
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
- 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)
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={showClear && value !== null && value !== "" && !loading}
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<CalendarProps, "mode" | "onSelect" | "selected">
39
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
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
- // Base UI's Popover handles open state — we still need blur on the
98
- // form's reactive store. Passing onBlur here mirrors how `<select>`
99
- // commits on close.
100
- onBlur={field.handleBlur}
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) => field.handleChange(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 }
@@ -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
- * Force the required `*` indicator on or off for this field. When omitted,
30
- * the value is derived from the form's schema.
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) => field.handleChange(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
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
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
- function NumberField(props: NumberFieldProps) {
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="numeric"
21
+ inputMode={inputMode}
22
22
  onChange={(event: ChangeEvent<HTMLInputElement>) => {
23
23
  const next = event.target.valueAsNumber
24
- field.handleChange(Number.isNaN(next) ? undefined : next)
24
+ field.handleChange(
25
+ Number.isNaN(next) ? getEmptyValue(field, undefined) : next
26
+ )
25
27
  }}
26
28
  type="number"
27
29
  value={inputValue}
@@ -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
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
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}