@zeno-lib/forms 0.6.0 → 0.7.1

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 (59) hide show
  1. package/dist/create-zeno-form.d.mts +19 -4
  2. package/dist/create-zeno-form.mjs +29 -6
  3. package/dist/form-element.d.mts +16 -3
  4. package/dist/form-element.mjs +38 -18
  5. package/dist/index.d.mts +2 -1
  6. package/dist/index.mjs +2 -1
  7. package/dist/lib/date-value.d.mts +24 -0
  8. package/dist/lib/date-value.mjs +37 -0
  9. package/dist/lib/default-props.d.mts +7 -0
  10. package/dist/lib/default-props.mjs +13 -0
  11. package/dist/lib/empty-value.mjs +25 -1
  12. package/dist/lib/form-disabled.d.mts +28 -0
  13. package/dist/lib/form-disabled.mjs +54 -0
  14. package/dist/lib/item-options.d.mts +45 -0
  15. package/dist/lib/item-options.mjs +70 -0
  16. package/dist/lib/schema-required.mjs +17 -0
  17. package/dist/lib/use-field-control.d.mts +7 -0
  18. package/dist/lib/use-field-control.mjs +4 -1
  19. package/package.json +1 -1
  20. package/src/create-form.tsx +34 -29
  21. package/src/create-zeno-form.tsx +124 -51
  22. package/src/date-picker-field.test.tsx +108 -0
  23. package/src/default-props.test-d.ts +66 -0
  24. package/src/default-props.test.tsx +165 -0
  25. package/src/fields/checkbox-field.tsx +5 -0
  26. package/src/fields/checkbox-group-field.tsx +6 -2
  27. package/src/fields/combobox-field.tsx +107 -40
  28. package/src/fields/date-picker-field.tsx +34 -11
  29. package/src/fields/field-shell.tsx +8 -1
  30. package/src/fields/input-field.tsx +2 -0
  31. package/src/fields/multi-select-field.tsx +100 -42
  32. package/src/fields/otp-field.tsx +8 -2
  33. package/src/fields/radio-group-field.tsx +9 -1
  34. package/src/fields/reset-button.tsx +4 -0
  35. package/src/fields/select-field.tsx +9 -1
  36. package/src/fields/slider-field.tsx +9 -1
  37. package/src/fields/submit-button.tsx +3 -1
  38. package/src/fields/switch-field.tsx +5 -0
  39. package/src/fields/textarea-field.tsx +2 -0
  40. package/src/form-dialog.test.tsx +255 -4
  41. package/src/form-dialog.tsx +187 -51
  42. package/src/form-disabled.test.tsx +329 -0
  43. package/src/form-element.tsx +64 -29
  44. package/src/grouped-options.test-d.ts +59 -0
  45. package/src/grouped-options.test.tsx +251 -0
  46. package/src/index.ts +1 -0
  47. package/src/lib/date-value.test.ts +14 -0
  48. package/src/lib/date-value.ts +70 -0
  49. package/src/lib/default-props.ts +19 -0
  50. package/src/lib/empty-value.test.ts +19 -0
  51. package/src/lib/empty-value.ts +45 -1
  52. package/src/lib/form-disabled.ts +95 -0
  53. package/src/lib/item-options.test.ts +11 -0
  54. package/src/lib/item-options.ts +165 -0
  55. package/src/lib/schema-required.test.ts +9 -0
  56. package/src/lib/schema-required.ts +33 -0
  57. package/src/lib/use-field-control.ts +11 -1
  58. package/src/with-form.test-d.ts +76 -0
  59. package/src/with-form.test.tsx +60 -0
@@ -11,10 +11,14 @@ import { type ReactNode, useState } from "react"
11
11
  import { afterEach, describe, expect, onTestFinished, test, vi } from "vitest"
12
12
  import { z } from "zod"
13
13
  import { useForm } from "./create-form"
14
- import { FormDialog } from "./form-dialog"
14
+ import {
15
+ type DiscardPromptText,
16
+ FormDialog,
17
+ FormDialogProvider,
18
+ } from "./form-dialog"
15
19
  import type { ActionResult } from "./lib/action-result"
16
20
  import { submitAction } from "./lib/submit-action"
17
- import { useFormDialog } from "./lib/use-form-dialog"
21
+ import { type FormDialogController, useFormDialog } from "./lib/use-form-dialog"
18
22
 
19
23
  const NAME = /Name/
20
24
  const EMAIL = /Email/
@@ -50,22 +54,36 @@ function Harness({
50
54
  children,
51
55
  onSubmit = vi.fn(),
52
56
  closeOnSubmit,
57
+ controller,
53
58
  disabled,
59
+ discardPrompt,
60
+ formDisabled,
54
61
  saveFromPrompt,
55
62
  submitErrorMessage,
63
+ submitLabel,
56
64
  }: {
57
65
  /** Submit through `submitAction` with this server action instead. */
58
66
  action?: (values: Values) => Promise<ActionResult<unknown>>
59
67
  children?: ReactNode
60
68
  onSubmit?: (values: Values) => unknown
61
69
  closeOnSubmit?: boolean
70
+ /** Gets the dialog's controller, to close it from outside. */
71
+ controller?: { current?: FormDialogController<Values> }
62
72
  disabled?: boolean
73
+ discardPrompt?: DiscardPromptText
74
+ /** Disable the form through `useForm({ disabled })`. */
75
+ formDisabled?: boolean
63
76
  saveFromPrompt?: boolean
64
77
  submitErrorMessage?: (error: unknown) => ReactNode
78
+ submitLabel?: ReactNode
65
79
  }) {
66
80
  const dialog = useFormDialog({ defaultValues: BLANK })
81
+ if (controller) {
82
+ controller.current = dialog
83
+ }
67
84
  const form = useForm({
68
85
  defaultValues: dialog.defaultValues,
86
+ disabled: formDisabled,
69
87
  onSubmit: (submit) =>
70
88
  action ? submitAction(submit, action) : onSubmit(submit.value),
71
89
  schema,
@@ -86,9 +104,11 @@ function Harness({
86
104
  closeOnSubmit={closeOnSubmit}
87
105
  dialog={dialog}
88
106
  disabled={disabled}
107
+ discardPrompt={discardPrompt}
89
108
  form={form}
90
109
  saveFromPrompt={saveFromPrompt}
91
110
  submitErrorMessage={submitErrorMessage}
111
+ submitLabel={submitLabel}
92
112
  title="Person"
93
113
  >
94
114
  <InputField label="Name" name="name" />
@@ -109,6 +129,13 @@ function dropFocus() {
109
129
  sink.remove()
110
130
  }
111
131
 
132
+ // Whether leaving the page now would make the browser ask first.
133
+ function warnsOnUnload(): boolean {
134
+ const event = new Event("beforeunload", { cancelable: true })
135
+ window.dispatchEvent(event)
136
+ return event.defaultPrevented
137
+ }
138
+
112
139
  // The alerts added where assistive tech could see them. One added under an
113
140
  // `aria-hidden` ancestor (the dialog, while the leave prompt is open) is never
114
141
  // announced, not even once that ancestor is shown again.
@@ -267,6 +294,110 @@ describe("FormDialog", () => {
267
294
  expect(screen.getByRole("dialog")).toBeTruthy()
268
295
  })
269
296
 
297
+ for (const closeOnSubmit of [true, false]) {
298
+ test(`a leave asked for while a Save runs goes through once it saves (closeOnSubmit: ${closeOnSubmit})`, async () => {
299
+ const user = userEvent.setup()
300
+ let finishSave: () => void = () => undefined
301
+ const onSubmit = vi.fn(
302
+ () =>
303
+ new Promise<void>((resolve) => {
304
+ finishSave = resolve
305
+ })
306
+ )
307
+ render(<Harness closeOnSubmit={closeOnSubmit} onSubmit={onSubmit} />)
308
+ const name = await openEdit(user)
309
+ await user.type(name, "!")
310
+ await user.click(screen.getByRole("button", { name: "Save" }))
311
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
312
+ await user.keyboard("{Escape}")
313
+ await screen.findByRole("alertdialog")
314
+
315
+ finishSave()
316
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
317
+ expect(screen.queryByRole("alertdialog")).toBeNull()
318
+ })
319
+ }
320
+
321
+ test("an edit made while a Save runs keeps the leave prompt asking once it saves", async () => {
322
+ const user = userEvent.setup()
323
+ let finishSave: () => void = () => undefined
324
+ const onSubmit = vi.fn(
325
+ () =>
326
+ new Promise<void>((resolve) => {
327
+ finishSave = resolve
328
+ })
329
+ )
330
+ render(<Harness onSubmit={onSubmit} />)
331
+ const name = await openEdit(user)
332
+ await user.type(name, "!")
333
+ await user.click(screen.getByRole("button", { name: "Save" }))
334
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
335
+ await user.type(name, "?")
336
+ await user.keyboard("{Escape}")
337
+ const prompt = await screen.findByRole("alertdialog")
338
+
339
+ finishSave()
340
+ await waitFor(() =>
341
+ expect(screen.queryByRole("status", { hidden: true })).toBeNull()
342
+ )
343
+ expect(screen.getByRole("alertdialog")).toBe(prompt)
344
+ await user.click(
345
+ within(prompt).getByRole("button", { name: "Keep editing" })
346
+ )
347
+ await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
348
+ expect(name.value).toBe("Ada!?")
349
+ })
350
+
351
+ test("a Save that fails while the leave prompt asks focuses the invalid field once it closes", async () => {
352
+ const user = userEvent.setup()
353
+ let finishSave: (result: ActionResult<never>) => void = () => undefined
354
+ const action = vi.fn(
355
+ () =>
356
+ new Promise<ActionResult<never>>((resolve) => {
357
+ finishSave = resolve
358
+ })
359
+ )
360
+ render(<Harness action={action} />)
361
+ const name = await openEdit(user)
362
+ await user.type(name, "!")
363
+ await user.click(screen.getByRole("button", { name: "Save" }))
364
+ await waitFor(() => expect(action).toHaveBeenCalled())
365
+ await user.keyboard("{Escape}")
366
+ const prompt = await screen.findByRole("alertdialog")
367
+ await waitFor(() =>
368
+ expect(prompt.contains(document.activeElement)).toBe(true)
369
+ )
370
+
371
+ finishSave({
372
+ error: { fieldErrors: { email: ["Already taken"] }, formErrors: [] },
373
+ ok: false,
374
+ })
375
+ const email = screen.getByLabelText(EMAIL)
376
+ await waitFor(() => expect(email.getAttribute("aria-invalid")).toBe("true"))
377
+ expect(prompt.contains(document.activeElement)).toBe(true)
378
+ await user.click(
379
+ within(prompt).getByRole("button", { name: "Keep editing" })
380
+ )
381
+ await waitFor(() => expect(document.activeElement).toBe(email))
382
+ })
383
+
384
+ test("closing the dialog from outside closes its leave prompt for good", async () => {
385
+ const user = userEvent.setup()
386
+ const controller: { current?: FormDialogController<Values> } = {}
387
+ render(<Harness controller={controller} />)
388
+ const name = await openEdit(user)
389
+ await user.type(name, "!")
390
+ await user.keyboard("{Escape}")
391
+ await screen.findByRole("alertdialog")
392
+
393
+ act(() => controller.current?.close())
394
+ await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
395
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
396
+ await user.click(screen.getByRole("button", { name: "Edit" }))
397
+ await screen.findByRole("dialog")
398
+ expect(screen.queryByRole("alertdialog")).toBeNull()
399
+ })
400
+
270
401
  test("a field error from the server focuses that field", async () => {
271
402
  const user = userEvent.setup()
272
403
  const action = vi.fn(() =>
@@ -573,17 +704,46 @@ describe("FormDialog", () => {
573
704
  expect(screen.getByRole("dialog")).toBeTruthy()
574
705
  })
575
706
 
576
- test("disabled also turns off Save in the leave prompt", async () => {
707
+ for (const via of ["disabled", "useForm({ disabled })"]) {
708
+ test(`with ${via}, a changed dialog still asks and warns, without offering Save`, async () => {
709
+ const user = userEvent.setup()
710
+ const { rerender } = render(<Harness saveFromPrompt />)
711
+ const name = await openEdit(user)
712
+ await user.type(name, "!")
713
+
714
+ rerender(
715
+ <Harness
716
+ disabled={via === "disabled"}
717
+ formDisabled={via !== "disabled"}
718
+ saveFromPrompt
719
+ />
720
+ )
721
+ expect(warnsOnUnload()).toBe(true)
722
+ await user.click(screen.getByRole("button", { name: "Cancel" }))
723
+ const prompt = await screen.findByRole("alertdialog")
724
+ expect(within(prompt).getByText("Discard changes?")).toBeTruthy()
725
+ expect(within(prompt).queryByRole("button", { name: "Save" })).toBeNull()
726
+ await user.click(within(prompt).getByRole("button", { name: "Discard" }))
727
+ await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
728
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
729
+ })
730
+ }
731
+
732
+ test("disabled while the leave prompt asks keeps it, with its Save off", async () => {
577
733
  const user = userEvent.setup()
578
734
  const onSubmit = vi.fn()
579
735
  const { rerender } = render(<Harness onSubmit={onSubmit} saveFromPrompt />)
580
736
  const name = await openEdit(user)
581
737
  await user.type(name, "!")
582
- rerender(<Harness disabled onSubmit={onSubmit} saveFromPrompt />)
583
738
  await user.click(screen.getByRole("button", { name: "Cancel" }))
584
739
  const prompt = await screen.findByRole("alertdialog")
740
+
741
+ rerender(<Harness disabled onSubmit={onSubmit} saveFromPrompt />)
585
742
  await user.click(within(prompt).getByRole("button", { name: "Save" }))
586
743
  expect(onSubmit).not.toHaveBeenCalled()
744
+ expect(screen.getByRole("alertdialog")).toBe(prompt)
745
+ await user.click(within(prompt).getByRole("button", { name: "Discard" }))
746
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
587
747
  })
588
748
 
589
749
  test("Save in the leave prompt doesn't submit again while a submit runs", async () => {
@@ -650,3 +810,94 @@ describe("FormDialog", () => {
650
810
  expect(outer).not.toHaveBeenCalled()
651
811
  })
652
812
  })
813
+
814
+ describe("FormDialogProvider", () => {
815
+ test("sets the defaults of the dialogs below it; a dialog's own prop wins", async () => {
816
+ const user = userEvent.setup()
817
+ render(
818
+ <FormDialogProvider
819
+ cancelLabel="Abbrechen"
820
+ discardPrompt={{
821
+ confirmLabel: "Verwerfen",
822
+ saveLabel: "Sichern",
823
+ saveTitle: "Speichern?",
824
+ title: "Verwerfen?",
825
+ }}
826
+ >
827
+ <FormDialogProvider saveFromPrompt submitLabel="Speichern">
828
+ <Harness discardPrompt={{ title: "Leave this person?" }} />
829
+ </FormDialogProvider>
830
+ </FormDialogProvider>
831
+ )
832
+ const name = await openEdit(user)
833
+ const dialog = screen.getByRole("dialog")
834
+ within(dialog).getByRole("button", { name: "Speichern" })
835
+ await user.type(name, "!")
836
+ await user.click(within(dialog).getByRole("button", { name: "Abbrechen" }))
837
+
838
+ // The prompt's copy merges key by key, and the nearest level wins: the
839
+ // dialog's title over the outer provider's `saveTitle`, its Discard
840
+ // label, and Save labelled by the inner provider's `submitLabel` over the
841
+ // outer one's `saveLabel`.
842
+ const prompt = await screen.findByRole("alertdialog")
843
+ within(prompt).getByText("Leave this person?")
844
+ within(prompt).getByRole("button", { name: "Verwerfen" })
845
+ within(prompt).getByRole("button", { name: "Speichern" })
846
+ })
847
+
848
+ test.each([
849
+ [true, "Unsaved changes", "Save them before you leave?"],
850
+ [false, "Discard changes?", "They will be lost."],
851
+ ])(
852
+ "a provider words the prompt for each mode (saveFromPrompt: %s)",
853
+ async (saveFromPrompt, title, description) => {
854
+ const user = userEvent.setup()
855
+ render(
856
+ <FormDialogProvider
857
+ discardPrompt={{
858
+ description: "They will be lost.",
859
+ saveDescription: "Save them before you leave?",
860
+ saveTitle: "Unsaved changes",
861
+ title: "Discard changes?",
862
+ }}
863
+ >
864
+ <Harness saveFromPrompt={saveFromPrompt} />
865
+ </FormDialogProvider>
866
+ )
867
+ const name = await openEdit(user)
868
+ await user.type(name, "!")
869
+ await user.keyboard("{Escape}")
870
+ const prompt = await screen.findByRole("alertdialog")
871
+ within(prompt).getByText(title)
872
+ within(prompt).getByText(description)
873
+ }
874
+ )
875
+
876
+ test("a dialog's submitLabel labels the prompt's Save over a provider's saveLabel", async () => {
877
+ const user = userEvent.setup()
878
+ render(
879
+ <FormDialogProvider discardPrompt={{ saveLabel: "Speichern" }}>
880
+ <Harness saveFromPrompt submitLabel="Create invoice" />
881
+ </FormDialogProvider>
882
+ )
883
+ const name = await openEdit(user)
884
+ await user.type(name, "!")
885
+ await user.keyboard("{Escape}")
886
+ const prompt = await screen.findByRole("alertdialog")
887
+ within(prompt).getByRole("button", { name: "Create invoice" })
888
+ })
889
+
890
+ test("a dialog's own false wins over the provider's true", async () => {
891
+ const user = userEvent.setup()
892
+ render(
893
+ <FormDialogProvider saveFromPrompt>
894
+ <Harness saveFromPrompt={false} />
895
+ </FormDialogProvider>
896
+ )
897
+ const name = await openEdit(user)
898
+ await user.type(name, "!")
899
+ await user.keyboard("{Escape}")
900
+ const prompt = await screen.findByRole("alertdialog")
901
+ expect(within(prompt).queryByRole("button", { name: "Save" })).toBeNull()
902
+ })
903
+ })