@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.
Files changed (48) hide show
  1. package/package.json +47 -0
  2. package/src/addons/index.ts +2 -0
  3. package/src/addons/validation-spinner.tsx +25 -0
  4. package/src/fields/checkbox-field.tsx +80 -0
  5. package/src/fields/combobox-field.tsx +189 -0
  6. package/src/fields/date-picker-field.tsx +141 -0
  7. package/src/fields/email-field.tsx +21 -0
  8. package/src/fields/index.ts +16 -0
  9. package/src/fields/input-field.tsx +95 -0
  10. package/src/fields/number-field.tsx +33 -0
  11. package/src/fields/otp-field.tsx +111 -0
  12. package/src/fields/password-field.tsx +20 -0
  13. package/src/fields/radio-group-field.tsx +105 -0
  14. package/src/fields/reset-button.tsx +29 -0
  15. package/src/fields/select-field.tsx +113 -0
  16. package/src/fields/slider-field.tsx +103 -0
  17. package/src/fields/submit-button.tsx +31 -0
  18. package/src/fields/switch-field.tsx +80 -0
  19. package/src/fields/textarea-field.tsx +91 -0
  20. package/src/form.tsx +105 -0
  21. package/src/lib/apply-validation-error.test.ts +97 -0
  22. package/src/lib/apply-validation-error.ts +31 -0
  23. package/src/lib/aria.ts +7 -0
  24. package/src/lib/contexts.ts +9 -0
  25. package/src/lib/required-indicator.tsx +22 -0
  26. package/src/lib/schema-defaults.test.ts +195 -0
  27. package/src/lib/schema-defaults.ts +127 -0
  28. package/src/lib/schema-required.test.ts +132 -0
  29. package/src/lib/schema-required.ts +64 -0
  30. package/src/lib/use-is-invalid.ts +109 -0
  31. package/src/lib/use-unsaved-changes-warning.ts +57 -0
  32. package/src/lib/validation-error.test.ts +41 -0
  33. package/src/lib/validation-error.ts +30 -0
  34. package/src/lib/validation-logic.test.ts +261 -0
  35. package/src/lib/validation-logic.ts +192 -0
  36. package/src/lib/validation-modes.test-d.ts +9 -0
  37. package/src/lib/validation-modes.test.ts +72 -0
  38. package/src/lib/validation-modes.ts +73 -0
  39. package/src/tanstack.ts +2 -0
  40. package/src/use-app-fields.test-d.ts +112 -0
  41. package/src/use-app-fields.test.tsx +163 -0
  42. package/src/use-app-fields.tsx +272 -0
  43. package/src/use-form.cascade.test.tsx +83 -0
  44. package/src/use-form.test-d.ts +153 -0
  45. package/src/use-form.test.tsx +348 -0
  46. package/src/use-form.tsx +450 -0
  47. package/src/use-form.unsaved-warning.test.tsx +192 -0
  48. 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
+ })