@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.
- package/package.json +3 -1
- package/src/create-form.tsx +10 -0
- package/src/fields/checkbox-group-field.tsx +160 -0
- package/src/fields/index.ts +5 -0
- package/src/fields/money-field.tsx +123 -0
- package/src/fields/multi-select-field.tsx +193 -0
- package/src/fields/percentage-field.tsx +73 -0
- package/src/fields/year-field.tsx +56 -0
- package/src/form-dialog.test.tsx +161 -0
- package/src/form-dialog.tsx +253 -0
- package/src/formatted-number-fields.test.tsx +236 -0
- package/src/index.ts +13 -0
- package/src/lib/action-result.ts +61 -0
- package/src/lib/apply-validation-error.test.ts +15 -4
- package/src/lib/apply-validation-error.ts +38 -6
- package/src/lib/formatted-number.test.ts +157 -0
- package/src/lib/formatted-number.ts +307 -0
- package/src/lib/submit-action.test-d.ts +83 -0
- package/src/lib/submit-action.ts +178 -0
- package/src/lib/use-form-dialog.ts +184 -0
- package/src/lib/use-formatted-number.ts +235 -0
- package/src/multi-value-fields.test.tsx +116 -0
- package/src/submit-action.test.tsx +421 -0
|
@@ -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,
|