@zeno-lib/forms 0.0.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 +47 -0
- package/src/addons/index.ts +2 -0
- package/src/addons/validation-spinner.tsx +25 -0
- package/src/fields/checkbox-field.tsx +80 -0
- package/src/fields/combobox-field.tsx +189 -0
- package/src/fields/date-picker-field.tsx +141 -0
- package/src/fields/email-field.tsx +21 -0
- package/src/fields/index.ts +16 -0
- package/src/fields/input-field.tsx +95 -0
- package/src/fields/number-field.tsx +33 -0
- package/src/fields/otp-field.tsx +111 -0
- package/src/fields/password-field.tsx +20 -0
- package/src/fields/radio-group-field.tsx +105 -0
- package/src/fields/reset-button.tsx +29 -0
- package/src/fields/select-field.tsx +113 -0
- package/src/fields/slider-field.tsx +103 -0
- package/src/fields/submit-button.tsx +31 -0
- package/src/fields/switch-field.tsx +80 -0
- package/src/fields/textarea-field.tsx +91 -0
- package/src/form.tsx +105 -0
- package/src/lib/apply-validation-error.test.ts +97 -0
- package/src/lib/apply-validation-error.ts +31 -0
- package/src/lib/aria.ts +7 -0
- package/src/lib/contexts.ts +9 -0
- package/src/lib/required-indicator.tsx +22 -0
- package/src/lib/schema-defaults.test.ts +195 -0
- package/src/lib/schema-defaults.ts +127 -0
- package/src/lib/schema-required.test.ts +132 -0
- package/src/lib/schema-required.ts +64 -0
- package/src/lib/use-is-invalid.ts +109 -0
- package/src/lib/use-unsaved-changes-warning.ts +57 -0
- package/src/lib/validation-error.test.ts +41 -0
- package/src/lib/validation-error.ts +30 -0
- package/src/lib/validation-logic.test.ts +261 -0
- package/src/lib/validation-logic.ts +192 -0
- package/src/lib/validation-modes.test-d.ts +9 -0
- package/src/lib/validation-modes.test.ts +72 -0
- package/src/lib/validation-modes.ts +73 -0
- package/src/tanstack.ts +2 -0
- package/src/use-app-fields.test-d.ts +112 -0
- package/src/use-app-fields.test.tsx +163 -0
- package/src/use-app-fields.tsx +272 -0
- package/src/use-form.cascade.test.tsx +83 -0
- package/src/use-form.test-d.ts +153 -0
- package/src/use-form.test.tsx +348 -0
- package/src/use-form.tsx +450 -0
- package/src/use-form.unsaved-warning.test.tsx +192 -0
- package/src/use-form.validation-modes.test.tsx +149 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from "@zeno-lib/vitest"
|
|
2
|
+
import { act, cleanup, render, screen } from "@zeno-lib/vitest/testing-library"
|
|
3
|
+
import userEvent from "@zeno-lib/vitest/user-event"
|
|
4
|
+
import { useRef } from "react"
|
|
5
|
+
import { z } from "zod"
|
|
6
|
+
|
|
7
|
+
import { Form, FormProvider } from "./form"
|
|
8
|
+
import { ValidationError } from "./lib/validation-error"
|
|
9
|
+
import { useForm } from "./use-form"
|
|
10
|
+
|
|
11
|
+
const EMAIL_LABEL = /Email/
|
|
12
|
+
const VALID_EMAIL_MSG = /Enter a valid email/i
|
|
13
|
+
const SUBMIT_NAME = /Submit/
|
|
14
|
+
|
|
15
|
+
// @testing-library auto-cleanup only runs with `globals: true`. Our vitest
|
|
16
|
+
// config keeps globals off (we import test helpers explicitly), so add the
|
|
17
|
+
// hook ourselves to keep tests isolated.
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
cleanup()
|
|
20
|
+
vi.useRealTimers()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe("useForm — schema-derived defaults", () => {
|
|
24
|
+
test("strings/arrays fill in from schema, .default flows through, optional booleans only set when user supplies", () => {
|
|
25
|
+
const schema = z.object({
|
|
26
|
+
email: z.email(),
|
|
27
|
+
newsletter: z.boolean().optional(),
|
|
28
|
+
role: z.string().default("member"),
|
|
29
|
+
tags: z.array(z.string()),
|
|
30
|
+
})
|
|
31
|
+
let capturedValues: unknown
|
|
32
|
+
function Harness() {
|
|
33
|
+
const form = useForm({
|
|
34
|
+
defaultValues: { newsletter: false },
|
|
35
|
+
onSubmit: vi.fn(),
|
|
36
|
+
schema,
|
|
37
|
+
})
|
|
38
|
+
capturedValues = form.state.values
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
render(<Harness />)
|
|
42
|
+
expect(capturedValues).toEqual({
|
|
43
|
+
email: "",
|
|
44
|
+
newsletter: false,
|
|
45
|
+
role: "member",
|
|
46
|
+
tags: [],
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("user defaultValues win on conflict", () => {
|
|
51
|
+
const schema = z.object({ role: z.string().default("member") })
|
|
52
|
+
let capturedValues: unknown
|
|
53
|
+
function Harness() {
|
|
54
|
+
const form = useForm({
|
|
55
|
+
defaultValues: { role: "admin" },
|
|
56
|
+
onSubmit: vi.fn(),
|
|
57
|
+
schema,
|
|
58
|
+
})
|
|
59
|
+
capturedValues = form.state.values
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
render(<Harness />)
|
|
63
|
+
expect(capturedValues).toEqual({ role: "admin" })
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe("useForm — required indicator", () => {
|
|
68
|
+
test("`*` rendered next to required field labels (schema-derived)", () => {
|
|
69
|
+
const schema = z.object({
|
|
70
|
+
email: z.email(),
|
|
71
|
+
role: z.string().default("member"),
|
|
72
|
+
})
|
|
73
|
+
function Harness() {
|
|
74
|
+
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
75
|
+
const { EmailField, InputField } = form
|
|
76
|
+
return (
|
|
77
|
+
<FormProvider form={form}>
|
|
78
|
+
<Form>
|
|
79
|
+
<EmailField label="Email" />
|
|
80
|
+
<InputField label="Role" name="role" />
|
|
81
|
+
</Form>
|
|
82
|
+
</FormProvider>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
render(<Harness />)
|
|
86
|
+
const emailLabel = screen.getByText("Email")
|
|
87
|
+
const roleLabel = screen.getByText("Role")
|
|
88
|
+
expect(
|
|
89
|
+
emailLabel.querySelector('[data-slot="required-indicator"]')
|
|
90
|
+
).not.toBeNull()
|
|
91
|
+
expect(
|
|
92
|
+
roleLabel.querySelector('[data-slot="required-indicator"]')
|
|
93
|
+
).toBeNull()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test("requiredIndicator: false disables the `*` entirely", () => {
|
|
97
|
+
const schema = z.object({ email: z.email() })
|
|
98
|
+
function Harness() {
|
|
99
|
+
const form = useForm({
|
|
100
|
+
onSubmit: vi.fn(),
|
|
101
|
+
requiredIndicator: false,
|
|
102
|
+
schema,
|
|
103
|
+
})
|
|
104
|
+
const { EmailField } = form
|
|
105
|
+
return (
|
|
106
|
+
<FormProvider form={form}>
|
|
107
|
+
<Form>
|
|
108
|
+
<EmailField label="Email" />
|
|
109
|
+
</Form>
|
|
110
|
+
</FormProvider>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
render(<Harness />)
|
|
114
|
+
expect(
|
|
115
|
+
screen
|
|
116
|
+
.getByText("Email")
|
|
117
|
+
.querySelector('[data-slot="required-indicator"]')
|
|
118
|
+
).toBeNull()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe("useForm — hideFieldErrors", () => {
|
|
123
|
+
test("aria-invalid still flips after blur, but inline FieldError is suppressed", async () => {
|
|
124
|
+
const user = userEvent.setup()
|
|
125
|
+
const schema = z.object({ email: z.email("Enter a valid email") })
|
|
126
|
+
function Harness() {
|
|
127
|
+
const form = useForm({
|
|
128
|
+
hideFieldErrors: true,
|
|
129
|
+
onSubmit: vi.fn(),
|
|
130
|
+
schema,
|
|
131
|
+
validators: "blur",
|
|
132
|
+
})
|
|
133
|
+
const { EmailField, SubmitButton } = form
|
|
134
|
+
return (
|
|
135
|
+
<FormProvider form={form}>
|
|
136
|
+
<Form>
|
|
137
|
+
<EmailField label="Email" />
|
|
138
|
+
<SubmitButton>Submit</SubmitButton>
|
|
139
|
+
</Form>
|
|
140
|
+
</FormProvider>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
render(<Harness />)
|
|
144
|
+
const input = screen.getByLabelText(EMAIL_LABEL) as HTMLInputElement
|
|
145
|
+
await user.type(input, "not-an-email")
|
|
146
|
+
await user.tab() // blur → triggers validation
|
|
147
|
+
expect(input.getAttribute("aria-invalid")).toBe("true")
|
|
148
|
+
expect(screen.queryByText(VALID_EMAIL_MSG)).toBeNull()
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe("useForm — ValidationError from onSubmit", () => {
|
|
153
|
+
test("per-field messages land on the matching field after submit", async () => {
|
|
154
|
+
const user = userEvent.setup()
|
|
155
|
+
const schema = z.object({ email: z.email() })
|
|
156
|
+
function Harness() {
|
|
157
|
+
const form = useForm({
|
|
158
|
+
onSubmit: () => {
|
|
159
|
+
throw new ValidationError({ email: "Taken on the server" })
|
|
160
|
+
},
|
|
161
|
+
schema,
|
|
162
|
+
})
|
|
163
|
+
const { EmailField, SubmitButton } = form
|
|
164
|
+
return (
|
|
165
|
+
<FormProvider form={form}>
|
|
166
|
+
<Form>
|
|
167
|
+
<EmailField label="Email" />
|
|
168
|
+
<SubmitButton>Submit</SubmitButton>
|
|
169
|
+
</Form>
|
|
170
|
+
</FormProvider>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
render(<Harness />)
|
|
174
|
+
await user.type(screen.getByLabelText(EMAIL_LABEL), "user@example.com")
|
|
175
|
+
await user.click(screen.getByRole("button", { name: "Submit" }))
|
|
176
|
+
expect(await screen.findByText("Taken on the server")).not.toBeNull()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test("formError populates the form-level error map", async () => {
|
|
180
|
+
const user = userEvent.setup()
|
|
181
|
+
const schema = z.object({ email: z.email() })
|
|
182
|
+
const errorMapRef: { current: unknown } = { current: undefined }
|
|
183
|
+
function Harness() {
|
|
184
|
+
const form = useForm({
|
|
185
|
+
onSubmit: () => {
|
|
186
|
+
throw new ValidationError(
|
|
187
|
+
{ email: "Taken" },
|
|
188
|
+
{ formError: "Service unavailable" }
|
|
189
|
+
)
|
|
190
|
+
},
|
|
191
|
+
schema,
|
|
192
|
+
})
|
|
193
|
+
errorMapRef.current = form.state.errorMap
|
|
194
|
+
const { EmailField, SubmitButton } = form
|
|
195
|
+
return (
|
|
196
|
+
<FormProvider form={form}>
|
|
197
|
+
<Form>
|
|
198
|
+
<EmailField label="Email" />
|
|
199
|
+
<SubmitButton>Submit</SubmitButton>
|
|
200
|
+
</Form>
|
|
201
|
+
</FormProvider>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
render(<Harness />)
|
|
205
|
+
await user.type(screen.getByLabelText(EMAIL_LABEL), "user@example.com")
|
|
206
|
+
await user.click(screen.getByRole("button", { name: "Submit" }))
|
|
207
|
+
// TanStack Form normalises the `setErrorMap({ onSubmit: { form, fields } })`
|
|
208
|
+
// payload by surfacing the `form` string directly on `errorMap.onSubmit`.
|
|
209
|
+
expect(errorMapRef.current).toMatchObject({
|
|
210
|
+
onSubmit: "Service unavailable",
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test("non-ValidationError thrown from onSubmit propagates (is not swallowed)", async () => {
|
|
215
|
+
const user = userEvent.setup()
|
|
216
|
+
const schema = z.object({ email: z.email() })
|
|
217
|
+
const boom = new Error("not a validation problem")
|
|
218
|
+
const onSubmit = vi.fn(() => {
|
|
219
|
+
throw boom
|
|
220
|
+
})
|
|
221
|
+
function Harness() {
|
|
222
|
+
const form = useForm({ onSubmit, schema })
|
|
223
|
+
const { EmailField, SubmitButton } = form
|
|
224
|
+
return (
|
|
225
|
+
<FormProvider form={form}>
|
|
226
|
+
<Form>
|
|
227
|
+
<EmailField label="Email" />
|
|
228
|
+
<SubmitButton>Submit</SubmitButton>
|
|
229
|
+
</Form>
|
|
230
|
+
</FormProvider>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
render(<Harness />)
|
|
234
|
+
await user.type(screen.getByLabelText(EMAIL_LABEL), "user@example.com")
|
|
235
|
+
await user.click(screen.getByRole("button", { name: "Submit" }))
|
|
236
|
+
expect(onSubmit).toHaveBeenCalled()
|
|
237
|
+
// Not a ValidationError → no field-level message is applied.
|
|
238
|
+
expect(screen.queryByText("not a validation problem")).toBeNull()
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test("submit without an onSubmit prop does not crash (wrappedOnSubmit branch)", async () => {
|
|
242
|
+
const user = userEvent.setup()
|
|
243
|
+
const schema = z.object({ email: z.email() })
|
|
244
|
+
function Harness() {
|
|
245
|
+
const form = useForm({ schema })
|
|
246
|
+
const { EmailField, SubmitButton } = form
|
|
247
|
+
return (
|
|
248
|
+
<FormProvider form={form}>
|
|
249
|
+
<Form>
|
|
250
|
+
<EmailField label="Email" />
|
|
251
|
+
<SubmitButton>Submit</SubmitButton>
|
|
252
|
+
</Form>
|
|
253
|
+
</FormProvider>
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
render(<Harness />)
|
|
257
|
+
await user.type(screen.getByLabelText(EMAIL_LABEL), "user@example.com")
|
|
258
|
+
await expect(
|
|
259
|
+
user.click(screen.getByRole("button", { name: "Submit" }))
|
|
260
|
+
).resolves.not.toThrow()
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
describe("useForm — SubmitButton state", () => {
|
|
265
|
+
test("disabled and shows spinner while isSubmitting", async () => {
|
|
266
|
+
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
267
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
|
268
|
+
const schema = z.object({ email: z.email() })
|
|
269
|
+
function Harness() {
|
|
270
|
+
const form = useForm({
|
|
271
|
+
onSubmit: () => new Promise((resolve) => setTimeout(resolve, 50)),
|
|
272
|
+
schema,
|
|
273
|
+
})
|
|
274
|
+
const { EmailField, SubmitButton } = form
|
|
275
|
+
return (
|
|
276
|
+
<FormProvider form={form}>
|
|
277
|
+
<Form>
|
|
278
|
+
<EmailField label="Email" />
|
|
279
|
+
<SubmitButton>Submit</SubmitButton>
|
|
280
|
+
</Form>
|
|
281
|
+
</FormProvider>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
render(<Harness />)
|
|
285
|
+
await user.type(screen.getByLabelText(EMAIL_LABEL), "user@example.com")
|
|
286
|
+
const button = screen.getByRole("button", {
|
|
287
|
+
name: SUBMIT_NAME,
|
|
288
|
+
}) as HTMLButtonElement
|
|
289
|
+
await user.click(button)
|
|
290
|
+
expect(button.disabled).toBe(true)
|
|
291
|
+
// Spinner renders Lucide's Loader2 with role="status" + aria-label="Loading".
|
|
292
|
+
expect(button.querySelector('[role="status"]')).not.toBeNull()
|
|
293
|
+
await act(async () => {
|
|
294
|
+
await vi.advanceTimersByTimeAsync(60)
|
|
295
|
+
})
|
|
296
|
+
expect(button.disabled).toBe(false)
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
describe("useForm — useAppFields memoization", () => {
|
|
301
|
+
test("field component identity is stable across re-renders with the same form", () => {
|
|
302
|
+
const schema = z.object({ email: z.email() })
|
|
303
|
+
const seen: { same: boolean; tick: number }[] = []
|
|
304
|
+
function Harness({ tick }: { tick: number }) {
|
|
305
|
+
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
306
|
+
const lastEmail = useRef<unknown>(undefined)
|
|
307
|
+
if (lastEmail.current === undefined) {
|
|
308
|
+
lastEmail.current = form.EmailField
|
|
309
|
+
}
|
|
310
|
+
seen.push({ same: lastEmail.current === form.EmailField, tick })
|
|
311
|
+
return null
|
|
312
|
+
}
|
|
313
|
+
const { rerender } = render(<Harness tick={1} />)
|
|
314
|
+
rerender(<Harness tick={2} />)
|
|
315
|
+
rerender(<Harness tick={3} />)
|
|
316
|
+
expect(seen.every((s) => s.same)).toBe(true)
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
describe("useForm — manual path", () => {
|
|
321
|
+
test("native validators object is forwarded; runs on user input", async () => {
|
|
322
|
+
const user = userEvent.setup()
|
|
323
|
+
type Values = { email: string }
|
|
324
|
+
const onChange = vi.fn((_: { value: Values }) => undefined)
|
|
325
|
+
function Harness() {
|
|
326
|
+
const form = useForm<Values>({
|
|
327
|
+
defaultValues: { email: "" },
|
|
328
|
+
onSubmit: vi.fn(),
|
|
329
|
+
// Manual path: validators is the native object — typed by TanStack
|
|
330
|
+
// Form. The `vi.fn(...)` mock signature matches the validator shape.
|
|
331
|
+
validators: { onChange: onChange as never },
|
|
332
|
+
})
|
|
333
|
+
const { EmailField, SubmitButton } = form
|
|
334
|
+
return (
|
|
335
|
+
<FormProvider form={form}>
|
|
336
|
+
<Form>
|
|
337
|
+
<EmailField label="Email" name="email" />
|
|
338
|
+
<SubmitButton>Submit</SubmitButton>
|
|
339
|
+
</Form>
|
|
340
|
+
</FormProvider>
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
render(<Harness />)
|
|
344
|
+
const input = screen.getByLabelText(EMAIL_LABEL) as HTMLInputElement
|
|
345
|
+
await user.type(input, "bad")
|
|
346
|
+
expect(onChange).toHaveBeenCalled()
|
|
347
|
+
})
|
|
348
|
+
})
|