@zeno-lib/forms 0.0.0 → 0.1.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 +21 -11
- package/src/addons/validation-spinner.tsx +3 -4
- package/src/create-form.tsx +49 -0
- package/src/create-zeno-form.tsx +531 -0
- package/src/fields/checkbox-field.tsx +15 -12
- package/src/fields/combobox-field.tsx +13 -14
- package/src/fields/date-picker-field.tsx +18 -15
- package/src/fields/input-field.tsx +12 -13
- package/src/fields/number-field.tsx +1 -2
- package/src/fields/otp-field.tsx +15 -12
- 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/form-element.tsx +51 -0
- package/src/index.ts +14 -0
- package/src/lib/apply-validation-error.test.ts +1 -2
- 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/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/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,198 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cleanup,
|
|
3
|
+
render,
|
|
4
|
+
screen,
|
|
5
|
+
waitFor,
|
|
6
|
+
} from "@zeno-lib/test/testing-library"
|
|
7
|
+
import userEvent from "@zeno-lib/test/user-event"
|
|
8
|
+
import type { ReactNode } from "react"
|
|
9
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
10
|
+
import { z } from "zod"
|
|
11
|
+
import { Form, FormProvider, RadioGroupFieldItem, useForm } from "./create-form"
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
cleanup()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const schema = z.object({
|
|
18
|
+
email: z.email("Enter a valid email"),
|
|
19
|
+
name: z.string().min(1, "Required"),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// Forms in these tests reuse field names (and so ids), so look controls up
|
|
23
|
+
// inside their own form rather than through label association.
|
|
24
|
+
function control(form: string, name: string) {
|
|
25
|
+
return screen
|
|
26
|
+
.getByRole("form", { name: form })
|
|
27
|
+
.querySelector<HTMLElement>(`[name="${name}"]`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ProfileForm({
|
|
31
|
+
id,
|
|
32
|
+
label,
|
|
33
|
+
onSubmitInvalid,
|
|
34
|
+
outsideSubmit,
|
|
35
|
+
}: {
|
|
36
|
+
id?: string
|
|
37
|
+
label: string
|
|
38
|
+
onSubmitInvalid?: () => void
|
|
39
|
+
outsideSubmit?: boolean
|
|
40
|
+
}) {
|
|
41
|
+
const form = useForm({
|
|
42
|
+
...(onSubmitInvalid ? { onSubmitInvalid } : {}),
|
|
43
|
+
defaultValues: { email: "valid@example.com" },
|
|
44
|
+
onSubmit: vi.fn(),
|
|
45
|
+
schema,
|
|
46
|
+
})
|
|
47
|
+
const { EmailField, InputField, SubmitButton } = form
|
|
48
|
+
const submit: ReactNode = (
|
|
49
|
+
<SubmitButton form={id}>{`${label} submit`}</SubmitButton>
|
|
50
|
+
)
|
|
51
|
+
return (
|
|
52
|
+
<FormProvider form={form}>
|
|
53
|
+
<Form aria-label={label} id={id}>
|
|
54
|
+
<EmailField name="email" />
|
|
55
|
+
<InputField label="Name" name="name" />
|
|
56
|
+
{outsideSubmit ? null : submit}
|
|
57
|
+
</Form>
|
|
58
|
+
{outsideSubmit ? submit : null}
|
|
59
|
+
</FormProvider>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("<Form> — focus the first invalid field on a failed submit", () => {
|
|
64
|
+
test("focuses the first invalid field; onSubmitInvalid passes through", async () => {
|
|
65
|
+
const user = userEvent.setup()
|
|
66
|
+
const onSubmitInvalid = vi.fn()
|
|
67
|
+
render(<ProfileForm label="Profile" onSubmitInvalid={onSubmitInvalid} />)
|
|
68
|
+
await user.click(screen.getByRole("button", { name: "Profile submit" }))
|
|
69
|
+
await waitFor(() =>
|
|
70
|
+
expect(document.activeElement).toBe(control("Profile", "name"))
|
|
71
|
+
)
|
|
72
|
+
expect(onSubmitInvalid).toHaveBeenCalledTimes(1)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("only the submitted form's field gets focus", async () => {
|
|
76
|
+
const user = userEvent.setup()
|
|
77
|
+
render(
|
|
78
|
+
<>
|
|
79
|
+
<ProfileForm label="Page" />
|
|
80
|
+
<div role="dialog">
|
|
81
|
+
<ProfileForm label="Dialog" />
|
|
82
|
+
</div>
|
|
83
|
+
</>
|
|
84
|
+
)
|
|
85
|
+
await user.click(screen.getByRole("button", { name: "Page submit" }))
|
|
86
|
+
await waitFor(() =>
|
|
87
|
+
expect(document.activeElement).toBe(control("Page", "name"))
|
|
88
|
+
)
|
|
89
|
+
// The page form is still invalid, but the dialog's submit must stay in
|
|
90
|
+
// the dialog.
|
|
91
|
+
await user.click(screen.getByRole("button", { name: "Dialog submit" }))
|
|
92
|
+
await waitFor(() =>
|
|
93
|
+
expect(document.activeElement).toBe(control("Dialog", "name"))
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("works for an external <button form={id}> submit", async () => {
|
|
98
|
+
const user = userEvent.setup()
|
|
99
|
+
render(<ProfileForm id="profile-form" label="Profile" outsideSubmit />)
|
|
100
|
+
await user.click(screen.getByRole("button", { name: "Profile submit" }))
|
|
101
|
+
await waitFor(() =>
|
|
102
|
+
expect(document.activeElement).toBe(control("Profile", "name"))
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test("a radio group or slider root hands focus to its tabbable child", async () => {
|
|
107
|
+
const user = userEvent.setup()
|
|
108
|
+
function Harness({ sliderFirst }: { sliderFirst: boolean }) {
|
|
109
|
+
const form = useForm({
|
|
110
|
+
defaultValues: { volume: 10 },
|
|
111
|
+
onSubmit: vi.fn(),
|
|
112
|
+
schema: z.object({
|
|
113
|
+
plan: z.enum(["basic", "pro"]),
|
|
114
|
+
volume: z.number().min(50),
|
|
115
|
+
}),
|
|
116
|
+
})
|
|
117
|
+
const { RadioGroupField, SliderField, SubmitButton } = form
|
|
118
|
+
const radio = (
|
|
119
|
+
<RadioGroupField key="plan" label="Plan" name="plan">
|
|
120
|
+
<RadioGroupFieldItem value="basic">Basic</RadioGroupFieldItem>
|
|
121
|
+
<RadioGroupFieldItem value="pro">Pro</RadioGroupFieldItem>
|
|
122
|
+
</RadioGroupField>
|
|
123
|
+
)
|
|
124
|
+
const slider = <SliderField key="volume" label="Volume" name="volume" />
|
|
125
|
+
return (
|
|
126
|
+
<FormProvider form={form}>
|
|
127
|
+
<Form aria-label="Settings">
|
|
128
|
+
{sliderFirst ? [slider, radio] : [radio, slider]}
|
|
129
|
+
<SubmitButton>Save</SubmitButton>
|
|
130
|
+
</Form>
|
|
131
|
+
</FormProvider>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { unmount } = render(<Harness sliderFirst={false} />)
|
|
136
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
137
|
+
await waitFor(() =>
|
|
138
|
+
expect(document.activeElement?.getAttribute("role")).toBe("radio")
|
|
139
|
+
)
|
|
140
|
+
unmount()
|
|
141
|
+
|
|
142
|
+
render(<Harness sliderFirst />)
|
|
143
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
144
|
+
await waitFor(() =>
|
|
145
|
+
expect(document.activeElement?.closest('[data-field="volume"]')).not.toBe(
|
|
146
|
+
null
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe("fields — data-field and array rows", () => {
|
|
153
|
+
test("every Field root carries data-field={name}; array rows get the required indicator", () => {
|
|
154
|
+
const rowsSchema = z.object({
|
|
155
|
+
email: z.email(),
|
|
156
|
+
members: z.array(
|
|
157
|
+
z.object({
|
|
158
|
+
name: z.string().min(1),
|
|
159
|
+
note: z.string().optional(),
|
|
160
|
+
})
|
|
161
|
+
),
|
|
162
|
+
})
|
|
163
|
+
function Harness() {
|
|
164
|
+
const form = useForm({
|
|
165
|
+
defaultValues: { members: [{ name: "" }, { name: "" }] },
|
|
166
|
+
onSubmit: vi.fn(),
|
|
167
|
+
schema: rowsSchema,
|
|
168
|
+
})
|
|
169
|
+
const { EmailField, InputField } = form
|
|
170
|
+
return (
|
|
171
|
+
<FormProvider form={form}>
|
|
172
|
+
<Form>
|
|
173
|
+
<EmailField label="Email" name="email" />
|
|
174
|
+
<InputField label="Row 1 name" name="members[0].name" />
|
|
175
|
+
<InputField label="Row 2 name" name="members[1].name" />
|
|
176
|
+
<InputField label="Row 2 note" name="members[1].note" />
|
|
177
|
+
</Form>
|
|
178
|
+
</FormProvider>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
const { container } = render(<Harness />)
|
|
182
|
+
for (const name of [
|
|
183
|
+
"email",
|
|
184
|
+
"members[0].name",
|
|
185
|
+
"members[1].name",
|
|
186
|
+
"members[1].note",
|
|
187
|
+
]) {
|
|
188
|
+
const root = container.querySelector(`[data-field="${name}"]`)
|
|
189
|
+
expect(root).not.toBeNull()
|
|
190
|
+
expect(root?.getAttribute("data-slot")).toBe("field")
|
|
191
|
+
}
|
|
192
|
+
const indicator = (label: string) =>
|
|
193
|
+
screen.getByText(label).querySelector('[data-slot="required-indicator"]')
|
|
194
|
+
expect(indicator("Row 1 name")).not.toBeNull()
|
|
195
|
+
expect(indicator("Row 2 name")).not.toBeNull()
|
|
196
|
+
expect(indicator("Row 2 note")).toBeNull()
|
|
197
|
+
})
|
|
198
|
+
})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
act,
|
|
3
|
+
cleanup,
|
|
4
|
+
render,
|
|
5
|
+
screen,
|
|
6
|
+
waitFor,
|
|
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
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
cleanup()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const TITLE = /Title/
|
|
18
|
+
const SAVE = /Save/
|
|
19
|
+
|
|
20
|
+
const schema = z.object({ title: z.string().min(1) })
|
|
21
|
+
|
|
22
|
+
type Captured = {
|
|
23
|
+
isDefaultValue: boolean
|
|
24
|
+
isDirty: boolean
|
|
25
|
+
isTouched: boolean
|
|
26
|
+
values: { title: string }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function EditTitle({
|
|
30
|
+
capture,
|
|
31
|
+
record,
|
|
32
|
+
tick,
|
|
33
|
+
}: {
|
|
34
|
+
capture: (state: Captured) => void
|
|
35
|
+
record: { title: string }
|
|
36
|
+
tick?: number
|
|
37
|
+
}) {
|
|
38
|
+
const form = useForm({
|
|
39
|
+
// A fresh literal every render, like most callers write it.
|
|
40
|
+
defaultValues: { title: record.title },
|
|
41
|
+
onSubmit: ({ formApi, value }) => {
|
|
42
|
+
formApi.reset(value)
|
|
43
|
+
},
|
|
44
|
+
schema,
|
|
45
|
+
})
|
|
46
|
+
const { InputField, SubmitButton } = form
|
|
47
|
+
capture({
|
|
48
|
+
isDefaultValue: form.state.isDefaultValue,
|
|
49
|
+
isDirty: form.state.isDirty,
|
|
50
|
+
isTouched: form.state.isTouched,
|
|
51
|
+
values: form.state.values,
|
|
52
|
+
})
|
|
53
|
+
return (
|
|
54
|
+
<FormProvider form={form}>
|
|
55
|
+
<Form data-tick={tick}>
|
|
56
|
+
<InputField label="Title" name="title" />
|
|
57
|
+
<SubmitButton>Save</SubmitButton>
|
|
58
|
+
</Form>
|
|
59
|
+
</FormProvider>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function titleInput() {
|
|
64
|
+
return screen.getByLabelText(TITLE) as HTMLInputElement
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("useForm — reset(values) rebases defaults", () => {
|
|
68
|
+
test("reset after submit sticks across rerenders with fixed defaults", async () => {
|
|
69
|
+
const user = userEvent.setup()
|
|
70
|
+
let state: Captured | undefined
|
|
71
|
+
const capture = vi.fn((next: Captured) => {
|
|
72
|
+
state = next
|
|
73
|
+
})
|
|
74
|
+
const record = { title: "Draft" }
|
|
75
|
+
const { rerender } = render(<EditTitle capture={capture} record={record} />)
|
|
76
|
+
|
|
77
|
+
await user.clear(titleInput())
|
|
78
|
+
await user.type(titleInput(), "Saved title")
|
|
79
|
+
await user.click(screen.getByRole("button", { name: SAVE }))
|
|
80
|
+
|
|
81
|
+
await waitFor(() => expect(state?.isTouched).toBe(false))
|
|
82
|
+
for (const tick of [1, 2, 3]) {
|
|
83
|
+
rerender(<EditTitle capture={capture} record={record} tick={tick} />)
|
|
84
|
+
}
|
|
85
|
+
await act(() => Promise.resolve())
|
|
86
|
+
|
|
87
|
+
expect(titleInput().value).toBe("Saved title")
|
|
88
|
+
expect(state?.values).toEqual({ title: "Saved title" })
|
|
89
|
+
expect(state?.isDefaultValue).toBe(true)
|
|
90
|
+
expect(state?.isDirty).toBe(false)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test("new caller defaults apply while the form is untouched", async () => {
|
|
94
|
+
let state: Captured | undefined
|
|
95
|
+
const capture = (next: Captured) => {
|
|
96
|
+
state = next
|
|
97
|
+
}
|
|
98
|
+
const { rerender } = render(
|
|
99
|
+
<EditTitle capture={capture} record={{ title: "v1" }} />
|
|
100
|
+
)
|
|
101
|
+
expect(titleInput().value).toBe("v1")
|
|
102
|
+
|
|
103
|
+
rerender(<EditTitle capture={capture} record={{ title: "v2" }} />)
|
|
104
|
+
await waitFor(() => expect(titleInput().value).toBe("v2"))
|
|
105
|
+
expect(state?.isDefaultValue).toBe(true)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("new caller defaults are not applied once the form is touched", async () => {
|
|
109
|
+
const user = userEvent.setup()
|
|
110
|
+
let state: Captured | undefined
|
|
111
|
+
const capture = (next: Captured) => {
|
|
112
|
+
state = next
|
|
113
|
+
}
|
|
114
|
+
const { rerender } = render(
|
|
115
|
+
<EditTitle capture={capture} record={{ title: "v1" }} />
|
|
116
|
+
)
|
|
117
|
+
await user.type(titleInput(), " edited")
|
|
118
|
+
|
|
119
|
+
rerender(<EditTitle capture={capture} record={{ title: "v2" }} />)
|
|
120
|
+
await act(() => Promise.resolve())
|
|
121
|
+
|
|
122
|
+
expect(titleInput().value).toBe("v1 edited")
|
|
123
|
+
expect(state?.values).toEqual({ title: "v1 edited" })
|
|
124
|
+
})
|
|
125
|
+
})
|
package/src/use-form.test-d.ts
CHANGED
package/src/use-form.test.tsx
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import userEvent from "@zeno-lib/vitest/user-event"
|
|
1
|
+
import { act, cleanup, render, screen } from "@zeno-lib/test/testing-library"
|
|
2
|
+
import userEvent from "@zeno-lib/test/user-event"
|
|
4
3
|
import { useRef } from "react"
|
|
4
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
5
5
|
import { z } from "zod"
|
|
6
|
-
|
|
7
|
-
import { Form, FormProvider } from "./form"
|
|
6
|
+
import { Form, FormProvider, useForm } from "./create-form"
|
|
8
7
|
import { ValidationError } from "./lib/validation-error"
|
|
9
|
-
import { useForm } from "./use-form"
|
|
10
8
|
|
|
11
9
|
const EMAIL_LABEL = /Email/
|
|
12
10
|
const VALID_EMAIL_MSG = /Enter a valid email/i
|
|
@@ -76,7 +74,7 @@ describe("useForm — required indicator", () => {
|
|
|
76
74
|
return (
|
|
77
75
|
<FormProvider form={form}>
|
|
78
76
|
<Form>
|
|
79
|
-
<EmailField label="Email" />
|
|
77
|
+
<EmailField label="Email" name="email" />
|
|
80
78
|
<InputField label="Role" name="role" />
|
|
81
79
|
</Form>
|
|
82
80
|
</FormProvider>
|
|
@@ -105,7 +103,7 @@ describe("useForm — required indicator", () => {
|
|
|
105
103
|
return (
|
|
106
104
|
<FormProvider form={form}>
|
|
107
105
|
<Form>
|
|
108
|
-
<EmailField label="Email" />
|
|
106
|
+
<EmailField label="Email" name="email" />
|
|
109
107
|
</Form>
|
|
110
108
|
</FormProvider>
|
|
111
109
|
)
|
|
@@ -134,7 +132,7 @@ describe("useForm — hideFieldErrors", () => {
|
|
|
134
132
|
return (
|
|
135
133
|
<FormProvider form={form}>
|
|
136
134
|
<Form>
|
|
137
|
-
<EmailField label="Email" />
|
|
135
|
+
<EmailField label="Email" name="email" />
|
|
138
136
|
<SubmitButton>Submit</SubmitButton>
|
|
139
137
|
</Form>
|
|
140
138
|
</FormProvider>
|
|
@@ -164,7 +162,7 @@ describe("useForm — ValidationError from onSubmit", () => {
|
|
|
164
162
|
return (
|
|
165
163
|
<FormProvider form={form}>
|
|
166
164
|
<Form>
|
|
167
|
-
<EmailField label="Email" />
|
|
165
|
+
<EmailField label="Email" name="email" />
|
|
168
166
|
<SubmitButton>Submit</SubmitButton>
|
|
169
167
|
</Form>
|
|
170
168
|
</FormProvider>
|
|
@@ -195,7 +193,7 @@ describe("useForm — ValidationError from onSubmit", () => {
|
|
|
195
193
|
return (
|
|
196
194
|
<FormProvider form={form}>
|
|
197
195
|
<Form>
|
|
198
|
-
<EmailField label="Email" />
|
|
196
|
+
<EmailField label="Email" name="email" />
|
|
199
197
|
<SubmitButton>Submit</SubmitButton>
|
|
200
198
|
</Form>
|
|
201
199
|
</FormProvider>
|
|
@@ -224,7 +222,7 @@ describe("useForm — ValidationError from onSubmit", () => {
|
|
|
224
222
|
return (
|
|
225
223
|
<FormProvider form={form}>
|
|
226
224
|
<Form>
|
|
227
|
-
<EmailField label="Email" />
|
|
225
|
+
<EmailField label="Email" name="email" />
|
|
228
226
|
<SubmitButton>Submit</SubmitButton>
|
|
229
227
|
</Form>
|
|
230
228
|
</FormProvider>
|
|
@@ -247,7 +245,7 @@ describe("useForm — ValidationError from onSubmit", () => {
|
|
|
247
245
|
return (
|
|
248
246
|
<FormProvider form={form}>
|
|
249
247
|
<Form>
|
|
250
|
-
<EmailField label="Email" />
|
|
248
|
+
<EmailField label="Email" name="email" />
|
|
251
249
|
<SubmitButton>Submit</SubmitButton>
|
|
252
250
|
</Form>
|
|
253
251
|
</FormProvider>
|
|
@@ -275,7 +273,7 @@ describe("useForm — SubmitButton state", () => {
|
|
|
275
273
|
return (
|
|
276
274
|
<FormProvider form={form}>
|
|
277
275
|
<Form>
|
|
278
|
-
<EmailField label="Email" />
|
|
276
|
+
<EmailField label="Email" name="email" />
|
|
279
277
|
<SubmitButton>Submit</SubmitButton>
|
|
280
278
|
</Form>
|
|
281
279
|
</FormProvider>
|
|
@@ -1,17 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
describe,
|
|
5
|
-
expect,
|
|
6
|
-
test,
|
|
7
|
-
vi,
|
|
8
|
-
} from "@zeno-lib/vitest"
|
|
9
|
-
import { act, cleanup, render, screen } from "@zeno-lib/vitest/testing-library"
|
|
10
|
-
import userEvent from "@zeno-lib/vitest/user-event"
|
|
1
|
+
import { act, cleanup, render, screen } from "@zeno-lib/test/testing-library"
|
|
2
|
+
import userEvent from "@zeno-lib/test/user-event"
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
|
|
11
4
|
import { z } from "zod"
|
|
12
|
-
|
|
13
|
-
import { Form, FormProvider } from "./form"
|
|
14
|
-
import { useForm } from "./use-form"
|
|
5
|
+
import { Form, FormProvider, useForm } from "./create-form"
|
|
15
6
|
|
|
16
7
|
const NAME_LABEL = /Name/
|
|
17
8
|
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
1
|
+
import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
|
|
2
|
+
import userEvent from "@zeno-lib/test/user-event"
|
|
3
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
4
4
|
import { z } from "zod"
|
|
5
|
-
|
|
6
|
-
import { Form, FormProvider } from "./form"
|
|
5
|
+
import { Form, FormProvider, useForm } from "./create-form"
|
|
7
6
|
import type { ValidationMode } from "./lib/validation-modes"
|
|
8
|
-
import { useForm } from "./use-form"
|
|
9
7
|
|
|
10
8
|
afterEach(() => {
|
|
11
9
|
cleanup()
|
|
@@ -25,7 +23,7 @@ function Harness({ mode }: { mode?: ValidationMode }) {
|
|
|
25
23
|
return (
|
|
26
24
|
<FormProvider form={form}>
|
|
27
25
|
<Form>
|
|
28
|
-
<EmailField label="Email" />
|
|
26
|
+
<EmailField label="Email" name="email" />
|
|
29
27
|
<SubmitButton>Submit</SubmitButton>
|
|
30
28
|
</Form>
|
|
31
29
|
</FormProvider>
|
|
@@ -133,7 +131,11 @@ describe("per-field validators bypass form-level gating", () => {
|
|
|
133
131
|
return (
|
|
134
132
|
<FormProvider form={form}>
|
|
135
133
|
<Form>
|
|
136
|
-
<EmailField
|
|
134
|
+
<EmailField
|
|
135
|
+
label="Email"
|
|
136
|
+
name="email"
|
|
137
|
+
validators={{ onChange: validator }}
|
|
138
|
+
/>
|
|
137
139
|
</Form>
|
|
138
140
|
</FormProvider>
|
|
139
141
|
)
|
package/src/form.tsx
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
/* biome-ignore-all lint/performance/noBarrelFile: so consumers depend only on `@zeno-lib/forms`. */
|
|
2
|
-
"use client"
|
|
3
|
-
|
|
4
|
-
import { type AnyFormApi, createFormHook } from "@tanstack/react-form"
|
|
5
|
-
import type { ComponentProps, FormEvent, ReactNode } from "react"
|
|
6
|
-
|
|
7
|
-
import { CheckboxField } from "./fields/checkbox-field"
|
|
8
|
-
import { ComboboxField } from "./fields/combobox-field"
|
|
9
|
-
import { DatePickerField } from "./fields/date-picker-field"
|
|
10
|
-
import { EmailField } from "./fields/email-field"
|
|
11
|
-
import { InputField } from "./fields/input-field"
|
|
12
|
-
import { NumberField } from "./fields/number-field"
|
|
13
|
-
import { OtpField } from "./fields/otp-field"
|
|
14
|
-
import { PasswordField } from "./fields/password-field"
|
|
15
|
-
import { RadioGroupField } from "./fields/radio-group-field"
|
|
16
|
-
import { ResetButton } from "./fields/reset-button"
|
|
17
|
-
import { SelectField } from "./fields/select-field"
|
|
18
|
-
import { SliderField } from "./fields/slider-field"
|
|
19
|
-
import { SubmitButton } from "./fields/submit-button"
|
|
20
|
-
import { SwitchField } from "./fields/switch-field"
|
|
21
|
-
import { TextAreaField } from "./fields/textarea-field"
|
|
22
|
-
import {
|
|
23
|
-
fieldContext,
|
|
24
|
-
formContext,
|
|
25
|
-
FormProvider as RawFormProvider,
|
|
26
|
-
useFormContext,
|
|
27
|
-
} from "./lib/contexts"
|
|
28
|
-
|
|
29
|
-
export * from "./addons"
|
|
30
|
-
export { RadioGroupFieldItem } from "./fields/radio-group-field"
|
|
31
|
-
export { applyValidationError } from "./lib/apply-validation-error"
|
|
32
|
-
export { useFieldContext, useFormContext } from "./lib/contexts"
|
|
33
|
-
export {
|
|
34
|
-
RequiredIndicator,
|
|
35
|
-
type RequiredIndicatorProps,
|
|
36
|
-
} from "./lib/required-indicator"
|
|
37
|
-
export {
|
|
38
|
-
useHideFieldErrors,
|
|
39
|
-
useIsFieldRequired,
|
|
40
|
-
useIsInvalid,
|
|
41
|
-
} from "./lib/use-is-invalid"
|
|
42
|
-
export { type FieldMessage, ValidationError } from "./lib/validation-error"
|
|
43
|
-
export { blurThenChangeLogic } from "./lib/validation-logic"
|
|
44
|
-
export type { ValidationMode } from "./lib/validation-modes"
|
|
45
|
-
export { useAppFields } from "./use-app-fields"
|
|
46
|
-
export { useForm } from "./use-form"
|
|
47
|
-
|
|
48
|
-
const { useAppForm, withFieldGroup, withForm } = createFormHook({
|
|
49
|
-
fieldComponents: {
|
|
50
|
-
CheckboxField,
|
|
51
|
-
ComboboxField,
|
|
52
|
-
DatePickerField,
|
|
53
|
-
EmailField,
|
|
54
|
-
InputField,
|
|
55
|
-
NumberField,
|
|
56
|
-
OtpField,
|
|
57
|
-
PasswordField,
|
|
58
|
-
RadioGroupField,
|
|
59
|
-
SelectField,
|
|
60
|
-
SliderField,
|
|
61
|
-
SwitchField,
|
|
62
|
-
TextAreaField,
|
|
63
|
-
},
|
|
64
|
-
fieldContext,
|
|
65
|
-
formComponents: {
|
|
66
|
-
ResetButton,
|
|
67
|
-
SubmitButton,
|
|
68
|
-
},
|
|
69
|
-
formContext,
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
export { useAppForm, withFieldGroup, withForm }
|
|
73
|
-
|
|
74
|
-
type FormProviderProps = {
|
|
75
|
-
children: ReactNode
|
|
76
|
-
form: { handleSubmit: () => unknown }
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function FormProvider({ children, form }: FormProviderProps) {
|
|
80
|
-
return (
|
|
81
|
-
<RawFormProvider value={form as AnyFormApi}>{children}</RawFormProvider>
|
|
82
|
-
)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
type FormProps = Omit<ComponentProps<"form">, "onSubmit">
|
|
86
|
-
|
|
87
|
-
function Form({ children, className, ...props }: FormProps) {
|
|
88
|
-
const form = useFormContext()
|
|
89
|
-
return (
|
|
90
|
-
<form
|
|
91
|
-
className={className}
|
|
92
|
-
noValidate
|
|
93
|
-
onSubmit={(event: FormEvent<HTMLFormElement>) => {
|
|
94
|
-
event.preventDefault()
|
|
95
|
-
event.stopPropagation()
|
|
96
|
-
Promise.resolve(form.handleSubmit()).catch(() => undefined)
|
|
97
|
-
}}
|
|
98
|
-
{...props}
|
|
99
|
-
>
|
|
100
|
-
{children}
|
|
101
|
-
</form>
|
|
102
|
-
)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export { Form, FormProvider }
|