@zeno-lib/forms 0.1.0 → 0.2.1

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 (63) hide show
  1. package/dist/create-zeno-form.d.mts +118 -0
  2. package/dist/create-zeno-form.mjs +121 -0
  3. package/dist/form-element.d.mts +13 -0
  4. package/dist/form-element.mjs +31 -0
  5. package/dist/index.d.mts +11 -0
  6. package/dist/index.mjs +10 -0
  7. package/dist/lib/action-result.d.mts +22 -0
  8. package/dist/lib/action-result.mjs +32 -0
  9. package/dist/lib/apply-validation-error.d.mts +6 -0
  10. package/dist/lib/apply-validation-error.mjs +41 -0
  11. package/dist/lib/aria.d.mts +4 -0
  12. package/dist/lib/aria.mjs +7 -0
  13. package/dist/lib/contexts.d.mts +5 -0
  14. package/dist/lib/contexts.mjs +7 -0
  15. package/dist/lib/formatted-number.d.mts +56 -0
  16. package/dist/lib/formatted-number.mjs +138 -0
  17. package/dist/lib/schema-defaults.d.mts +17 -0
  18. package/dist/lib/schema-defaults.mjs +75 -0
  19. package/dist/lib/schema-required.d.mts +25 -0
  20. package/dist/lib/schema-required.mjs +72 -0
  21. package/dist/lib/submit-action.d.mts +64 -0
  22. package/dist/lib/submit-action.mjs +43 -0
  23. package/dist/lib/use-form-dialog.d.mts +73 -0
  24. package/dist/lib/use-form-dialog.mjs +90 -0
  25. package/dist/lib/use-formatted-number.d.mts +61 -0
  26. package/dist/lib/use-formatted-number.mjs +104 -0
  27. package/dist/lib/use-is-invalid.d.mts +7 -0
  28. package/dist/lib/use-is-invalid.mjs +31 -0
  29. package/dist/lib/use-rebased-default-values.d.mts +10 -0
  30. package/dist/lib/use-rebased-default-values.mjs +21 -0
  31. package/dist/lib/use-unsaved-changes-warning.d.mts +6 -0
  32. package/dist/lib/use-unsaved-changes-warning.mjs +24 -0
  33. package/dist/lib/validation-error.d.mts +12 -0
  34. package/dist/lib/validation-error.mjs +11 -0
  35. package/dist/lib/validation-logic.d.mts +5 -0
  36. package/dist/lib/validation-logic.mjs +114 -0
  37. package/dist/lib/validation-modes.d.mts +16 -0
  38. package/dist/lib/validation-modes.mjs +38 -0
  39. package/dist/tanstack.d.mts +1 -0
  40. package/dist/tanstack.mjs +2 -0
  41. package/package.json +25 -7
  42. package/src/create-form.tsx +10 -0
  43. package/src/fields/checkbox-group-field.tsx +160 -0
  44. package/src/fields/index.ts +5 -0
  45. package/src/fields/money-field.tsx +123 -0
  46. package/src/fields/multi-select-field.tsx +193 -0
  47. package/src/fields/percentage-field.tsx +73 -0
  48. package/src/fields/year-field.tsx +56 -0
  49. package/src/form-dialog.test.tsx +161 -0
  50. package/src/form-dialog.tsx +253 -0
  51. package/src/formatted-number-fields.test.tsx +236 -0
  52. package/src/index.ts +13 -0
  53. package/src/lib/action-result.ts +61 -0
  54. package/src/lib/apply-validation-error.test.ts +15 -4
  55. package/src/lib/apply-validation-error.ts +38 -6
  56. package/src/lib/formatted-number.test.ts +157 -0
  57. package/src/lib/formatted-number.ts +307 -0
  58. package/src/lib/submit-action.test-d.ts +83 -0
  59. package/src/lib/submit-action.ts +178 -0
  60. package/src/lib/use-form-dialog.ts +184 -0
  61. package/src/lib/use-formatted-number.ts +235 -0
  62. package/src/multi-value-fields.test.tsx +116 -0
  63. package/src/submit-action.test.tsx +421 -0
@@ -0,0 +1,253 @@
1
+ "use client"
2
+
3
+ import { FormProvider } from "@zeno-lib/forms"
4
+ import {
5
+ type FormDialogController,
6
+ findFieldElement,
7
+ useLeaveGuard,
8
+ } from "@zeno-lib/forms/lib/use-form-dialog"
9
+ import { useUnsavedChangesWarning } from "@zeno-lib/forms/lib/use-unsaved-changes-warning"
10
+ import { type AnyFormApi, useSelector } from "@zeno-lib/forms/tanstack"
11
+ import {
12
+ type FormEvent,
13
+ type ReactElement,
14
+ type ReactNode,
15
+ useId,
16
+ useLayoutEffect,
17
+ useRef,
18
+ } from "react"
19
+ import {
20
+ AlertDialog,
21
+ AlertDialogAction,
22
+ AlertDialogCancel,
23
+ AlertDialogContent,
24
+ AlertDialogDescription,
25
+ AlertDialogFooter,
26
+ AlertDialogHeader,
27
+ AlertDialogTitle,
28
+ } from "@/components/ui/alert-dialog"
29
+ import { Button } from "@/components/ui/button"
30
+ import {
31
+ Dialog,
32
+ DialogClose,
33
+ DialogContent,
34
+ DialogDescription,
35
+ DialogFooter,
36
+ DialogHeader,
37
+ DialogTitle,
38
+ DialogTrigger,
39
+ } from "@/components/ui/dialog"
40
+ import { Spinner } from "@/components/ui/spinner"
41
+
42
+ type DiscardPromptText = {
43
+ title?: ReactNode
44
+ description?: ReactNode
45
+ confirmLabel?: ReactNode
46
+ cancelLabel?: ReactNode
47
+ }
48
+
49
+ // The slice of the `useForm()` API the dialog uses. Structural, so any Zeno /
50
+ // TanStack form fits whatever its validator and submit-meta generics are.
51
+ type DialogFormState = {
52
+ isDefaultValue: boolean
53
+ isSubmitSuccessful: boolean
54
+ isSubmitting: boolean
55
+ isValid: boolean
56
+ }
57
+
58
+ type DialogForm<TValues> = {
59
+ store: {
60
+ get: () => DialogFormState
61
+ subscribe: (listener: (state: DialogFormState) => void) => {
62
+ unsubscribe: () => void
63
+ }
64
+ }
65
+ readonly state: DialogFormState
66
+ handleSubmit(): unknown
67
+ reset(values?: TValues): void
68
+ }
69
+
70
+ type FormDialogProps<TValues> = {
71
+ /** The controller from `useFormDialog()`. */
72
+ dialog: FormDialogController<TValues>
73
+ /** The form from `useForm()`; its `onSubmit` runs on Save. */
74
+ form: DialogForm<TValues>
75
+ title: ReactNode
76
+ description?: ReactNode
77
+ /** The fields. Rendered inside the `<form>`. */
78
+ children: ReactNode
79
+ /** Element that opens the dialog (with the hook's defaults) on click. */
80
+ trigger?: ReactElement
81
+ /** Defaults to `"Save"`. */
82
+ submitLabel?: ReactNode
83
+ /** Defaults to `"Cancel"`. */
84
+ cancelLabel?: ReactNode
85
+ /** Extra footer content, rendered before Cancel / Save. */
86
+ footer?: ReactNode
87
+ /** Close after a successful submit. Defaults to `true`. */
88
+ closeOnSubmit?: boolean
89
+ /**
90
+ * Ask before closing with unsaved changes, and warn on page unload while
91
+ * open. Defaults to `true`.
92
+ */
93
+ guard?: boolean
94
+ /** Copy for the discard prompt. */
95
+ discardPrompt?: DiscardPromptText
96
+ /** Class for the dialog popup (e.g. a wider `sm:max-w-lg`). */
97
+ className?: string
98
+ /** Class for the `<form>` element. */
99
+ formClassName?: string
100
+ }
101
+
102
+ /**
103
+ * A dialog-hosted form: opens with fresh values per session, submits from a
104
+ * footer button outside the `<form>` (via `form="id"`), shows a spinner while
105
+ * submitting, closes on success, and asks before discarding unsaved changes
106
+ * (Cancel, ×, Escape, outside press) or leaving the page.
107
+ */
108
+ function FormDialog<TValues>({
109
+ cancelLabel = "Cancel",
110
+ children,
111
+ className,
112
+ closeOnSubmit = true,
113
+ description,
114
+ dialog,
115
+ discardPrompt,
116
+ footer,
117
+ form,
118
+ formClassName,
119
+ guard = true,
120
+ submitLabel = "Save",
121
+ title,
122
+ trigger,
123
+ }: FormDialogProps<TValues>) {
124
+ const formId = useId()
125
+ const popupRef = useRef<HTMLDivElement>(null)
126
+ const { close, defaultValues, focus, isOpen, open, session } = dialog
127
+
128
+ // `isDefaultValue`, not `isDirty`: `isDirty` stays true after the user
129
+ // reverts an edit, `isDefaultValue` compares the values themselves.
130
+ const hasChanges = useSelector(form.store, (state) => !state.isDefaultValue)
131
+ const isSubmitting = useSelector(form.store, (state) => state.isSubmitting)
132
+ const { cancelLeave, confirmLeave, isConfirming, requestLeave } =
133
+ useLeaveGuard({ hasUnsavedChanges: guard && hasChanges })
134
+ useUnsavedChangesWarning(
135
+ form as unknown as AnyFormApi,
136
+ guard && isOpen ? "if-changed" : false
137
+ )
138
+
139
+ // Start every session from its own defaults. `reset(values)` also rebases
140
+ // the form's defaults, so the session's values count as "unchanged".
141
+ // biome-ignore lint/correctness/useExhaustiveDependencies: once per session.
142
+ useLayoutEffect(() => {
143
+ if (session > 0) {
144
+ form.reset(defaultValues)
145
+ }
146
+ }, [session])
147
+
148
+ const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
149
+ event.preventDefault()
150
+ event.stopPropagation()
151
+ try {
152
+ await form.handleSubmit()
153
+ } catch {
154
+ return // the submit handler threw; keep the dialog open
155
+ }
156
+ const { isSubmitSuccessful, isValid } = form.state
157
+ if (closeOnSubmit && isSubmitSuccessful && isValid) {
158
+ // Close directly, past the guard: the changes are saved. The form is
159
+ // reset once the exit animation completes (see `onOpenChangeComplete`).
160
+ close()
161
+ }
162
+ }
163
+
164
+ return (
165
+ <Dialog
166
+ onOpenChange={(next) => {
167
+ if (next) {
168
+ open()
169
+ } else {
170
+ requestLeave(close)
171
+ }
172
+ }}
173
+ onOpenChangeComplete={(opened) => {
174
+ // Drop abandoned edits once the close animation is done, so the
175
+ // fields don't visibly snap back while fading out.
176
+ if (!opened) {
177
+ form.reset()
178
+ }
179
+ }}
180
+ open={isOpen}
181
+ >
182
+ {trigger && <DialogTrigger render={trigger} />}
183
+ <DialogContent
184
+ className={className}
185
+ initialFocus={() => {
186
+ const target = focus
187
+ ? findFieldElement(popupRef.current, focus)
188
+ : null
189
+ return target ?? true
190
+ }}
191
+ ref={popupRef}
192
+ >
193
+ <DialogHeader>
194
+ <DialogTitle>{title}</DialogTitle>
195
+ {description && <DialogDescription>{description}</DialogDescription>}
196
+ </DialogHeader>
197
+ <FormProvider form={form}>
198
+ <form
199
+ className={formClassName}
200
+ id={formId}
201
+ noValidate
202
+ onSubmit={handleSubmit}
203
+ >
204
+ {children}
205
+ </form>
206
+ </FormProvider>
207
+ <DialogFooter>
208
+ {footer}
209
+ <DialogClose render={<Button type="button" variant="outline" />}>
210
+ {cancelLabel}
211
+ </DialogClose>
212
+ <Button disabled={isSubmitting} form={formId} type="submit">
213
+ {isSubmitting && <Spinner />}
214
+ {submitLabel}
215
+ </Button>
216
+ </DialogFooter>
217
+ </DialogContent>
218
+ {/* Inside the Dialog root (so it nests over the form) but outside the
219
+ popup (so it can finish its own exit animation). */}
220
+ <AlertDialog
221
+ onOpenChange={(next) => {
222
+ if (!next) {
223
+ cancelLeave()
224
+ }
225
+ }}
226
+ open={isConfirming}
227
+ >
228
+ <AlertDialogContent size="sm">
229
+ <AlertDialogHeader>
230
+ <AlertDialogTitle>
231
+ {discardPrompt?.title ?? "Discard changes?"}
232
+ </AlertDialogTitle>
233
+ <AlertDialogDescription>
234
+ {discardPrompt?.description ??
235
+ "Your unsaved changes will be lost."}
236
+ </AlertDialogDescription>
237
+ </AlertDialogHeader>
238
+ <AlertDialogFooter>
239
+ <AlertDialogCancel>
240
+ {discardPrompt?.cancelLabel ?? "Keep editing"}
241
+ </AlertDialogCancel>
242
+ <AlertDialogAction onClick={confirmLeave} variant="destructive">
243
+ {discardPrompt?.confirmLabel ?? "Discard"}
244
+ </AlertDialogAction>
245
+ </AlertDialogFooter>
246
+ </AlertDialogContent>
247
+ </AlertDialog>
248
+ </Dialog>
249
+ )
250
+ }
251
+
252
+ export type { DialogForm, DiscardPromptText, FormDialogProps }
253
+ export { FormDialog }
@@ -0,0 +1,236 @@
1
+ import {
2
+ act,
3
+ cleanup,
4
+ fireEvent,
5
+ render,
6
+ screen,
7
+ } from "@zeno-lib/test/testing-library"
8
+ import userEvent from "@zeno-lib/test/user-event"
9
+ import { afterEach, describe, expect, test, vi } from "vitest"
10
+ import { z } from "zod"
11
+ import { Form, FormProvider, useForm } from "./create-form"
12
+ import { getNumberSeparators } from "./lib/formatted-number"
13
+
14
+ const AMOUNT = /Amount/
15
+ const RATE = /Rate/
16
+ const YEAR = /Year/
17
+ const CH = getNumberSeparators("de-CH").group
18
+
19
+ afterEach(() => {
20
+ cleanup()
21
+ })
22
+
23
+ type Captured = { current: ReturnType<typeof useAmountForm> | null }
24
+
25
+ const AMOUNT_DEFAULTS = { amount: null as number | null }
26
+
27
+ function useAmountForm(onSubmit = vi.fn()) {
28
+ return useForm({ defaultValues: AMOUNT_DEFAULTS, onSubmit })
29
+ }
30
+
31
+ function MoneyHarness({
32
+ captured,
33
+ locale = "de-CH",
34
+ currency = "CHF",
35
+ }: {
36
+ captured?: Captured
37
+ locale?: string
38
+ currency?: string
39
+ }) {
40
+ const form = useAmountForm()
41
+ if (captured) {
42
+ captured.current = form
43
+ }
44
+ const { MoneyField } = form
45
+ return (
46
+ <FormProvider form={form}>
47
+ <Form>
48
+ <MoneyField
49
+ currency={currency}
50
+ label="Amount"
51
+ locale={locale}
52
+ name="amount"
53
+ />
54
+ </Form>
55
+ </FormProvider>
56
+ )
57
+ }
58
+
59
+ describe("MoneyField", () => {
60
+ test("formats with thousands separators while typing and stores a number", async () => {
61
+ const user = userEvent.setup()
62
+ const captured: Captured = { current: null }
63
+ render(<MoneyHarness captured={captured} />)
64
+ const input = screen.getByLabelText(AMOUNT) as HTMLInputElement
65
+ expect(input.inputMode).toBe("decimal")
66
+ expect(input.type).toBe("text")
67
+
68
+ await user.type(input, "1234567.5")
69
+ expect(input.value).toBe(`1${CH}234${CH}567.5`)
70
+ expect(captured.current?.state.values.amount).toBe(1_234_567.5)
71
+
72
+ await user.tab()
73
+ expect(input.value).toBe(`1${CH}234${CH}567.50`)
74
+ })
75
+
76
+ test("shows the currency as an add-on on the locale's side", () => {
77
+ render(<MoneyHarness />)
78
+ const addon = screen.getByText("CHF")
79
+ const group = addon.closest("[data-slot=input-group-addon]")
80
+ expect(group?.getAttribute("data-align")).toBe("inline-start")
81
+ })
82
+
83
+ test("parses pasted text from another locale", () => {
84
+ const captured: Captured = { current: null }
85
+ render(<MoneyHarness captured={captured} />)
86
+ const input = screen.getByLabelText(AMOUNT) as HTMLInputElement
87
+ fireEvent.paste(input, {
88
+ clipboardData: { getData: () => "EUR 1.234,56" },
89
+ })
90
+ expect(captured.current?.state.values.amount).toBe(1234.56)
91
+ expect(input.value).toBe(`1${CH}234.56`)
92
+ })
93
+
94
+ test("clearing the input stores null", async () => {
95
+ const user = userEvent.setup()
96
+ const captured: Captured = { current: null }
97
+ render(<MoneyHarness captured={captured} />)
98
+ const input = screen.getByLabelText(AMOUNT) as HTMLInputElement
99
+ await user.type(input, "12")
100
+ await user.clear(input)
101
+ expect(captured.current?.state.values.amount).toBeNull()
102
+ })
103
+
104
+ test("re-syncs the text when the value changes from outside", async () => {
105
+ const user = userEvent.setup()
106
+ const captured: Captured = { current: null }
107
+ render(<MoneyHarness captured={captured} />)
108
+ const input = screen.getByLabelText(AMOUNT) as HTMLInputElement
109
+ await user.type(input, "5")
110
+ act(() => {
111
+ captured.current?.setFieldValue("amount", 9876)
112
+ })
113
+ console.log(
114
+ "VALS",
115
+ JSON.stringify(captured.current?.state.values),
116
+ input.value
117
+ )
118
+ expect(input.value).toBe(`9${CH}876`)
119
+ })
120
+
121
+ test("rejects letters and keeps integers bare on blur", async () => {
122
+ const user = userEvent.setup()
123
+ render(<MoneyHarness />)
124
+ const input = screen.getByLabelText(AMOUNT) as HTMLInputElement
125
+ await user.type(input, "12a3")
126
+ expect(input.value).toBe("123")
127
+ await user.tab()
128
+ expect(input.value).toBe("123")
129
+ })
130
+ })
131
+
132
+ describe("PercentageField", () => {
133
+ function PercentHarness({
134
+ captured,
135
+ scale,
136
+ }: {
137
+ captured: { current: number | null | undefined }
138
+ scale?: "fraction" | "percent"
139
+ }) {
140
+ const form = useForm({
141
+ defaultValues: { rate: 0.05 as number | null },
142
+ onSubmit: vi.fn(),
143
+ })
144
+ const { PercentageField, Subscribe } = form
145
+ return (
146
+ <FormProvider form={form}>
147
+ <Form>
148
+ <PercentageField
149
+ label="Rate"
150
+ locale="en-US"
151
+ name="rate"
152
+ scale={scale}
153
+ />
154
+ <Subscribe selector={(state) => state.values.rate}>
155
+ {(rate) => {
156
+ captured.current = rate
157
+ return null
158
+ }}
159
+ </Subscribe>
160
+ </Form>
161
+ </FormProvider>
162
+ )
163
+ }
164
+
165
+ test("edits a 0–1 fraction as a percentage with scale='fraction'", async () => {
166
+ const user = userEvent.setup()
167
+ const captured = { current: undefined as number | null | undefined }
168
+ render(<PercentHarness captured={captured} scale="fraction" />)
169
+ const input = screen.getByLabelText(RATE) as HTMLInputElement
170
+ expect(input.value).toBe("5")
171
+ expect(screen.getByText("%")).toBeTruthy()
172
+
173
+ await user.clear(input)
174
+ await user.type(input, "12.5")
175
+ expect(captured.current).toBe(0.125)
176
+ })
177
+
178
+ test("stores the typed number by default", async () => {
179
+ const user = userEvent.setup()
180
+ const captured = { current: undefined as number | null | undefined }
181
+ render(<PercentHarness captured={captured} />)
182
+ const input = screen.getByLabelText(RATE) as HTMLInputElement
183
+ await user.clear(input)
184
+ await user.type(input, "7.25")
185
+ expect(captured.current).toBe(7.25)
186
+ })
187
+ })
188
+
189
+ describe("YearField", () => {
190
+ function YearHarness({
191
+ captured,
192
+ }: {
193
+ captured: { current: number | null | undefined }
194
+ }) {
195
+ const form = useForm({
196
+ onSubmit: vi.fn(),
197
+ schema: z.object({ year: z.number().int().nullable() }),
198
+ })
199
+ const { Subscribe, YearField } = form
200
+ return (
201
+ <FormProvider form={form}>
202
+ <Form>
203
+ <YearField label="Year" max={2100} min={1900} name="year" />
204
+ <Subscribe selector={(state) => state.values.year}>
205
+ {(year) => {
206
+ captured.current = year
207
+ return null
208
+ }}
209
+ </Subscribe>
210
+ </Form>
211
+ </FormProvider>
212
+ )
213
+ }
214
+
215
+ test("caps at four digits without grouping", async () => {
216
+ const user = userEvent.setup()
217
+ const captured = { current: undefined as number | null | undefined }
218
+ render(<YearHarness captured={captured} />)
219
+ const input = screen.getByLabelText(YEAR) as HTMLInputElement
220
+ expect(input.inputMode).toBe("numeric")
221
+ await user.type(input, "20245")
222
+ expect(input.value).toBe("2024")
223
+ expect(captured.current).toBe(2024)
224
+ })
225
+
226
+ test("clamps to min/max on blur", async () => {
227
+ const user = userEvent.setup()
228
+ const captured = { current: undefined as number | null | undefined }
229
+ render(<YearHarness captured={captured} />)
230
+ const input = screen.getByLabelText(YEAR) as HTMLInputElement
231
+ await user.type(input, "1850")
232
+ await user.tab()
233
+ expect(input.value).toBe("1900")
234
+ expect(captured.current).toBe(1900)
235
+ })
236
+ })
package/src/index.ts CHANGED
@@ -2,8 +2,21 @@
2
2
 
3
3
  export { createZenoForm } from "./create-zeno-form"
4
4
  export { Form, FormProvider } from "./form-element"
5
+ export {
6
+ type ActionError,
7
+ type ActionIssue,
8
+ type ActionResult,
9
+ toActionError,
10
+ toFieldName,
11
+ } from "./lib/action-result"
5
12
  export { applyValidationError } from "./lib/apply-validation-error"
6
13
  export { useFieldContext, useFormContext } from "./lib/contexts"
14
+ export {
15
+ applyActionError,
16
+ type SubmitActionOptions,
17
+ type SubmitActionSchema,
18
+ submitAction,
19
+ } from "./lib/submit-action"
7
20
  export {
8
21
  useHideFieldErrors,
9
22
  useIsFieldRequired,
@@ -0,0 +1,61 @@
1
+ // The result shape a server action returns to a form. Declared here rather
2
+ // than imported so `@zeno-lib/forms` has no dependency on a server package:
3
+ // `defineFormAction` in `@zeno-lib/db/next` returns the same shape, and any
4
+ // action that resolves to it (hand-written or from another library) works
5
+ // with `submitAction`. The types are read-only so a producer's mutable arrays
6
+ // are assignable.
7
+
8
+ type ActionError = {
9
+ // Keyed by TanStack Form field name: `address.city`, `owners[0].percentage`.
10
+ readonly fieldErrors: Readonly<Record<string, readonly string[]>>
11
+ // Messages with no field (a whole-object refinement, a closed record).
12
+ readonly formErrors: readonly string[]
13
+ }
14
+
15
+ type ActionResult<TData> =
16
+ | { readonly ok: true; readonly data: TData }
17
+ | { readonly ok: false; readonly error: ActionError }
18
+
19
+ // A Standard Schema issue, reduced to what the path mapping reads.
20
+ type ActionIssue = {
21
+ readonly message: string
22
+ readonly path?:
23
+ | ReadonlyArray<PropertyKey | { readonly key: PropertyKey }>
24
+ | undefined
25
+ }
26
+
27
+ // `["owners", 0, "percentage"]` → `owners[0].percentage`, the name the field
28
+ // was registered under. An empty path yields `""`.
29
+ function toFieldName(path: ActionIssue["path"]): string {
30
+ let name = ""
31
+ for (const segment of path ?? []) {
32
+ const key = typeof segment === "object" ? segment.key : segment
33
+ if (typeof key === "number") {
34
+ name += `[${key}]`
35
+ } else {
36
+ const part = typeof key === "symbol" ? (key.description ?? "") : key
37
+ name += name === "" ? part : `.${part}`
38
+ }
39
+ }
40
+ return name
41
+ }
42
+
43
+ // Group issues by field name; a path-less issue goes to `formErrors`.
44
+ function toActionError(issues: readonly ActionIssue[]): ActionError {
45
+ const fieldErrors: Record<string, string[]> = {}
46
+ const formErrors: string[] = []
47
+ for (const issue of issues) {
48
+ const name = toFieldName(issue.path)
49
+ if (name === "") {
50
+ formErrors.push(issue.message)
51
+ } else {
52
+ const messages = fieldErrors[name] ?? []
53
+ messages.push(issue.message)
54
+ fieldErrors[name] = messages
55
+ }
56
+ }
57
+ return { fieldErrors, formErrors }
58
+ }
59
+
60
+ export type { ActionError, ActionIssue, ActionResult }
61
+ export { toActionError, toFieldName }
@@ -13,8 +13,15 @@ function makeFormStub() {
13
13
  (_name: string, _updater: FieldMetaUpdater) => undefined
14
14
  )
15
15
  const setErrorMap = vi.fn((_map: unknown) => undefined)
16
+ const store = { subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })) }
16
17
  return {
17
- api: { setErrorMap, setFieldMeta } as unknown as AnyFormApi,
18
+ api: {
19
+ getFieldMeta: vi.fn(() => undefined),
20
+ getFieldValue: vi.fn(() => undefined),
21
+ setErrorMap,
22
+ setFieldMeta,
23
+ store,
24
+ } as unknown as AnyFormApi,
18
25
  setErrorMap,
19
26
  setFieldMeta,
20
27
  }
@@ -40,7 +47,7 @@ describe("applyValidationError", () => {
40
47
  expect(next.isValid).toBe(false)
41
48
  })
42
49
 
43
- test("array of messages forwards all entries; errorMap.onChange gets first", () => {
50
+ test("array of messages forwards all entries, errorMap.onChange included", () => {
44
51
  const { api, setFieldMeta } = makeFormStub()
45
52
  applyValidationError(
46
53
  api,
@@ -49,10 +56,14 @@ describe("applyValidationError", () => {
49
56
  const updater = setFieldMeta.mock.calls[0]?.[1] as FieldMetaUpdater
50
57
  const next = updater({}) as {
51
58
  errors: { message: string }[]
52
- errorMap: { onChange: { message: string } }
59
+ errorMap: { onChange: { message: string }[] }
53
60
  }
54
61
  expect(next.errors).toEqual([{ message: "short" }, { message: "no digit" }])
55
- expect(next.errorMap.onChange).toEqual({ message: "short" })
62
+ // TanStack flattens an array entry into `meta.errors`, so every message shows.
63
+ expect(next.errorMap.onChange).toEqual([
64
+ { message: "short" },
65
+ { message: "no digit" },
66
+ ])
56
67
  })
57
68
 
58
69
  test("preserves existing errorMap keys via spread", () => {
@@ -2,11 +2,41 @@ import type { AnyFormApi } from "@tanstack/react-form"
2
2
 
3
3
  import type { ValidationError } from "./validation-error"
4
4
 
5
- // Write per-field messages from a `ValidationError` onto the form. We target
6
- // `errorMap.onChange` so the form-level schema overwrites the message as
7
- // soon as the user edits the field; the error clears automatically the
8
- // moment the value passes validation. Form-level error message is written
9
- // to the form's overall error map under the `onSubmit` cause.
5
+ // Clear a server message the first time the field's value moves off the value
6
+ // it was rejected with. TanStack Form does not do this on its own: a field
7
+ // with no validators of its own runs nothing on change, and the form-level
8
+ // pass keeps an error it did not write, so the message would otherwise stick,
9
+ // and keep `canSubmit` false, until the form is reset. Only that field is
10
+ // cleared, and only while the entry is still ours: a schema error written over
11
+ // it in the meantime is left alone.
12
+ function clearWhenEdited(
13
+ formApi: AnyFormApi,
14
+ name: string,
15
+ entry: unknown
16
+ ): void {
17
+ const rejected = formApi.getFieldValue(name)
18
+ const subscription = formApi.store.subscribe(() => {
19
+ if (formApi.getFieldMeta(name)?.errorMap?.onChange !== entry) {
20
+ subscription.unsubscribe()
21
+ return
22
+ }
23
+ if (Object.is(formApi.getFieldValue(name), rejected)) {
24
+ return
25
+ }
26
+ subscription.unsubscribe()
27
+ formApi.setFieldMeta(name, (prev) => ({
28
+ ...prev,
29
+ errorMap: { ...prev.errorMap, onChange: undefined },
30
+ }))
31
+ })
32
+ }
33
+
34
+ // Write per-field messages from a `ValidationError` onto the form. They go in
35
+ // `errorMap.onChange` (an array when there are several, which TanStack
36
+ // flattens into `meta.errors`) and clear as soon as the user edits that
37
+ // field; see `clearWhenEdited`. The form-level message is written to the
38
+ // form's error map under the `onSubmit` cause, which TanStack clears on the
39
+ // next change that validates cleanly.
10
40
  function applyValidationError(
11
41
  formApi: AnyFormApi,
12
42
  error: ValidationError
@@ -14,12 +44,14 @@ function applyValidationError(
14
44
  for (const [name, message] of Object.entries(error.fields)) {
15
45
  const messages = Array.isArray(message) ? message : [message]
16
46
  const errors = messages.map((m) => ({ message: m }))
47
+ const entry = errors.length === 1 ? errors[0] : errors
17
48
  formApi.setFieldMeta(name, (prev) => ({
18
49
  ...prev,
19
- errorMap: { ...prev.errorMap, onChange: errors[0] },
50
+ errorMap: { ...prev.errorMap, onChange: entry },
20
51
  errors,
21
52
  isValid: false,
22
53
  }))
54
+ clearWhenEdited(formApi, name, entry)
23
55
  }
24
56
  if (error.formError) {
25
57
  formApi.setErrorMap({