@zeno-lib/forms 0.3.0 → 0.4.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 (40) hide show
  1. package/dist/create-zeno-form.mjs +5 -3
  2. package/dist/index.d.mts +2 -2
  3. package/dist/index.mjs +3 -3
  4. package/dist/lib/empty-value.d.mts +12 -0
  5. package/dist/lib/empty-value.mjs +68 -0
  6. package/dist/lib/schema-defaults.d.mts +9 -1
  7. package/dist/lib/schema-defaults.mjs +33 -1
  8. package/dist/lib/schema-required.d.mts +2 -1
  9. package/dist/lib/schema-required.mjs +49 -21
  10. package/dist/lib/submit-action.mjs +1 -1
  11. package/dist/lib/use-is-invalid.d.mts +2 -1
  12. package/dist/lib/use-is-invalid.mjs +5 -2
  13. package/dist/lib/validation-modes.d.mts +5 -1
  14. package/dist/lib/validation-modes.mjs +9 -4
  15. package/package.json +1 -1
  16. package/src/create-zeno-form.tsx +13 -5
  17. package/src/fields/checkbox-field.tsx +8 -1
  18. package/src/fields/combobox-field.tsx +29 -12
  19. package/src/fields/date-picker-field.tsx +37 -9
  20. package/src/fields/email-field.tsx +8 -0
  21. package/src/fields/input-field.tsx +6 -2
  22. package/src/fields/money-field.tsx +3 -1
  23. package/src/fields/multi-select-field.tsx +8 -1
  24. package/src/fields/number-field.tsx +9 -7
  25. package/src/fields/otp-field.tsx +8 -1
  26. package/src/fields/percentage-field.tsx +3 -1
  27. package/src/fields/radio-group-field.tsx +11 -4
  28. package/src/fields/select-field.tsx +26 -3
  29. package/src/fields/switch-field.tsx +8 -1
  30. package/src/fields/textarea-field.tsx +8 -1
  31. package/src/fields/year-field.tsx +3 -1
  32. package/src/index.ts +1 -0
  33. package/src/lib/empty-value.test.ts +72 -0
  34. package/src/lib/empty-value.ts +130 -0
  35. package/src/lib/schema-defaults.ts +50 -1
  36. package/src/lib/schema-required.test.ts +24 -2
  37. package/src/lib/schema-required.ts +81 -43
  38. package/src/lib/use-is-invalid.ts +14 -1
  39. package/src/lib/validation-modes.ts +18 -6
  40. package/src/use-app-fields.test.tsx +312 -2
@@ -1,13 +1,29 @@
1
- import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
1
+ import {
2
+ cleanup,
3
+ fireEvent,
4
+ render,
5
+ screen,
6
+ waitFor,
7
+ within,
8
+ } from "@zeno-lib/test/testing-library"
2
9
  import userEvent from "@zeno-lib/test/user-event"
3
10
  import { afterEach, describe, expect, test, vi } from "vitest"
4
11
  import { z } from "zod"
5
- import { Form, FormProvider, useForm } from "./create-form"
12
+ import { SelectItem } from "@/components/ui/select"
13
+ import { Form, FormProvider, RadioGroupFieldItem, useForm } from "./create-form"
6
14
 
7
15
  const CONTACT_EMAIL_LABEL = /Contact email/
8
16
  const PASSWORD_LABEL = /Password/
9
17
  const NAME_LABEL = /Name/
10
18
  const ACCEPT_NAME = /Accept/
19
+ const TITLE_LABEL = /Title/
20
+ const NOTE_LABEL = /Note/
21
+ const DAY_LABEL = /Day/
22
+ const PICKED_DAY = /October 9/
23
+ const ROLE_LABEL = /Role/
24
+ const OWNER_LABEL = /Owner/
25
+ const PICK_A_ROLE = "Pick a role"
26
+ const PICK_A_DAY = "Pick a day"
11
27
 
12
28
  afterEach(() => {
13
29
  cleanup()
@@ -164,3 +180,297 @@ describe("field ids", () => {
164
180
  expect(dialog.getAttribute("aria-describedby")).toBe(hints[1]?.id)
165
181
  })
166
182
  })
183
+
184
+ describe("ComboboxField", () => {
185
+ test("offers to clear an optional value but not a required one", () => {
186
+ const schema = z.object({
187
+ forced: z.string().optional(),
188
+ kind: z.string().min(1),
189
+ note: z.string().optional(),
190
+ overridden: z.string().min(1),
191
+ })
192
+ const ITEMS = ["a", "b"]
193
+ function H() {
194
+ const form = useForm({
195
+ defaultValues: { forced: "a", kind: "a", note: "a", overridden: "a" },
196
+ onSubmit: vi.fn(),
197
+ schema,
198
+ })
199
+ const { ComboboxField } = form
200
+ return (
201
+ <FormProvider form={form}>
202
+ <Form>
203
+ <ComboboxField items={ITEMS} name="note" />
204
+ <ComboboxField items={ITEMS} name="kind" />
205
+ <ComboboxField items={ITEMS} name="forced" required />
206
+ <ComboboxField items={ITEMS} name="overridden" showClear />
207
+ </Form>
208
+ </FormProvider>
209
+ )
210
+ }
211
+ const { container } = render(<H />)
212
+ const hasClear = (name: string) => {
213
+ const root = container.querySelector<HTMLElement>(
214
+ `[data-field="${name}"]`
215
+ )
216
+ return root
217
+ ? within(root).queryByRole("button", { name: "Clear" }) !== null
218
+ : false
219
+ }
220
+ expect(hasClear("note")).toBe(true)
221
+ expect(hasClear("kind")).toBe(false)
222
+ expect(hasClear("forced")).toBe(false)
223
+ expect(hasClear("overridden")).toBe(true)
224
+ })
225
+ })
226
+
227
+ describe("required fields", () => {
228
+ test("a required control says so to assistive tech, with or without the `*`", () => {
229
+ const schema = z.object({
230
+ kind: z.string().min(1),
231
+ note: z.string(),
232
+ title: z.string().min(1),
233
+ })
234
+ function H({ requiredIndicator }: { requiredIndicator: boolean }) {
235
+ const form = useForm({
236
+ defaultValues: { kind: "a" },
237
+ onSubmit: vi.fn(),
238
+ requiredIndicator,
239
+ schema,
240
+ })
241
+ const { ComboboxField, InputField } = form
242
+ return (
243
+ <FormProvider form={form}>
244
+ <Form>
245
+ <InputField label="Title" name="title" />
246
+ <InputField label="Note" name="note" />
247
+ <ComboboxField items={["a", "b"]} label="Kind" name="kind" />
248
+ </Form>
249
+ </FormProvider>
250
+ )
251
+ }
252
+ for (const requiredIndicator of [true, false]) {
253
+ const { container, unmount } = render(
254
+ <H requiredIndicator={requiredIndicator} />
255
+ )
256
+ const title = screen.getByRole("textbox", { name: TITLE_LABEL })
257
+ const note = screen.getByRole("textbox", { name: NOTE_LABEL })
258
+ expect(title.getAttribute("aria-required")).toBe("true")
259
+ expect(note.hasAttribute("aria-required")).toBe(false)
260
+ expect(
261
+ container.querySelectorAll('[data-slot="required-indicator"]')
262
+ ).toHaveLength(requiredIndicator ? 2 : 0)
263
+ expect(
264
+ container.querySelector(
265
+ '[data-field="kind"] [data-slot="combobox-clear"]'
266
+ )
267
+ ).toBeNull()
268
+ unmount()
269
+ }
270
+ })
271
+ })
272
+
273
+ describe("DatePickerField", () => {
274
+ const DAY = new Date(2026, 9, 9)
275
+
276
+ async function pickSelectedDayAgain(required: boolean) {
277
+ const captured: { day?: Date } = {}
278
+ function H() {
279
+ const form = useForm({
280
+ defaultValues: { day: DAY as Date | undefined },
281
+ onSubmit: vi.fn(),
282
+ })
283
+ const { DatePickerField, Subscribe } = form
284
+ return (
285
+ <FormProvider form={form}>
286
+ <Form>
287
+ <DatePickerField label="Day" name="day" required={required} />
288
+ <Subscribe selector={(state) => state.values.day}>
289
+ {(day) => {
290
+ captured.day = day
291
+ return null
292
+ }}
293
+ </Subscribe>
294
+ </Form>
295
+ </FormProvider>
296
+ )
297
+ }
298
+ const user = userEvent.setup()
299
+ render(<H />)
300
+ fireEvent.click(screen.getByRole("button", { name: DAY_LABEL }))
301
+ await user.click(await screen.findByRole("button", { name: PICKED_DAY }))
302
+ return captured.day
303
+ }
304
+
305
+ test("picking a required date's day again keeps it", async () => {
306
+ expect(await pickSelectedDayAgain(true)).toEqual(DAY)
307
+ })
308
+
309
+ test("picking an optional date's day again clears it", async () => {
310
+ expect(await pickSelectedDayAgain(false)).toBeUndefined()
311
+ })
312
+ })
313
+
314
+ describe("ComboboxField with items still loading", () => {
315
+ test("shows a stored id's label once its item arrives, never the raw id", () => {
316
+ function H({ items }: { items: { label: string; value: string }[] }) {
317
+ const form = useForm({
318
+ defaultValues: { ownerId: "user-7" },
319
+ onSubmit: vi.fn(),
320
+ })
321
+ const { ComboboxField } = form
322
+ return (
323
+ <FormProvider form={form}>
324
+ <Form>
325
+ <ComboboxField items={items} label="Owner" name="ownerId" />
326
+ </Form>
327
+ </FormProvider>
328
+ )
329
+ }
330
+ const { rerender } = render(<H items={[]} />)
331
+ const input = screen.getByRole("combobox", { name: OWNER_LABEL })
332
+ expect(input).toHaveProperty("value", "")
333
+ rerender(<H items={[{ label: "Ada Lovelace", value: "user-7" }]} />)
334
+ expect(input).toHaveProperty("value", "Ada Lovelace")
335
+ })
336
+ })
337
+
338
+ describe("popup fields", () => {
339
+ test("a required select shows its error once the popup closes, not while it is open", async () => {
340
+ const user = userEvent.setup()
341
+ function H() {
342
+ const form = useForm({
343
+ onSubmit: vi.fn(),
344
+ schema: z.object({ role: z.string().min(1, PICK_A_ROLE) }),
345
+ })
346
+ const { SelectField } = form
347
+ return (
348
+ <FormProvider form={form}>
349
+ <Form>
350
+ <SelectField label="Role" name="role">
351
+ <SelectItem value="admin">Admin</SelectItem>
352
+ </SelectField>
353
+ </Form>
354
+ </FormProvider>
355
+ )
356
+ }
357
+ render(<H />)
358
+ await user.click(screen.getByRole("combobox", { name: ROLE_LABEL }))
359
+ await screen.findByRole("option", { name: "Admin" })
360
+ expect(screen.queryByText(PICK_A_ROLE)).toBeNull()
361
+ await user.keyboard("{Escape}")
362
+ expect(await screen.findByText(PICK_A_ROLE)).toBeTruthy()
363
+ })
364
+
365
+ test("a required date shows its error once the popover closes, not while it is open", async () => {
366
+ const user = userEvent.setup()
367
+ function H() {
368
+ const form = useForm({
369
+ onSubmit: vi.fn(),
370
+ schema: z.object({ day: z.date({ error: PICK_A_DAY }) }),
371
+ })
372
+ const { DatePickerField } = form
373
+ return (
374
+ <FormProvider form={form}>
375
+ <Form>
376
+ <DatePickerField label="Day" name="day" />
377
+ </Form>
378
+ </FormProvider>
379
+ )
380
+ }
381
+ render(<H />)
382
+ const trigger = screen.getByRole("button", { name: DAY_LABEL })
383
+ trigger.focus()
384
+ fireEvent.click(trigger)
385
+ await screen.findByRole("grid")
386
+ // The trigger's blur is what used to mark the field touched.
387
+ await waitFor(() => expect(document.activeElement).not.toBe(trigger))
388
+ expect(screen.queryByText(PICK_A_DAY)).toBeNull()
389
+ await user.keyboard("{Escape}")
390
+ expect(await screen.findByText(PICK_A_DAY)).toBeTruthy()
391
+ })
392
+ })
393
+
394
+ describe("RadioGroupField", () => {
395
+ test("stores each item's value as is", async () => {
396
+ const user = userEvent.setup()
397
+ const captured: { consent?: unknown } = {}
398
+ function H() {
399
+ const form = useForm({
400
+ defaultValues: { consent: undefined as boolean | undefined },
401
+ onSubmit: vi.fn(),
402
+ })
403
+ const { RadioGroupField, Subscribe } = form
404
+ return (
405
+ <FormProvider form={form}>
406
+ <Form>
407
+ <RadioGroupField label="Consent" name="consent">
408
+ <RadioGroupFieldItem value={true}>Yes</RadioGroupFieldItem>
409
+ <RadioGroupFieldItem value={false}>No</RadioGroupFieldItem>
410
+ </RadioGroupField>
411
+ <Subscribe selector={(state) => state.values.consent}>
412
+ {(consent) => {
413
+ captured.consent = consent
414
+ return null
415
+ }}
416
+ </Subscribe>
417
+ </Form>
418
+ </FormProvider>
419
+ )
420
+ }
421
+ render(<H />)
422
+ await user.click(screen.getByRole("radio", { name: "No" }))
423
+ expect(captured.consent).toBe(false)
424
+ await user.click(screen.getByRole("radio", { name: "Yes" }))
425
+ expect(captured.consent).toBe(true)
426
+ })
427
+ })
428
+
429
+ describe("clearing a field", () => {
430
+ test("empties it to a value its schema accepts", async () => {
431
+ const user = userEvent.setup()
432
+ const OWNER_ID = "00000000-0000-4000-8000-000000000001"
433
+ const schema = z.object({ kind: z.string(), ownerId: z.uuid().nullable() })
434
+ const captured: { values?: unknown } = {}
435
+ function H() {
436
+ const form = useForm({
437
+ defaultValues: { kind: "a", ownerId: OWNER_ID },
438
+ onSubmit: vi.fn(),
439
+ schema,
440
+ })
441
+ const { ComboboxField, Subscribe } = form
442
+ return (
443
+ <FormProvider form={form}>
444
+ <Form>
445
+ <ComboboxField items={["a", "b"]} label="Kind" name="kind" />
446
+ <ComboboxField
447
+ items={[{ label: "Ada Lovelace", value: OWNER_ID }]}
448
+ label="Owner"
449
+ name="ownerId"
450
+ />
451
+ <Subscribe selector={(state) => state.values}>
452
+ {(values) => {
453
+ captured.values = values
454
+ return null
455
+ }}
456
+ </Subscribe>
457
+ </Form>
458
+ </FormProvider>
459
+ )
460
+ }
461
+ const { container } = render(<H />)
462
+ const clearButton = (name: string) => {
463
+ const root = container.querySelector<HTMLElement>(
464
+ `[data-field="${name}"]`
465
+ )
466
+ if (!root) {
467
+ throw new Error(`no ${name} field`)
468
+ }
469
+ return within(root).getByRole("button", { name: "Clear" })
470
+ }
471
+ await user.click(clearButton("kind"))
472
+ await user.click(clearButton("ownerId"))
473
+ expect(captured.values).toEqual({ kind: "", ownerId: null })
474
+ expect(schema.safeParse(captured.values).success).toBe(true)
475
+ })
476
+ })