@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.
- package/package.json +23 -11
- package/src/addons/validation-spinner.tsx +3 -4
- package/src/create-form.tsx +59 -0
- package/src/create-zeno-form.tsx +531 -0
- package/src/fields/checkbox-field.tsx +15 -12
- package/src/fields/checkbox-group-field.tsx +160 -0
- package/src/fields/combobox-field.tsx +13 -14
- package/src/fields/date-picker-field.tsx +18 -15
- package/src/fields/index.ts +5 -0
- package/src/fields/input-field.tsx +12 -13
- package/src/fields/money-field.tsx +123 -0
- package/src/fields/multi-select-field.tsx +193 -0
- package/src/fields/number-field.tsx +1 -2
- package/src/fields/otp-field.tsx +15 -12
- package/src/fields/percentage-field.tsx +73 -0
- package/src/fields/radio-group-field.tsx +12 -13
- package/src/fields/reset-button.tsx +2 -3
- package/src/fields/select-field.tsx +12 -13
- package/src/fields/slider-field.tsx +11 -12
- package/src/fields/submit-button.tsx +3 -4
- package/src/fields/switch-field.tsx +15 -12
- package/src/fields/textarea-field.tsx +12 -13
- 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/form-element.tsx +51 -0
- package/src/formatted-number-fields.test.tsx +236 -0
- package/src/index.ts +27 -0
- package/src/lib/action-result.ts +61 -0
- package/src/lib/apply-validation-error.test.ts +16 -6
- 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/required-indicator.tsx +1 -1
- package/src/lib/schema-defaults.test.ts +1 -2
- package/src/lib/schema-required.test.ts +98 -6
- package/src/lib/schema-required.ts +126 -22
- 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/lib/use-is-invalid.ts +4 -4
- package/src/lib/use-rebased-default-values.ts +42 -0
- package/src/lib/use-unsaved-changes-warning.ts +3 -3
- package/src/lib/validation-error.test.ts +1 -1
- package/src/lib/validation-logic.test.ts +1 -1
- package/src/lib/validation-modes.test-d.ts +1 -1
- package/src/lib/validation-modes.test.ts +12 -2
- package/src/lib/validation-modes.ts +3 -1
- package/src/multi-value-fields.test.tsx +116 -0
- package/src/submit-action.test.tsx +421 -0
- package/src/use-app-fields.test-d.ts +8 -25
- package/src/use-app-fields.test.tsx +8 -32
- package/src/use-form.cascade.test.tsx +4 -6
- package/src/use-form.focus-invalid.test.tsx +198 -0
- package/src/use-form.reset-defaults.test.tsx +125 -0
- package/src/use-form.test-d.ts +2 -3
- package/src/use-form.test.tsx +12 -14
- package/src/use-form.unsaved-warning.test.tsx +4 -13
- package/src/use-form.validation-modes.test.tsx +10 -8
- package/src/form.tsx +0 -105
- package/src/use-app-fields.tsx +0 -272
- 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 "
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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", () => {
|