@zeno-lib/forms 0.0.1 → 0.2.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 (63) hide show
  1. package/package.json +23 -11
  2. package/src/addons/validation-spinner.tsx +3 -4
  3. package/src/create-form.tsx +59 -0
  4. package/src/create-zeno-form.tsx +531 -0
  5. package/src/fields/checkbox-field.tsx +15 -12
  6. package/src/fields/checkbox-group-field.tsx +160 -0
  7. package/src/fields/combobox-field.tsx +13 -14
  8. package/src/fields/date-picker-field.tsx +18 -15
  9. package/src/fields/index.ts +5 -0
  10. package/src/fields/input-field.tsx +12 -13
  11. package/src/fields/money-field.tsx +123 -0
  12. package/src/fields/multi-select-field.tsx +193 -0
  13. package/src/fields/number-field.tsx +1 -2
  14. package/src/fields/otp-field.tsx +15 -12
  15. package/src/fields/percentage-field.tsx +73 -0
  16. package/src/fields/radio-group-field.tsx +12 -13
  17. package/src/fields/reset-button.tsx +2 -3
  18. package/src/fields/select-field.tsx +12 -13
  19. package/src/fields/slider-field.tsx +11 -12
  20. package/src/fields/submit-button.tsx +3 -4
  21. package/src/fields/switch-field.tsx +15 -12
  22. package/src/fields/textarea-field.tsx +12 -13
  23. package/src/fields/year-field.tsx +56 -0
  24. package/src/form-dialog.test.tsx +161 -0
  25. package/src/form-dialog.tsx +253 -0
  26. package/src/form-element.tsx +51 -0
  27. package/src/formatted-number-fields.test.tsx +236 -0
  28. package/src/index.ts +27 -0
  29. package/src/lib/action-result.ts +61 -0
  30. package/src/lib/apply-validation-error.test.ts +16 -6
  31. package/src/lib/apply-validation-error.ts +38 -6
  32. package/src/lib/formatted-number.test.ts +157 -0
  33. package/src/lib/formatted-number.ts +307 -0
  34. package/src/lib/required-indicator.tsx +1 -1
  35. package/src/lib/schema-defaults.test.ts +1 -2
  36. package/src/lib/schema-required.test.ts +98 -6
  37. package/src/lib/schema-required.ts +126 -22
  38. package/src/lib/submit-action.test-d.ts +83 -0
  39. package/src/lib/submit-action.ts +178 -0
  40. package/src/lib/use-form-dialog.ts +184 -0
  41. package/src/lib/use-formatted-number.ts +235 -0
  42. package/src/lib/use-is-invalid.ts +4 -4
  43. package/src/lib/use-rebased-default-values.ts +42 -0
  44. package/src/lib/use-unsaved-changes-warning.ts +3 -3
  45. package/src/lib/validation-error.test.ts +1 -1
  46. package/src/lib/validation-logic.test.ts +1 -1
  47. package/src/lib/validation-modes.test-d.ts +1 -1
  48. package/src/lib/validation-modes.test.ts +12 -2
  49. package/src/lib/validation-modes.ts +3 -1
  50. package/src/multi-value-fields.test.tsx +116 -0
  51. package/src/submit-action.test.tsx +421 -0
  52. package/src/use-app-fields.test-d.ts +8 -25
  53. package/src/use-app-fields.test.tsx +8 -32
  54. package/src/use-form.cascade.test.tsx +4 -6
  55. package/src/use-form.focus-invalid.test.tsx +198 -0
  56. package/src/use-form.reset-defaults.test.tsx +125 -0
  57. package/src/use-form.test-d.ts +2 -3
  58. package/src/use-form.test.tsx +12 -14
  59. package/src/use-form.unsaved-warning.test.tsx +4 -13
  60. package/src/use-form.validation-modes.test.tsx +10 -8
  61. package/src/form.tsx +0 -105
  62. package/src/use-app-fields.tsx +0 -272
  63. package/src/use-form.tsx +0 -450
@@ -0,0 +1,421 @@
1
+ import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
2
+ import userEvent from "@zeno-lib/test/user-event"
3
+ import { useState } from "react"
4
+ import { afterEach, describe, expect, test, vi } from "vitest"
5
+ import { z } from "zod"
6
+ import { Form, FormProvider, useForm } from "./create-form"
7
+ import type { ActionResult } from "./lib/action-result"
8
+ import { submitAction } from "./lib/submit-action"
9
+ import { ValidationError } from "./lib/validation-error"
10
+ import type { ValidationMode } from "./lib/validation-modes"
11
+
12
+ afterEach(() => {
13
+ cleanup()
14
+ })
15
+
16
+ const SUBMIT = { name: "Submit" }
17
+ const EMAIL_LABEL = /Email/
18
+ const NAME_LABEL = /Name/
19
+ const OWNER_1_SHARE_LABEL = /^Owner 1 share/
20
+ const OWNER_2_SHARE_LABEL = /^Owner 2 share/
21
+
22
+ function failure(
23
+ fieldErrors: Record<string, string[]>,
24
+ formErrors: string[] = []
25
+ ): ActionResult<never> {
26
+ return { error: { fieldErrors, formErrors }, ok: false }
27
+ }
28
+
29
+ const profileSchema = z.object({
30
+ email: z.email("Enter a valid email"),
31
+ name: z.string().min(1, "Required"),
32
+ })
33
+ type Profile = z.infer<typeof profileSchema>
34
+
35
+ function ProfileForm({
36
+ action,
37
+ formRef,
38
+ validators,
39
+ }: {
40
+ action: (input: Profile) => Promise<ActionResult<unknown>>
41
+ formRef?: { current: unknown }
42
+ validators?: ValidationMode
43
+ }) {
44
+ const form = useForm({
45
+ defaultValues: { email: "ada@example.com", name: "Ada" },
46
+ onSubmit: (submit) => submitAction(submit, action),
47
+ schema: profileSchema,
48
+ ...(validators ? { validators } : {}),
49
+ })
50
+ if (formRef) {
51
+ formRef.current = form
52
+ }
53
+ const { EmailField, InputField, SubmitButton } = form
54
+ return (
55
+ <FormProvider form={form}>
56
+ <Form>
57
+ <InputField label="Name" name="name" />
58
+ <EmailField label="Email" name="email" />
59
+ <SubmitButton>Submit</SubmitButton>
60
+ </Form>
61
+ </FormProvider>
62
+ )
63
+ }
64
+
65
+ describe("submitAction — field errors from the action", () => {
66
+ const modes: ValidationMode[] = [
67
+ "blur-then-change",
68
+ "change",
69
+ "blur",
70
+ "submit",
71
+ ]
72
+
73
+ for (const mode of modes) {
74
+ test(`${mode}: an error lands on its field and clears when that field is edited, not another`, async () => {
75
+ const user = userEvent.setup()
76
+ const action = vi.fn(() =>
77
+ Promise.resolve(
78
+ failure({ email: ["Already taken"], name: ["Name is reserved"] })
79
+ )
80
+ )
81
+ render(<ProfileForm action={action} validators={mode} />)
82
+
83
+ await user.click(screen.getByRole("button", SUBMIT))
84
+
85
+ expect(await screen.findByText("Already taken")).not.toBeNull()
86
+ expect(screen.getByText("Name is reserved")).not.toBeNull()
87
+
88
+ // Editing the email clears only the email's server message.
89
+ await user.type(screen.getByLabelText(EMAIL_LABEL), "m")
90
+ expect(screen.queryByText("Already taken")).toBeNull()
91
+ expect(screen.getByText("Name is reserved")).not.toBeNull()
92
+
93
+ await user.type(screen.getByLabelText(NAME_LABEL), "x")
94
+ expect(screen.queryByText("Name is reserved")).toBeNull()
95
+ })
96
+ }
97
+
98
+ test("a field with a server error blocks resubmission until it is edited", async () => {
99
+ const user = userEvent.setup()
100
+ const action = vi
101
+ .fn<(input: Profile) => Promise<ActionResult<unknown>>>()
102
+ .mockResolvedValueOnce(failure({ email: ["Already taken"] }))
103
+ .mockResolvedValue({ data: null, ok: true })
104
+ render(<ProfileForm action={action} />)
105
+
106
+ await user.click(screen.getByRole("button", SUBMIT))
107
+ await screen.findByText("Already taken")
108
+ await user.click(screen.getByRole("button", SUBMIT))
109
+ expect(action).toHaveBeenCalledTimes(1)
110
+
111
+ await user.type(screen.getByLabelText(EMAIL_LABEL), "m")
112
+ await user.click(screen.getByRole("button", SUBMIT))
113
+ expect(action).toHaveBeenCalledTimes(2)
114
+ })
115
+
116
+ test("array-index paths reach the field registered under that name", async () => {
117
+ const user = userEvent.setup()
118
+ const schema = z.object({
119
+ owners: z.array(z.object({ name: z.string(), percentage: z.string() })),
120
+ })
121
+ function Harness() {
122
+ const form = useForm({
123
+ defaultValues: {
124
+ owners: [
125
+ { name: "Ada", percentage: "60" },
126
+ { name: "Grace", percentage: "40" },
127
+ ],
128
+ },
129
+ onSubmit: (submit) =>
130
+ submitAction(submit, () =>
131
+ Promise.resolve(
132
+ failure({
133
+ "owners[1].percentage": ["Exceeds the remaining share"],
134
+ })
135
+ )
136
+ ),
137
+ schema,
138
+ })
139
+ const { InputField, SubmitButton } = form
140
+ return (
141
+ <FormProvider form={form}>
142
+ <Form>
143
+ <InputField label="Owner 1 share" name="owners[0].percentage" />
144
+ <InputField label="Owner 2 share" name="owners[1].percentage" />
145
+ <SubmitButton>Submit</SubmitButton>
146
+ </Form>
147
+ </FormProvider>
148
+ )
149
+ }
150
+ render(<Harness />)
151
+
152
+ await user.click(screen.getByRole("button", SUBMIT))
153
+
154
+ const message = await screen.findByText("Exceeds the remaining share")
155
+ const second = screen.getByLabelText(OWNER_2_SHARE_LABEL)
156
+ expect(second.getAttribute("aria-invalid")).toBe("true")
157
+ expect(
158
+ screen.getByLabelText(OWNER_1_SHARE_LABEL).getAttribute("aria-invalid")
159
+ ).not.toBe("true")
160
+ expect(message).not.toBeNull()
161
+ })
162
+
163
+ test("form errors and messages for unmounted names become the form-level error", async () => {
164
+ const user = userEvent.setup()
165
+ const formRef: { current: unknown } = { current: undefined }
166
+ const action = () =>
167
+ Promise.resolve(
168
+ failure({ "owners[3].name": ["Unknown owner"] }, ["Record is locked"])
169
+ )
170
+ render(<ProfileForm action={action} formRef={formRef} />)
171
+
172
+ await user.click(screen.getByRole("button", SUBMIT))
173
+
174
+ await vi.waitFor(() => {
175
+ const form = formRef.current as { state: { errorMap: unknown } }
176
+ expect(form.state.errorMap).toMatchObject({
177
+ onSubmit: "Record is locked\nUnknown owner",
178
+ })
179
+ })
180
+ const form = formRef.current as {
181
+ state: { fieldMeta: Record<string, unknown> }
182
+ }
183
+ expect(form.state.fieldMeta["owners[3].name"]).toBeUndefined()
184
+ })
185
+
186
+ test("a ValidationError thrown from onSubmit also clears when its field is edited", async () => {
187
+ const user = userEvent.setup()
188
+ function Harness() {
189
+ const form = useForm({
190
+ defaultValues: { email: "ada@example.com", name: "Ada" },
191
+ onSubmit: () => {
192
+ throw new ValidationError({ email: "Taken on the server" })
193
+ },
194
+ schema: profileSchema,
195
+ })
196
+ const { EmailField, InputField, SubmitButton } = form
197
+ return (
198
+ <FormProvider form={form}>
199
+ <Form>
200
+ <InputField label="Name" name="name" />
201
+ <EmailField label="Email" name="email" />
202
+ <SubmitButton>Submit</SubmitButton>
203
+ </Form>
204
+ </FormProvider>
205
+ )
206
+ }
207
+ render(<Harness />)
208
+
209
+ await user.click(screen.getByRole("button", SUBMIT))
210
+ expect(await screen.findByText("Taken on the server")).not.toBeNull()
211
+
212
+ await user.type(screen.getByLabelText(NAME_LABEL), "x")
213
+ expect(screen.getByText("Taken on the server")).not.toBeNull()
214
+
215
+ await user.type(screen.getByLabelText(EMAIL_LABEL), "m")
216
+ expect(screen.queryByText("Taken on the server")).toBeNull()
217
+ })
218
+
219
+ test("several messages for one field all render", async () => {
220
+ const user = userEvent.setup()
221
+ const action = () =>
222
+ Promise.resolve(
223
+ failure({ email: ["Already taken", "Domain is blocked"] })
224
+ )
225
+ render(<ProfileForm action={action} />)
226
+
227
+ await user.click(screen.getByRole("button", SUBMIT))
228
+
229
+ expect(await screen.findByText("Already taken")).not.toBeNull()
230
+ expect(screen.getByText("Domain is blocked")).not.toBeNull()
231
+ })
232
+
233
+ test("a thrown action error propagates and applies nothing", async () => {
234
+ const boom = new Error("network down")
235
+ const run = submitAction(
236
+ {
237
+ formApi: {} as never,
238
+ value: { email: "a@b.co", name: "A" },
239
+ },
240
+ () => Promise.reject(boom)
241
+ )
242
+ await expect(run).rejects.toBe(boom)
243
+ })
244
+ })
245
+
246
+ describe("submitAction — schema", () => {
247
+ test("passes the schema output to the action, not the input values", async () => {
248
+ const user = userEvent.setup()
249
+ const schema = z.object({
250
+ percentage: z.string().transform((value) => Number(value)),
251
+ })
252
+ const action = vi.fn((input: { percentage: number }) =>
253
+ Promise.resolve<ActionResult<number>>({
254
+ data: input.percentage,
255
+ ok: true,
256
+ })
257
+ )
258
+ function Harness() {
259
+ const form = useForm({
260
+ defaultValues: { percentage: "42" },
261
+ onSubmit: (submit) => submitAction(submit, action, { schema }),
262
+ })
263
+ const { InputField, SubmitButton } = form
264
+ return (
265
+ <FormProvider form={form}>
266
+ <Form>
267
+ <InputField label="Share" name="percentage" />
268
+ <SubmitButton>Submit</SubmitButton>
269
+ </Form>
270
+ </FormProvider>
271
+ )
272
+ }
273
+ render(<Harness />)
274
+
275
+ await user.click(screen.getByRole("button", SUBMIT))
276
+
277
+ await vi.waitFor(() =>
278
+ expect(action).toHaveBeenCalledWith({ percentage: 42 })
279
+ )
280
+ })
281
+
282
+ test("a client-side schema failure is applied and the action is not called", async () => {
283
+ const user = userEvent.setup()
284
+ const schema = z.object({ name: z.string().min(3, "At least 3") })
285
+ const action = vi.fn(() =>
286
+ Promise.resolve<ActionResult<null>>({ data: null, ok: true })
287
+ )
288
+ let result: ActionResult<null> | undefined
289
+ function Harness() {
290
+ const form = useForm({
291
+ defaultValues: { name: "Al" },
292
+ onSubmit: async (submit) => {
293
+ result = await submitAction(submit, action, { schema })
294
+ },
295
+ })
296
+ const { InputField, SubmitButton } = form
297
+ return (
298
+ <FormProvider form={form}>
299
+ <Form>
300
+ <InputField label="Name" name="name" />
301
+ <SubmitButton>Submit</SubmitButton>
302
+ </Form>
303
+ </FormProvider>
304
+ )
305
+ }
306
+ render(<Harness />)
307
+
308
+ await user.click(screen.getByRole("button", SUBMIT))
309
+
310
+ expect(await screen.findByText("At least 3")).not.toBeNull()
311
+ expect(action).not.toHaveBeenCalled()
312
+ expect(result).toEqual({
313
+ error: { fieldErrors: { name: ["At least 3"] }, formErrors: [] },
314
+ ok: false,
315
+ })
316
+ })
317
+ })
318
+
319
+ describe("submitAction — success", () => {
320
+ type FormState = {
321
+ state: {
322
+ isDefaultValue: boolean
323
+ isDirty: boolean
324
+ values: Profile
325
+ }
326
+ }
327
+
328
+ // The page re-reading the saved record, as `revalidatePath` does for a
329
+ // server component: `defaultValues` follows what the action saved.
330
+ function SavedForm({
331
+ formRef,
332
+ reset,
333
+ }: {
334
+ formRef: { current: unknown }
335
+ reset?: "values" | true
336
+ }) {
337
+ const [saved, setSaved] = useState<Profile>({
338
+ email: "ada@example.com",
339
+ name: "Ada",
340
+ })
341
+ const save = (input: Profile) => {
342
+ const record = { ...input, name: input.name.trim() }
343
+ setSaved(record)
344
+ return Promise.resolve<ActionResult<Profile>>({ data: record, ok: true })
345
+ }
346
+ const form = useForm({
347
+ defaultValues: saved,
348
+ onSubmit: (submit) => submitAction(submit, save, reset ? { reset } : {}),
349
+ schema: profileSchema,
350
+ })
351
+ formRef.current = form
352
+ const { EmailField, InputField, SubmitButton } = form
353
+ return (
354
+ <FormProvider form={form}>
355
+ <Form>
356
+ <InputField label="Name" name="name" />
357
+ <EmailField label="Email" name="email" />
358
+ <SubmitButton>Submit</SubmitButton>
359
+ </Form>
360
+ </FormProvider>
361
+ )
362
+ }
363
+
364
+ test("returns the action's data", async () => {
365
+ const result = await submitAction(
366
+ { formApi: {} as never, value: { id: 1 } },
367
+ (input: { id: number }) =>
368
+ Promise.resolve<ActionResult<string>>({
369
+ data: `saved ${input.id}`,
370
+ ok: true,
371
+ })
372
+ )
373
+ expect(result).toEqual({ data: "saved 1", ok: true })
374
+ })
375
+
376
+ test("without reset, the form stays dirty after the save", async () => {
377
+ const user = userEvent.setup()
378
+ const formRef: { current: unknown } = { current: undefined }
379
+ render(<SavedForm formRef={formRef} />)
380
+
381
+ await user.type(screen.getByLabelText(NAME_LABEL), " Lovelace ")
382
+ await user.click(screen.getByRole("button", SUBMIT))
383
+
384
+ const form = formRef.current as FormState
385
+ await vi.waitFor(() => expect(form.state.isDefaultValue).toBe(false))
386
+ expect(form.state.isDirty).toBe(true)
387
+ expect(form.state.values.name).toBe("Ada Lovelace ")
388
+ })
389
+
390
+ test("reset: true rebases the form on the returned data", async () => {
391
+ const user = userEvent.setup()
392
+ const formRef: { current: unknown } = { current: undefined }
393
+ render(<SavedForm formRef={formRef} reset />)
394
+
395
+ await user.type(screen.getByLabelText(NAME_LABEL), " Lovelace ")
396
+ await user.click(screen.getByRole("button", SUBMIT))
397
+
398
+ await vi.waitFor(() =>
399
+ expect((formRef.current as FormState).state.values.name).toBe(
400
+ "Ada Lovelace"
401
+ )
402
+ )
403
+ const form = formRef.current as FormState
404
+ expect(form.state.isDefaultValue).toBe(true)
405
+ expect(form.state.isDirty).toBe(false)
406
+ })
407
+
408
+ test('reset: "values" rebases the form on the submitted values', async () => {
409
+ const user = userEvent.setup()
410
+ const formRef: { current: unknown } = { current: undefined }
411
+ render(<SavedForm formRef={formRef} reset="values" />)
412
+
413
+ await user.type(screen.getByLabelText(NAME_LABEL), " Lovelace")
414
+ await user.click(screen.getByRole("button", SUBMIT))
415
+
416
+ const form = formRef.current as FormState
417
+ await vi.waitFor(() => expect(form.state.isDirty).toBe(false))
418
+ expect(form.state.values.name).toBe("Ada Lovelace")
419
+ expect(form.state.isDefaultValue).toBe(true)
420
+ })
421
+ })
@@ -1,7 +1,6 @@
1
- import { test } from "@zeno-lib/vitest"
1
+ import { test } from "vitest"
2
2
  import { z } from "zod"
3
-
4
- import { useForm } from "./use-form"
3
+ import { useForm } from "./create-form"
5
4
 
6
5
  const schema = z.object({
7
6
  accept: z.boolean(),
@@ -14,10 +13,6 @@ const schema = z.object({
14
13
  size: z.string(),
15
14
  })
16
15
 
17
- const schemaNoEmail = z.object({
18
- contact: z.string(),
19
- })
20
-
21
16
  test("InputField.name is constrained to DeepKeys<T>", () => {
22
17
  const form = useForm({ onSubmit: () => undefined, schema })
23
18
  const { InputField } = form
@@ -57,36 +52,24 @@ test("TextAreaField.name is constrained to DeepKeys<T>", () => {
57
52
  form.TextAreaField({ name: "nonexistent" })
58
53
  })
59
54
 
60
- test("EmailField.name is optional when T has an `email` field", () => {
55
+ test("EmailField.name is required and constrained to DeepKeys<T>", () => {
61
56
  const form = useForm({ onSubmit: () => undefined, schema })
62
57
  const { EmailField } = form
63
- // No `name` required.
58
+ // @ts-expect-error — name is required
64
59
  EmailField({})
65
- EmailField({ label: "Email" })
66
60
  EmailField({ name: "email" })
61
+ // @ts-expect-error — not a valid path
62
+ EmailField({ name: "nonexistent" })
67
63
  })
68
64
 
69
- test("EmailField.name is required when T has no `email` field", () => {
70
- const form = useForm({ onSubmit: () => undefined, schema: schemaNoEmail })
71
- // @ts-expect-error — name becomes required without an "email" key on T
72
- form.EmailField({})
73
- form.EmailField({ name: "contact" })
74
- })
75
-
76
- test("PasswordField.name is optional when T has a `password` field", () => {
65
+ test("PasswordField.name is required and constrained to DeepKeys<T>", () => {
77
66
  const form = useForm({ onSubmit: () => undefined, schema })
78
67
  const { PasswordField } = form
68
+ // @ts-expect-error — name is required
79
69
  PasswordField({})
80
70
  PasswordField({ name: "password" })
81
71
  })
82
72
 
83
- test("PasswordField.name is required when T has no `password` field", () => {
84
- const form = useForm({ onSubmit: () => undefined, schema: schemaNoEmail })
85
- // @ts-expect-error — name becomes required without a "password" key on T
86
- form.PasswordField({})
87
- form.PasswordField({ name: "contact" })
88
- })
89
-
90
73
  test("validators prop is accepted on every field wrapper", () => {
91
74
  const form = useForm({ onSubmit: () => undefined, schema })
92
75
  form.InputField({
@@ -1,12 +1,9 @@
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"
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
+ import { Form, FormProvider, useForm } from "./create-form"
5
6
 
6
- import { Form, FormProvider } from "./form"
7
- import { useForm } from "./use-form"
8
-
9
- const EMAIL_LABEL = /Email/
10
7
  const CONTACT_EMAIL_LABEL = /Contact email/
11
8
  const PASSWORD_LABEL = /Password/
12
9
  const NAME_LABEL = /Name/
@@ -16,27 +13,7 @@ afterEach(() => {
16
13
  cleanup()
17
14
  })
18
15
 
19
- describe("EmailField / PasswordField — default name", () => {
20
- test("EmailField with no `name` renders an input with id='email'", () => {
21
- const schema = z.object({ email: z.email() })
22
- function H() {
23
- const form = useForm({ onSubmit: vi.fn(), schema })
24
- const { EmailField } = form
25
- return (
26
- <FormProvider form={form}>
27
- <Form>
28
- <EmailField label="Email" />
29
- </Form>
30
- </FormProvider>
31
- )
32
- }
33
- render(<H />)
34
- const input = screen.getByLabelText(EMAIL_LABEL) as HTMLInputElement
35
- expect(input.id).toBe("email")
36
- expect(input.name).toBe("email")
37
- expect(input.type).toBe("email")
38
- })
39
-
16
+ describe("EmailField / PasswordField", () => {
40
17
  test("EmailField with an explicit `name` binds to that field", async () => {
41
18
  const user = userEvent.setup()
42
19
  const schema = z.object({ contactEmail: z.email() })
@@ -59,19 +36,18 @@ describe("EmailField / PasswordField — default name", () => {
59
36
  expect(input.value).toBe("u@example.com")
60
37
  })
61
38
 
62
- test("PasswordField with no `name` renders an input with id='password'", () => {
39
+ test("PasswordField with an explicit `name` renders a password input", () => {
63
40
  const schema = z.object({
64
41
  email: z.email(),
65
42
  password: z.string().min(1),
66
43
  })
67
44
  function H() {
68
45
  const form = useForm({ onSubmit: vi.fn(), schema })
69
- const { EmailField, PasswordField } = form
46
+ const { PasswordField } = form
70
47
  return (
71
48
  <FormProvider form={form}>
72
49
  <Form>
73
- <EmailField />
74
- <PasswordField label="Password" />
50
+ <PasswordField label="Password" name="password" />
75
51
  </Form>
76
52
  </FormProvider>
77
53
  )
@@ -1,10 +1,8 @@
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"
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"
7
- import { useForm } from "./use-form"
5
+ import { Form, FormProvider, useForm } from "./create-form"
8
6
 
9
7
  const DIET_LABEL = /Diet/
10
8
  const SAUSAGE_LABEL = /Sausage/