@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,149 @@
1
+ import { afterEach, describe, expect, test, vi } from "@zeno-lib/vitest"
2
+ import { cleanup, render, screen } from "@zeno-lib/vitest/testing-library"
3
+ import userEvent from "@zeno-lib/vitest/user-event"
4
+ import { z } from "zod"
5
+
6
+ import { Form, FormProvider } from "./form"
7
+ import type { ValidationMode } from "./lib/validation-modes"
8
+ import { useForm } from "./use-form"
9
+
10
+ afterEach(() => {
11
+ cleanup()
12
+ })
13
+
14
+ const schema = z.object({
15
+ email: z.email("Enter a valid email"),
16
+ })
17
+
18
+ function Harness({ mode }: { mode?: ValidationMode }) {
19
+ const form = useForm({
20
+ onSubmit: vi.fn(),
21
+ schema,
22
+ validators: mode,
23
+ })
24
+ const { EmailField, SubmitButton } = form
25
+ return (
26
+ <FormProvider form={form}>
27
+ <Form>
28
+ <EmailField label="Email" />
29
+ <SubmitButton>Submit</SubmitButton>
30
+ </Form>
31
+ </FormProvider>
32
+ )
33
+ }
34
+
35
+ const ERROR = /Enter a valid email/i
36
+ const EMAIL_LABEL = /Email/
37
+
38
+ function getInput() {
39
+ return screen.getByLabelText(EMAIL_LABEL) as HTMLInputElement
40
+ }
41
+ function isInvalid() {
42
+ return getInput().getAttribute("aria-invalid") === "true"
43
+ }
44
+
45
+ describe("validation mode: blur-then-change (default)", () => {
46
+ test("hidden while typing pristine", async () => {
47
+ const user = userEvent.setup()
48
+ render(<Harness />)
49
+ await user.type(getInput(), "bad")
50
+ expect(screen.queryByText(ERROR)).toBeNull()
51
+ expect(isInvalid()).toBe(false)
52
+ })
53
+
54
+ test("shown after blur with invalid value", async () => {
55
+ const user = userEvent.setup()
56
+ render(<Harness />)
57
+ await user.type(getInput(), "bad")
58
+ await user.tab()
59
+ expect(screen.queryByText(ERROR)).not.toBeNull()
60
+ expect(isInvalid()).toBe(true)
61
+ })
62
+
63
+ test("stickiness fix: blur invalid, fix value, error clears live", async () => {
64
+ const user = userEvent.setup()
65
+ render(<Harness />)
66
+ await user.type(getInput(), "bad")
67
+ await user.tab()
68
+ expect(screen.queryByText(ERROR)).not.toBeNull()
69
+ // Focus back, fix the value — error should clear without re-blur.
70
+ await user.click(getInput())
71
+ await user.clear(getInput())
72
+ await user.type(getInput(), "ok@example.com")
73
+ expect(screen.queryByText(ERROR)).toBeNull()
74
+ expect(isInvalid()).toBe(false)
75
+ })
76
+ })
77
+
78
+ describe("validation mode: change", () => {
79
+ test("shown live as soon as the user types invalid", async () => {
80
+ const user = userEvent.setup()
81
+ render(<Harness mode="change" />)
82
+ await user.type(getInput(), "bad")
83
+ expect(screen.queryByText(ERROR)).not.toBeNull()
84
+ expect(isInvalid()).toBe(true)
85
+ })
86
+
87
+ test("clears live when value becomes valid", async () => {
88
+ const user = userEvent.setup()
89
+ render(<Harness mode="change" />)
90
+ await user.type(getInput(), "bad")
91
+ expect(screen.queryByText(ERROR)).not.toBeNull()
92
+ await user.clear(getInput())
93
+ await user.type(getInput(), "ok@example.com")
94
+ expect(screen.queryByText(ERROR)).toBeNull()
95
+ })
96
+ })
97
+
98
+ describe("validation mode: blur", () => {
99
+ test("hidden while typing pristine; shown only after blur", async () => {
100
+ const user = userEvent.setup()
101
+ render(<Harness mode="blur" />)
102
+ await user.type(getInput(), "bad")
103
+ expect(screen.queryByText(ERROR)).toBeNull()
104
+ await user.tab()
105
+ expect(screen.queryByText(ERROR)).not.toBeNull()
106
+ })
107
+ })
108
+
109
+ describe("validation mode: submit", () => {
110
+ test("hidden until submit attempt", async () => {
111
+ const user = userEvent.setup()
112
+ render(<Harness mode="submit" />)
113
+ await user.type(getInput(), "bad")
114
+ await user.tab()
115
+ expect(screen.queryByText(ERROR)).toBeNull()
116
+ await user.click(screen.getByRole("button", { name: "Submit" }))
117
+ expect(screen.queryByText(ERROR)).not.toBeNull()
118
+ })
119
+ })
120
+
121
+ describe("per-field validators bypass form-level gating", () => {
122
+ test("per-field onChange flips aria-invalid live, even with a blur-then-change form schema", async () => {
123
+ const user = userEvent.setup()
124
+ const validator = vi.fn(({ value }: { value: string }) =>
125
+ value === "nope" ? "Field-level live error" : undefined
126
+ )
127
+ function H() {
128
+ const form = useForm({
129
+ onSubmit: vi.fn(),
130
+ schema,
131
+ })
132
+ const { EmailField } = form
133
+ return (
134
+ <FormProvider form={form}>
135
+ <Form>
136
+ <EmailField label="Email" validators={{ onChange: validator }} />
137
+ </Form>
138
+ </FormProvider>
139
+ )
140
+ }
141
+ render(<H />)
142
+ await user.type(getInput(), "nope")
143
+ expect(validator).toHaveBeenCalled()
144
+ // No blur yet — the form-level schema is gated, but the per-field
145
+ // onChange is honoured directly by `useIsInvalid` and flips the field
146
+ // invalid live (no submit attempt, no blur).
147
+ expect(isInvalid()).toBe(true)
148
+ })
149
+ })