@zeno-lib/forms 0.0.1 → 0.2.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 (63) hide show
  1. package/package.json +23 -11
  2. package/src/addons/validation-spinner.tsx +3 -4
  3. package/src/create-form.tsx +59 -0
  4. package/src/create-zeno-form.tsx +531 -0
  5. package/src/fields/checkbox-field.tsx +15 -12
  6. package/src/fields/checkbox-group-field.tsx +160 -0
  7. package/src/fields/combobox-field.tsx +13 -14
  8. package/src/fields/date-picker-field.tsx +18 -15
  9. package/src/fields/index.ts +5 -0
  10. package/src/fields/input-field.tsx +12 -13
  11. package/src/fields/money-field.tsx +123 -0
  12. package/src/fields/multi-select-field.tsx +193 -0
  13. package/src/fields/number-field.tsx +1 -2
  14. package/src/fields/otp-field.tsx +15 -12
  15. package/src/fields/percentage-field.tsx +73 -0
  16. package/src/fields/radio-group-field.tsx +12 -13
  17. package/src/fields/reset-button.tsx +2 -3
  18. package/src/fields/select-field.tsx +12 -13
  19. package/src/fields/slider-field.tsx +11 -12
  20. package/src/fields/submit-button.tsx +3 -4
  21. package/src/fields/switch-field.tsx +15 -12
  22. package/src/fields/textarea-field.tsx +12 -13
  23. package/src/fields/year-field.tsx +56 -0
  24. package/src/form-dialog.test.tsx +161 -0
  25. package/src/form-dialog.tsx +253 -0
  26. package/src/form-element.tsx +51 -0
  27. package/src/formatted-number-fields.test.tsx +236 -0
  28. package/src/index.ts +27 -0
  29. package/src/lib/action-result.ts +61 -0
  30. package/src/lib/apply-validation-error.test.ts +16 -6
  31. package/src/lib/apply-validation-error.ts +38 -6
  32. package/src/lib/formatted-number.test.ts +157 -0
  33. package/src/lib/formatted-number.ts +307 -0
  34. package/src/lib/required-indicator.tsx +1 -1
  35. package/src/lib/schema-defaults.test.ts +1 -2
  36. package/src/lib/schema-required.test.ts +98 -6
  37. package/src/lib/schema-required.ts +126 -22
  38. package/src/lib/submit-action.test-d.ts +83 -0
  39. package/src/lib/submit-action.ts +178 -0
  40. package/src/lib/use-form-dialog.ts +184 -0
  41. package/src/lib/use-formatted-number.ts +235 -0
  42. package/src/lib/use-is-invalid.ts +4 -4
  43. package/src/lib/use-rebased-default-values.ts +42 -0
  44. package/src/lib/use-unsaved-changes-warning.ts +3 -3
  45. package/src/lib/validation-error.test.ts +1 -1
  46. package/src/lib/validation-logic.test.ts +1 -1
  47. package/src/lib/validation-modes.test-d.ts +1 -1
  48. package/src/lib/validation-modes.test.ts +12 -2
  49. package/src/lib/validation-modes.ts +3 -1
  50. package/src/multi-value-fields.test.tsx +116 -0
  51. package/src/submit-action.test.tsx +421 -0
  52. package/src/use-app-fields.test-d.ts +8 -25
  53. package/src/use-app-fields.test.tsx +8 -32
  54. package/src/use-form.cascade.test.tsx +4 -6
  55. package/src/use-form.focus-invalid.test.tsx +198 -0
  56. package/src/use-form.reset-defaults.test.tsx +125 -0
  57. package/src/use-form.test-d.ts +2 -3
  58. package/src/use-form.test.tsx +12 -14
  59. package/src/use-form.unsaved-warning.test.tsx +4 -13
  60. package/src/use-form.validation-modes.test.tsx +10 -8
  61. package/src/form.tsx +0 -105
  62. package/src/use-app-fields.tsx +0 -272
  63. package/src/use-form.tsx +0 -450
@@ -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,51 @@
1
+ "use client"
2
+
3
+ import type { AnyFormApi } from "@tanstack/react-form"
4
+ import type { ComponentProps, FormEvent, ReactNode } from "react"
5
+
6
+ import { FormProvider as RawFormProvider, useFormContext } from "./lib/contexts"
7
+
8
+ type FormProviderProps = {
9
+ children: ReactNode
10
+ form: { handleSubmit: () => unknown }
11
+ }
12
+
13
+ function FormProvider({ children, form }: FormProviderProps) {
14
+ return (
15
+ <RawFormProvider value={form as AnyFormApi}>{children}</RawFormProvider>
16
+ )
17
+ }
18
+
19
+ type FormProps = Omit<ComponentProps<"form">, "onSubmit">
20
+
21
+ function Form({ children, className, ...props }: FormProps) {
22
+ const form = useFormContext()
23
+ return (
24
+ <form
25
+ className={className}
26
+ noValidate
27
+ onSubmit={async (event: FormEvent<HTMLFormElement>) => {
28
+ event.preventDefault()
29
+ event.stopPropagation()
30
+ const node = event.currentTarget
31
+ await Promise.resolve(form.handleSubmit()).catch(() => undefined)
32
+ if (!form.state.isValid) {
33
+ // First invalid control in *this* form; a group root (radio group,
34
+ // slider) hands focus to its first tabbable child.
35
+ const invalid = node.querySelector<HTMLElement>(
36
+ '[aria-invalid="true"]'
37
+ )
38
+ const targets = invalid
39
+ ? [invalid, ...invalid.querySelectorAll<HTMLElement>("*")]
40
+ : []
41
+ targets.find((el) => el.tabIndex >= 0)?.focus()
42
+ }
43
+ }}
44
+ {...props}
45
+ >
46
+ {children}
47
+ </form>
48
+ )
49
+ }
50
+
51
+ export { Form, FormProvider }
@@ -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 ADDED
@@ -0,0 +1,27 @@
1
+ /* biome-ignore-all lint/performance/noBarrelFile: single npm entry for the headless form API. */
2
+
3
+ export { createZenoForm } from "./create-zeno-form"
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"
12
+ export { applyValidationError } from "./lib/apply-validation-error"
13
+ export { useFieldContext, useFormContext } from "./lib/contexts"
14
+ export {
15
+ applyActionError,
16
+ type SubmitActionOptions,
17
+ type SubmitActionSchema,
18
+ submitAction,
19
+ } from "./lib/submit-action"
20
+ export {
21
+ useHideFieldErrors,
22
+ useIsFieldRequired,
23
+ useIsInvalid,
24
+ } from "./lib/use-is-invalid"
25
+ export { type FieldMessage, ValidationError } from "./lib/validation-error"
26
+ export { blurThenChangeLogic } from "./lib/validation-logic"
27
+ export type { ValidationMode } from "./lib/validation-modes"
@@ -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 }
@@ -1,6 +1,5 @@
1
1
  import type { AnyFormApi } from "@tanstack/react-form"
2
- import { describe, expect, test, vi } from "@zeno-lib/vitest"
3
-
2
+ import { describe, expect, test, vi } from "vitest"
4
3
  import { applyValidationError } from "./apply-validation-error"
5
4
  import { ValidationError } from "./validation-error"
6
5
 
@@ -14,8 +13,15 @@ function makeFormStub() {
14
13
  (_name: string, _updater: FieldMetaUpdater) => undefined
15
14
  )
16
15
  const setErrorMap = vi.fn((_map: unknown) => undefined)
16
+ const store = { subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })) }
17
17
  return {
18
- 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,
19
25
  setErrorMap,
20
26
  setFieldMeta,
21
27
  }
@@ -41,7 +47,7 @@ describe("applyValidationError", () => {
41
47
  expect(next.isValid).toBe(false)
42
48
  })
43
49
 
44
- test("array of messages forwards all entries; errorMap.onChange gets first", () => {
50
+ test("array of messages forwards all entries, errorMap.onChange included", () => {
45
51
  const { api, setFieldMeta } = makeFormStub()
46
52
  applyValidationError(
47
53
  api,
@@ -50,10 +56,14 @@ describe("applyValidationError", () => {
50
56
  const updater = setFieldMeta.mock.calls[0]?.[1] as FieldMetaUpdater
51
57
  const next = updater({}) as {
52
58
  errors: { message: string }[]
53
- errorMap: { onChange: { message: string } }
59
+ errorMap: { onChange: { message: string }[] }
54
60
  }
55
61
  expect(next.errors).toEqual([{ message: "short" }, { message: "no digit" }])
56
- 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
+ ])
57
67
  })
58
68
 
59
69
  test("preserves existing errorMap keys via spread", () => {