@zeno-lib/forms 0.4.0 → 0.6.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 (39) hide show
  1. package/dist/create-zeno-form.d.mts +8 -2
  2. package/dist/create-zeno-form.mjs +4 -0
  3. package/dist/form-element.mjs +7 -5
  4. package/dist/index.d.mts +2 -1
  5. package/dist/index.mjs +2 -1
  6. package/dist/lib/focus-first-invalid.d.mts +23 -0
  7. package/dist/lib/focus-first-invalid.mjs +37 -0
  8. package/dist/lib/submit-action.d.mts +8 -2
  9. package/dist/lib/submit-action.mjs +4 -2
  10. package/dist/lib/submit-error.d.mts +36 -0
  11. package/dist/lib/submit-error.mjs +66 -0
  12. package/dist/lib/use-field-control.d.mts +76 -0
  13. package/dist/lib/use-field-control.mjs +67 -0
  14. package/dist/lib/use-form-dialog.d.mts +15 -1
  15. package/dist/lib/use-form-dialog.mjs +20 -2
  16. package/package.json +1 -1
  17. package/src/control-field.test.tsx +338 -0
  18. package/src/create-form.tsx +5 -0
  19. package/src/create-zeno-form.tsx +32 -1
  20. package/src/fields/control-field.tsx +57 -0
  21. package/src/fields/field-shell.tsx +73 -0
  22. package/src/fields/form-error.tsx +61 -0
  23. package/src/fields/index.ts +3 -0
  24. package/src/fields/input-field.tsx +14 -48
  25. package/src/fields/textarea-field.tsx +14 -48
  26. package/src/form-dialog.test.tsx +493 -2
  27. package/src/form-dialog.tsx +217 -33
  28. package/src/form-element.tsx +13 -11
  29. package/src/form-error.test.tsx +255 -0
  30. package/src/index.ts +6 -0
  31. package/src/lib/focus-first-invalid.test.ts +22 -0
  32. package/src/lib/focus-first-invalid.ts +52 -0
  33. package/src/lib/submit-action.ts +18 -4
  34. package/src/lib/submit-error.ts +133 -0
  35. package/src/lib/use-field-control.ts +181 -0
  36. package/src/lib/use-form-dialog.ts +49 -1
  37. package/src/submit-action.test.tsx +103 -0
  38. package/src/use-app-fields.test-d.ts +51 -1
  39. package/src/use-form.focus-invalid.test.tsx +34 -0
@@ -0,0 +1,338 @@
1
+ import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
2
+ import userEvent from "@zeno-lib/test/user-event"
3
+ import type { ComponentProps } from "react"
4
+ import { afterEach, describe, expect, test, vi } from "vitest"
5
+ import { z } from "zod"
6
+ import { Form, FormProvider, useForm } from "./create-form"
7
+
8
+ afterEach(() => {
9
+ cleanup()
10
+ })
11
+
12
+ const PHONE = /Phone/
13
+ const SUBMIT = { name: "Submit" }
14
+
15
+ // A control a `<label htmlFor>` can't name: only `aria-labelledby` does.
16
+ function StarRating({
17
+ disabled,
18
+ name: _name,
19
+ onChange,
20
+ value,
21
+ ...props
22
+ }: Omit<ComponentProps<"div">, "onChange"> & {
23
+ disabled?: boolean
24
+ name?: string
25
+ onChange: (value: number) => void
26
+ value?: number | null
27
+ }) {
28
+ return (
29
+ <div role="radiogroup" {...props}>
30
+ {[1, 2, 3].map((star) => (
31
+ // biome-ignore lint/a11y/useSemanticElements: a custom radio group, as a star rating draws one.
32
+ <button
33
+ aria-checked={value === star}
34
+ disabled={disabled}
35
+ key={star}
36
+ onClick={() => onChange(star)}
37
+ role="radio"
38
+ type="button"
39
+ >
40
+ {star}
41
+ </button>
42
+ ))}
43
+ </div>
44
+ )
45
+ }
46
+
47
+ // A text control that reports its value, not a change event.
48
+ function PhoneInput({
49
+ onChange,
50
+ ...props
51
+ }: Omit<ComponentProps<"input">, "onChange" | "value"> & {
52
+ onChange: (value: string) => void
53
+ value: string
54
+ }) {
55
+ return (
56
+ <input
57
+ {...props}
58
+ onChange={(event) => onChange(event.target.value)}
59
+ type="tel"
60
+ />
61
+ )
62
+ }
63
+
64
+ describe("ControlField", () => {
65
+ test("names and describes its control, and flags it once invalid", async () => {
66
+ const user = userEvent.setup()
67
+ const schema = z.object({ rating: z.number("Rate it") })
68
+ function H() {
69
+ const form = useForm({ onSubmit: vi.fn(), schema })
70
+ const { ControlField, SubmitButton } = form
71
+ return (
72
+ <FormProvider form={form}>
73
+ <Form>
74
+ <ControlField
75
+ description="Out of three."
76
+ label="Rating"
77
+ name="rating"
78
+ render={(control) => <StarRating {...control} />}
79
+ />
80
+ <SubmitButton>Submit</SubmitButton>
81
+ </Form>
82
+ </FormProvider>
83
+ )
84
+ }
85
+ const { container } = render(<H />)
86
+ const group = screen.getByRole("radiogroup", { name: "Rating" })
87
+ const description = screen.getByText("Out of three.")
88
+ expect(group.getAttribute("aria-describedby")).toBe(description.id)
89
+ expect(group.hasAttribute("aria-invalid")).toBe(false)
90
+
91
+ await user.click(screen.getByRole("button", SUBMIT))
92
+ const error = await screen.findByRole("alert")
93
+ expect(error.textContent).toBe("Rate it")
94
+ expect(group.getAttribute("aria-invalid")).toBe("true")
95
+ expect(group.getAttribute("aria-describedby")).toBe(
96
+ `${description.id} ${error.id}`
97
+ )
98
+ expect(
99
+ container
100
+ .querySelector('[data-field="rating"]')
101
+ ?.getAttribute("data-invalid")
102
+ ).toBe("true")
103
+
104
+ await user.click(screen.getByRole("radio", { name: "2" }))
105
+ expect(screen.queryByRole("alert")).toBeNull()
106
+ expect(group.hasAttribute("aria-invalid")).toBe(false)
107
+ })
108
+
109
+ test("marks the field touched on blur, so its error shows", async () => {
110
+ const user = userEvent.setup()
111
+ const schema = z.object({ phone: z.string().min(1, "Enter a phone") })
112
+ function H() {
113
+ const form = useForm({ onSubmit: vi.fn(), schema })
114
+ const { ControlField } = form
115
+ return (
116
+ <FormProvider form={form}>
117
+ <Form>
118
+ <ControlField
119
+ label="Phone"
120
+ name="phone"
121
+ render={(control) => <PhoneInput {...control} />}
122
+ />
123
+ </Form>
124
+ </FormProvider>
125
+ )
126
+ }
127
+ render(<H />)
128
+ await user.click(screen.getByLabelText(PHONE))
129
+ expect(screen.queryByRole("alert")).toBeNull()
130
+ await user.tab()
131
+ expect((await screen.findByRole("alert")).textContent).toBe("Enter a phone")
132
+ })
133
+
134
+ test("takes required from the schema unless `required` says otherwise", () => {
135
+ const schema = z.object({
136
+ forced: z.string().optional(),
137
+ kept: z.string().min(1),
138
+ lifted: z.string().min(1),
139
+ open: z.string().optional(),
140
+ })
141
+ const FIELDS = [
142
+ { name: "kept" },
143
+ { name: "open" },
144
+ { name: "forced", required: true },
145
+ { name: "lifted", required: false },
146
+ ] as const
147
+ function H({ requiredIndicator }: { requiredIndicator: boolean }) {
148
+ const form = useForm({ onSubmit: vi.fn(), requiredIndicator, schema })
149
+ const { ControlField } = form
150
+ return (
151
+ <FormProvider form={form}>
152
+ <Form>
153
+ {FIELDS.map((props) => (
154
+ <ControlField
155
+ key={props.name}
156
+ label={props.name}
157
+ render={(control) => (
158
+ <PhoneInput {...control} value={control.value ?? ""} />
159
+ )}
160
+ {...props}
161
+ />
162
+ ))}
163
+ </Form>
164
+ </FormProvider>
165
+ )
166
+ }
167
+ for (const requiredIndicator of [true, false]) {
168
+ const { container, unmount } = render(
169
+ <H requiredIndicator={requiredIndicator} />
170
+ )
171
+ const required = screen
172
+ .getAllByRole("textbox")
173
+ .filter((input) => input.getAttribute("aria-required") === "true")
174
+ .map((input) => input.getAttribute("name"))
175
+ expect(required).toEqual(["kept", "forced"])
176
+ // `required` overrides `requiredIndicator: false` too.
177
+ expect(
178
+ container.querySelectorAll('[data-slot="required-indicator"]')
179
+ ).toHaveLength(requiredIndicator ? 2 : 1)
180
+ unmount()
181
+ }
182
+ })
183
+
184
+ test("clearing stores the value the schema accepts as empty", async () => {
185
+ const user = userEvent.setup()
186
+ const schema = z.object({
187
+ count: z.number().nullable(),
188
+ nullable: z.string().min(1).nullable(),
189
+ optional: z.string().min(1).optional(),
190
+ tags: z.array(z.string()),
191
+ text: z.string(),
192
+ })
193
+ const captured: { values?: unknown } = {}
194
+ function H() {
195
+ const form = useForm({
196
+ defaultValues: {
197
+ count: 1,
198
+ nullable: "a",
199
+ optional: "b",
200
+ tags: ["c"],
201
+ text: "d",
202
+ },
203
+ onSubmit: vi.fn(),
204
+ schema,
205
+ })
206
+ const { ControlField, Subscribe } = form
207
+ return (
208
+ <FormProvider form={form}>
209
+ <Form>
210
+ {(["nullable", "optional", "text"] as const).map((name) => (
211
+ <ControlField
212
+ key={name}
213
+ label={name}
214
+ name={name}
215
+ render={(control) => (
216
+ <PhoneInput {...control} value={control.value ?? ""} />
217
+ )}
218
+ />
219
+ ))}
220
+ <ControlField
221
+ label="Tags"
222
+ name="tags"
223
+ render={({ onChange }) => (
224
+ <button onClick={() => onChange([])} type="button">
225
+ Clear tags
226
+ </button>
227
+ )}
228
+ />
229
+ {/* A number control reports `NaN` once cleared. */}
230
+ <ControlField
231
+ label="Count"
232
+ name="count"
233
+ render={({ onChange }) => (
234
+ <button onClick={() => onChange(Number.NaN)} type="button">
235
+ Clear count
236
+ </button>
237
+ )}
238
+ />
239
+ <Subscribe selector={(state) => state.values}>
240
+ {(values) => {
241
+ captured.values = values
242
+ return null
243
+ }}
244
+ </Subscribe>
245
+ </Form>
246
+ </FormProvider>
247
+ )
248
+ }
249
+ render(<H />)
250
+ await user.clear(screen.getByLabelText("nullable"))
251
+ await user.clear(screen.getByLabelText("optional"))
252
+ await user.clear(screen.getByLabelText("text"))
253
+ await user.click(screen.getByRole("button", { name: "Clear tags" }))
254
+ await user.click(screen.getByRole("button", { name: "Clear count" }))
255
+ expect(captured.values).toStrictEqual({
256
+ count: null,
257
+ nullable: null,
258
+ optional: undefined,
259
+ tags: [],
260
+ text: "",
261
+ })
262
+ expect(schema.safeParse(captured.values).success).toBe(true)
263
+ })
264
+
265
+ test("keeps one `onChange` across renders, so a control can memoize on it", async () => {
266
+ const user = userEvent.setup()
267
+ const handlers = new Set<unknown>()
268
+ function H() {
269
+ const form = useForm({ defaultValues: { phone: "" }, onSubmit: vi.fn() })
270
+ const { ControlField } = form
271
+ return (
272
+ <FormProvider form={form}>
273
+ <Form>
274
+ <ControlField
275
+ label="Phone"
276
+ name="phone"
277
+ render={(control) => {
278
+ handlers.add(control.onChange)
279
+ return <PhoneInput {...control} />
280
+ }}
281
+ />
282
+ </Form>
283
+ </FormProvider>
284
+ )
285
+ }
286
+ render(<H />)
287
+ await user.type(screen.getByLabelText(PHONE), "079")
288
+ expect(handlers.size).toBe(1)
289
+ })
290
+
291
+ test("refuses a change event, rather than storing it", async () => {
292
+ const user = userEvent.setup()
293
+ const errors: unknown[] = []
294
+ const onError = (event: ErrorEvent) => {
295
+ event.preventDefault()
296
+ errors.push(event.error)
297
+ }
298
+ window.addEventListener("error", onError)
299
+ const captured: { values?: unknown } = {}
300
+ function H() {
301
+ const form = useForm({ defaultValues: { phone: "" }, onSubmit: vi.fn() })
302
+ const { ControlField, Subscribe } = form
303
+ return (
304
+ <FormProvider form={form}>
305
+ <Form>
306
+ <ControlField
307
+ label="Phone"
308
+ name="phone"
309
+ render={({ onChange }) => (
310
+ // The mistake: an event handed to `onChange` as the value.
311
+ <button
312
+ onClick={(event) => onChange(event as never)}
313
+ type="button"
314
+ >
315
+ Set
316
+ </button>
317
+ )}
318
+ />
319
+ <Subscribe selector={(state) => state.values}>
320
+ {(values) => {
321
+ captured.values = values
322
+ return null
323
+ }}
324
+ </Subscribe>
325
+ </Form>
326
+ </FormProvider>
327
+ )
328
+ }
329
+ try {
330
+ render(<H />)
331
+ await user.click(screen.getByRole("button", { name: "Set" }))
332
+ } finally {
333
+ window.removeEventListener("error", onError)
334
+ }
335
+ expect(errors).toEqual([expect.any(TypeError)])
336
+ expect(captured.values).toStrictEqual({ phone: "" })
337
+ })
338
+ })
@@ -6,8 +6,10 @@ import { createZenoForm } from "@zeno-lib/forms"
6
6
  import { CheckboxField } from "./fields/checkbox-field"
7
7
  import { CheckboxGroupField } from "./fields/checkbox-group-field"
8
8
  import { ComboboxField } from "./fields/combobox-field"
9
+ import { ControlField } from "./fields/control-field"
9
10
  import { DatePickerField } from "./fields/date-picker-field"
10
11
  import { EmailField } from "./fields/email-field"
12
+ import { FormError } from "./fields/form-error"
11
13
  import { InputField } from "./fields/input-field"
12
14
  import { MoneyField } from "./fields/money-field"
13
15
  import { MultiSelectField } from "./fields/multi-select-field"
@@ -31,6 +33,7 @@ const { useAppForm, useForm, withFieldGroup, withForm } = createZenoForm({
31
33
  CheckboxField,
32
34
  CheckboxGroupField,
33
35
  ComboboxField,
36
+ ControlField,
34
37
  DatePickerField,
35
38
  EmailField,
36
39
  InputField,
@@ -48,6 +51,7 @@ const { useAppForm, useForm, withFieldGroup, withForm } = createZenoForm({
48
51
  YearField,
49
52
  },
50
53
  formComponents: {
54
+ FormError,
51
55
  ResetButton,
52
56
  SubmitButton,
53
57
  },
@@ -55,5 +59,6 @@ const { useAppForm, useForm, withFieldGroup, withForm } = createZenoForm({
55
59
 
56
60
  export * from "@zeno-lib/forms"
57
61
  export { ValidationSpinner } from "./addons/validation-spinner"
62
+ export { FieldShell } from "./fields/field-shell"
58
63
  export { RadioGroupFieldItem } from "./fields/radio-group-field"
59
64
  export { useAppForm, useForm, withFieldGroup, withForm }
@@ -4,6 +4,7 @@
4
4
  import type {
5
5
  AnyFormApi,
6
6
  DeepKeys,
7
+ DeepValue,
7
8
  FormAsyncValidateOrFn,
8
9
  FormOptions,
9
10
  FormValidateOrFn,
@@ -18,6 +19,8 @@ import {
18
19
  getRequiredPaths,
19
20
  type StandardSchemaLike,
20
21
  } from "./lib/schema-required"
22
+ import { setSubmitError } from "./lib/submit-error"
23
+ import type { FieldControlProps } from "./lib/use-field-control"
21
24
  import { useRebasedDefaultValues } from "./lib/use-rebased-default-values"
22
25
  import { useUnsavedChangesWarning } from "./lib/use-unsaved-changes-warning"
23
26
  import { ValidationError } from "./lib/validation-error"
@@ -272,9 +275,26 @@ type WithValidators = {
272
275
  // biome-ignore lint/suspicious/noExplicitAny: field components accept arbitrary props.
273
276
  type AnyComponent = React.ComponentType<any>
274
277
 
278
+ // A field that hands `render` a `FieldControlProps` (the kit's `ControlField`)
279
+ // gets the control typed with the value at `name`, which its own generic
280
+ // can't see. Matching on the parameter, not on `render` as a whole, leaves a
281
+ // field whose `render` takes something else (or nothing) as declared.
282
+ type WithControlValue<P, V> = P extends {
283
+ render: (control: infer C) => ReactNode
284
+ }
285
+ ? // biome-ignore lint/suspicious/noExplicitAny: matches any value type.
286
+ [C] extends [FieldControlProps<any>]
287
+ ? Omit<P, "render"> & {
288
+ render: (control: FieldControlProps<V>) => ReactNode
289
+ }
290
+ : P
291
+ : P
292
+
275
293
  type FieldWrappers<FC extends Record<string, AnyComponent>, T> = {
276
294
  [K in keyof FC]: <N extends DeepKeys<T>>(
277
- props: ComponentProps<FC[K]> & { name: N } & WithValidators
295
+ props: WithControlValue<ComponentProps<FC[K]>, DeepValue<T, N>> & {
296
+ name: N
297
+ } & WithValidators
278
298
  ) => ReactNode
279
299
  }
280
300
 
@@ -439,6 +459,7 @@ export function createZenoForm<
439
459
 
440
460
  const wrappedOnSubmit = userOnSubmit
441
461
  ? ((async (props: { formApi: AnyFormApi; value: TFormData }) => {
462
+ const { submissionAttempts } = props.formApi.state
442
463
  try {
443
464
  await (
444
465
  userOnSubmit as unknown as (
@@ -450,6 +471,16 @@ export function createZenoForm<
450
471
  applyValidationError(props.formApi, error)
451
472
  return
452
473
  }
474
+ // Kept for `FormError`, unless an edit, a reset (a dialog closed
475
+ // meanwhile) or a newer submit came first: then it is about
476
+ // values no longer on screen.
477
+ const { state } = props.formApi
478
+ if (
479
+ state.values === props.value &&
480
+ state.submissionAttempts === submissionAttempts
481
+ ) {
482
+ setSubmitError(props.formApi, error)
483
+ }
453
484
  throw error
454
485
  }
455
486
  }) as typeof userOnSubmit)
@@ -0,0 +1,57 @@
1
+ "use client"
2
+
3
+ import {
4
+ type FieldControlProps,
5
+ useFieldControl,
6
+ } from "@zeno-lib/forms/lib/use-field-control"
7
+ import type { ReactNode } from "react"
8
+ import { FieldShell } from "./field-shell"
9
+
10
+ type ControlFieldProps<TValue = unknown> = {
11
+ description?: ReactNode
12
+ disabled?: boolean
13
+ label?: ReactNode
14
+ /**
15
+ * Renders the control, given the props that bind it to the field: `id`,
16
+ * `name`, `value`, `onChange(value)`, `onBlur`, `disabled` and the
17
+ * `aria-*`. Spread them on a control that takes its value that way, or map
18
+ * them for one that doesn't. Keep `aria-invalid` on the focusable element
19
+ * (or a group root around it): a failed submit focuses the first invalid
20
+ * field by it.
21
+ */
22
+ render: (control: FieldControlProps<TValue>) => ReactNode
23
+ /**
24
+ * Mark the field required, or not, over the schema. Drives the `*` and
25
+ * `aria-required`.
26
+ */
27
+ required?: boolean
28
+ }
29
+
30
+ /**
31
+ * Binds a controlled component the kit has no field for (a phone input, a
32
+ * star rating, a color picker) in the kit's field layout. Clearing it stores
33
+ * the field's empty value, as the kit's fields do.
34
+ */
35
+ function ControlField<TValue = unknown>({
36
+ description,
37
+ disabled,
38
+ label,
39
+ render,
40
+ required,
41
+ }: ControlFieldProps<TValue>) {
42
+ const { controlProps, ...shell } = useFieldControl<TValue>({
43
+ description,
44
+ disabled,
45
+ label,
46
+ required,
47
+ })
48
+
49
+ return (
50
+ <FieldShell {...shell} description={description} label={label}>
51
+ {render(controlProps)}
52
+ </FieldShell>
53
+ )
54
+ }
55
+
56
+ export type { ControlFieldProps }
57
+ export { ControlField }
@@ -0,0 +1,73 @@
1
+ "use client"
2
+
3
+ import type { FieldControl } from "@zeno-lib/forms/lib/use-field-control"
4
+ import type { ReactNode } from "react"
5
+ import {
6
+ Field,
7
+ FieldDescription,
8
+ FieldError,
9
+ FieldLabel,
10
+ } from "@/components/ui/field"
11
+ import { RequiredIndicator } from "../lib/required-indicator"
12
+
13
+ type FieldShellProps = Pick<
14
+ FieldControl,
15
+ | "descriptionId"
16
+ | "errorId"
17
+ | "errors"
18
+ | "id"
19
+ | "isInvalid"
20
+ | "labelId"
21
+ | "name"
22
+ | "showRequiredIndicator"
23
+ > & {
24
+ /** The control, bound with `useFieldControl`'s `controlProps`. */
25
+ children: ReactNode
26
+ description?: ReactNode
27
+ label?: ReactNode
28
+ }
29
+
30
+ /**
31
+ * The field layout every kit field shares: the label with its `*`, the
32
+ * control, the description and the error, tied together by the ids
33
+ * `useFieldControl` returns. Spread the rest of its result here:
34
+ *
35
+ * ```tsx
36
+ * const { controlProps, ...shell } = useFieldControl({ description, label })
37
+ * <FieldShell {...shell} description={description} label={label}>
38
+ * <MyControl {...controlProps} />
39
+ * </FieldShell>
40
+ * ```
41
+ */
42
+ function FieldShell({
43
+ children,
44
+ description,
45
+ descriptionId,
46
+ errorId,
47
+ errors,
48
+ id,
49
+ isInvalid,
50
+ label,
51
+ labelId,
52
+ name,
53
+ showRequiredIndicator,
54
+ }: FieldShellProps) {
55
+ return (
56
+ <Field data-field={name} data-invalid={isInvalid}>
57
+ {label && (
58
+ <FieldLabel htmlFor={id} id={labelId}>
59
+ {label}
60
+ {showRequiredIndicator && <RequiredIndicator />}
61
+ </FieldLabel>
62
+ )}
63
+ {children}
64
+ {description && (
65
+ <FieldDescription id={descriptionId}>{description}</FieldDescription>
66
+ )}
67
+ {errors.length > 0 && <FieldError errors={errors} id={errorId} />}
68
+ </Field>
69
+ )
70
+ }
71
+
72
+ export type { FieldShellProps }
73
+ export { FieldShell }
@@ -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 }
@@ -2,8 +2,11 @@
2
2
  export * from "./checkbox-field"
3
3
  export * from "./checkbox-group-field"
4
4
  export * from "./combobox-field"
5
+ export * from "./control-field"
5
6
  export * from "./date-picker-field"
6
7
  export * from "./email-field"
8
+ export * from "./field-shell"
9
+ export * from "./form-error"
7
10
  export * from "./input-field"
8
11
  export * from "./money-field"
9
12
  export * from "./multi-select-field"