@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,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
|
+
})
|