@zeno-lib/forms 0.1.0 → 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.
@@ -0,0 +1,161 @@
1
+ import {
2
+ cleanup,
3
+ render,
4
+ screen,
5
+ waitFor,
6
+ } from "@zeno-lib/test/testing-library"
7
+ import userEvent from "@zeno-lib/test/user-event"
8
+ import { afterEach, describe, expect, test, vi } from "vitest"
9
+ import { z } from "zod"
10
+ import { useForm } from "./create-form"
11
+ import { FormDialog } from "./form-dialog"
12
+ import { useFormDialog } from "./lib/use-form-dialog"
13
+
14
+ const NAME = /Name/
15
+ const EMAIL = /Email/
16
+
17
+ afterEach(() => {
18
+ cleanup()
19
+ })
20
+
21
+ const schema = z.object({
22
+ email: z.string(),
23
+ name: z.string().min(1, "Required"),
24
+ })
25
+ type Values = z.infer<typeof schema>
26
+ const BLANK: Values = { email: "", name: "" }
27
+ const ROW: Values = { email: "ada@example.com", name: "Ada" }
28
+
29
+ function Harness({
30
+ onSubmit = vi.fn(),
31
+ closeOnSubmit,
32
+ }: {
33
+ onSubmit?: (values: Values) => unknown
34
+ closeOnSubmit?: boolean
35
+ }) {
36
+ const dialog = useFormDialog({ defaultValues: BLANK })
37
+ const form = useForm({
38
+ defaultValues: dialog.defaultValues,
39
+ onSubmit: ({ value }) => onSubmit(value),
40
+ schema,
41
+ })
42
+ const { InputField } = form
43
+ return (
44
+ <>
45
+ <button onClick={() => dialog.open()} type="button">
46
+ New
47
+ </button>
48
+ <button
49
+ onClick={() => dialog.open({ defaultValues: ROW, focus: "email" })}
50
+ type="button"
51
+ >
52
+ Edit
53
+ </button>
54
+ <FormDialog
55
+ closeOnSubmit={closeOnSubmit}
56
+ dialog={dialog}
57
+ form={form}
58
+ title="Person"
59
+ >
60
+ <InputField label="Name" name="name" />
61
+ <InputField label="Email" name="email" />
62
+ </FormDialog>
63
+ </>
64
+ )
65
+ }
66
+
67
+ // Open the "Edit" session and wait for its initial focus (on Email) to land,
68
+ // so later typing isn't raced by the dialog's focus management.
69
+ async function openEdit(user: ReturnType<typeof userEvent.setup>) {
70
+ await user.click(screen.getByRole("button", { name: "Edit" }))
71
+ const email = await screen.findByLabelText(EMAIL)
72
+ await waitFor(() => expect(document.activeElement).toBe(email))
73
+ return screen.getByLabelText(NAME) as HTMLInputElement
74
+ }
75
+
76
+ describe("FormDialog", () => {
77
+ test("opens with per-session defaults and focuses the requested field", async () => {
78
+ const user = userEvent.setup()
79
+ render(<Harness />)
80
+ const name = await openEdit(user)
81
+ expect(name.value).toBe("Ada")
82
+ })
83
+
84
+ test("closes without asking when nothing changed", async () => {
85
+ const user = userEvent.setup()
86
+ render(<Harness />)
87
+ await openEdit(user)
88
+ await user.click(screen.getByRole("button", { name: "Cancel" }))
89
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
90
+ })
91
+
92
+ test("asks before discarding, keeps editing on cancel, resets on discard", async () => {
93
+ const user = userEvent.setup()
94
+ render(<Harness />)
95
+ const name = await openEdit(user)
96
+ await user.type(name, "!")
97
+ await user.keyboard("{Escape}")
98
+
99
+ await screen.findByRole("alertdialog")
100
+ await user.click(screen.getByRole("button", { name: "Keep editing" }))
101
+ await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
102
+ expect((screen.getByLabelText(NAME) as HTMLInputElement).value).toBe("Ada!")
103
+
104
+ await user.click(screen.getByRole("button", { name: "Cancel" }))
105
+ await user.click(await screen.findByRole("button", { name: "Discard" }))
106
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
107
+
108
+ // Reopening a blank session shows the blank defaults, not the old edit.
109
+ await user.click(screen.getByRole("button", { name: "New" }))
110
+ expect(
111
+ ((await screen.findByLabelText(NAME)) as HTMLInputElement).value
112
+ ).toBe("")
113
+ })
114
+
115
+ test("reverting an edit counts as unchanged (isDefaultValue, not isDirty)", async () => {
116
+ const user = userEvent.setup()
117
+ render(<Harness />)
118
+ const name = await openEdit(user)
119
+ await user.type(name, "x{Backspace}")
120
+ await user.click(screen.getByRole("button", { name: "Cancel" }))
121
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
122
+ expect(screen.queryByRole("alertdialog")).toBeNull()
123
+ })
124
+
125
+ test("submits from the footer button and closes on success", async () => {
126
+ const user = userEvent.setup()
127
+ const onSubmit = vi.fn()
128
+ render(<Harness onSubmit={onSubmit} />)
129
+ const name = await openEdit(user)
130
+ await user.type(name, "!")
131
+ const save = screen.getByRole("button", { name: "Save" })
132
+ expect(save.closest("form")).toBeNull()
133
+ await user.click(save)
134
+ await waitFor(() =>
135
+ expect(onSubmit).toHaveBeenCalledWith({
136
+ email: "ada@example.com",
137
+ name: "Ada!",
138
+ })
139
+ )
140
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
141
+ expect(screen.queryByRole("alertdialog")).toBeNull()
142
+ })
143
+
144
+ test("stays open when validation fails or the submit throws", async () => {
145
+ const user = userEvent.setup()
146
+ const onSubmit = vi.fn(() => {
147
+ throw new Error("nope")
148
+ })
149
+ render(<Harness onSubmit={onSubmit} />)
150
+ await user.click(screen.getByRole("button", { name: "New" }))
151
+ await screen.findByLabelText(NAME)
152
+ await user.click(screen.getByRole("button", { name: "Save" }))
153
+ expect(onSubmit).not.toHaveBeenCalled()
154
+ expect(await screen.findByText("Required")).toBeTruthy()
155
+
156
+ await user.type(screen.getByLabelText(NAME), "Bob")
157
+ await user.click(screen.getByRole("button", { name: "Save" }))
158
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
159
+ expect(screen.getByRole("dialog")).toBeTruthy()
160
+ })
161
+ })
@@ -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,