@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
@@ -1,8 +1,7 @@
1
1
  "use client"
2
2
 
3
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
3
4
  import type { ChangeEvent } from "react"
4
-
5
- import { useFieldContext } from "../lib/contexts"
6
5
  import { InputField, type InputFieldProps } from "./input-field"
7
6
 
8
7
  type NumberFieldProps = Omit<
@@ -1,22 +1,25 @@
1
1
  "use client"
2
2
 
3
+ import { describedBy } from "@zeno-lib/forms/lib/aria"
4
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
+ import {
6
+ useHideFieldErrors,
7
+ useIsFieldRequired,
8
+ useIsInvalid,
9
+ } from "@zeno-lib/forms/lib/use-is-invalid"
10
+ import type { ReactNode } from "react"
3
11
  import {
4
12
  Field,
5
13
  FieldDescription,
6
14
  FieldError,
7
15
  FieldLabel,
8
- } from "@zeno-lib/ui/field"
9
- import { InputOTP, InputOTPGroup, InputOTPSlot } from "@zeno-lib/ui/input-otp"
10
- import type { ReactNode } from "react"
11
-
12
- import { describedBy } from "../lib/aria"
13
- import { useFieldContext } from "../lib/contexts"
14
- import { RequiredIndicator } from "../lib/required-indicator"
16
+ } from "@/components/ui/field"
15
17
  import {
16
- useHideFieldErrors,
17
- useIsFieldRequired,
18
- useIsInvalid,
19
- } from "../lib/use-is-invalid"
18
+ InputOTP,
19
+ InputOTPGroup,
20
+ InputOTPSlot,
21
+ } from "@/components/ui/input-otp"
22
+ import { RequiredIndicator } from "../lib/required-indicator"
20
23
 
21
24
  // Flat surface — `OTPInput`'s prop type is a discriminated union over
22
25
  // `render`/`children`, which doesn't compose cleanly with `Omit`. Listing the
@@ -63,7 +66,7 @@ function OtpField({
63
66
  const isRequired = required ?? schemaRequired
64
67
 
65
68
  return (
66
- <Field data-invalid={isInvalid}>
69
+ <Field data-field={field.name} data-invalid={isInvalid}>
67
70
  {label && (
68
71
  <FieldLabel htmlFor={field.name}>
69
72
  {label}
@@ -0,0 +1,73 @@
1
+ "use client"
2
+
3
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
4
+ import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
5
+ import { InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
6
+ import { InputField, type InputFieldProps } from "./input-field"
7
+
8
+ type PercentageFieldProps = Omit<
9
+ InputFieldProps,
10
+ "defaultValue" | "inputMode" | "onChange" | "onPaste" | "type" | "value"
11
+ > & {
12
+ /**
13
+ * How the form value relates to what the user types:
14
+ * - `"percent"` (default): the value is the number shown, `12.5` ↔ `12.5 %`.
15
+ * - `"fraction"`: the value is a ratio, `0.125` ↔ `12.5 %`.
16
+ */
17
+ scale?: "fraction" | "percent"
18
+ /** BCP 47 locale driving the decimal separator. Defaults to the runtime locale. */
19
+ locale?: string
20
+ /** Digits allowed after the decimal separator (of the shown percentage). Defaults to `2`. */
21
+ fractionDigits?: number
22
+ /** Accept negative percentages. Defaults to `false`. */
23
+ allowNegative?: boolean
24
+ /** Clamp on blur, in the form value's scale. */
25
+ min?: number
26
+ /** Clamp on blur, in the form value's scale. */
27
+ max?: number
28
+ }
29
+
30
+ const toPercent = (value: number) => value * 100
31
+ const fromPercent = (value: number) => value / 100
32
+
33
+ /**
34
+ * Percentage input with a `%` add-on. The form value is `number | null`, on
35
+ * the `scale` you pick (`0–100` by default, `0–1` with `scale="fraction"`).
36
+ */
37
+ function PercentageField({
38
+ allowNegative = false,
39
+ children,
40
+ fractionDigits = 2,
41
+ locale,
42
+ max,
43
+ min,
44
+ scale = "percent",
45
+ ...props
46
+ }: PercentageFieldProps) {
47
+ const field = useFieldContext<number | null | undefined>()
48
+ const isFraction = scale === "fraction"
49
+
50
+ const inputProps = useFormattedNumber({
51
+ allowNegative,
52
+ locale,
53
+ max,
54
+ maximumFractionDigits: fractionDigits,
55
+ min,
56
+ onBlur: field.handleBlur,
57
+ onValueChange: (next) => field.handleChange(next),
58
+ value: field.state.value,
59
+ ...(isFraction ? { fromDisplay: fromPercent, toDisplay: toPercent } : {}),
60
+ })
61
+
62
+ return (
63
+ <InputField {...props} {...inputProps}>
64
+ <InputGroupAddon align="inline-end">
65
+ <InputGroupText>%</InputGroupText>
66
+ </InputGroupAddon>
67
+ {children}
68
+ </InputField>
69
+ )
70
+ }
71
+
72
+ export type { PercentageFieldProps }
73
+ export { PercentageField }
@@ -1,23 +1,22 @@
1
1
  "use client"
2
2
 
3
+ import { describedBy } from "@zeno-lib/forms/lib/aria"
4
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
+ import {
6
+ useHideFieldErrors,
7
+ useIsFieldRequired,
8
+ useIsInvalid,
9
+ } from "@zeno-lib/forms/lib/use-is-invalid"
10
+ import { type ComponentProps, type ReactNode, useId } from "react"
3
11
  import {
4
12
  Field,
5
13
  FieldDescription,
6
14
  FieldError,
7
15
  FieldLabel,
8
- } from "@zeno-lib/ui/field"
9
- import { cn } from "@zeno-lib/ui/lib/utils"
10
- import { RadioGroup, RadioGroupItem } from "@zeno-lib/ui/radio-group"
11
- import { type ComponentProps, type ReactNode, useId } from "react"
12
-
13
- import { describedBy } from "../lib/aria"
14
- import { useFieldContext } from "../lib/contexts"
16
+ } from "@/components/ui/field"
17
+ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
18
+ import { cn } from "@/lib/utils"
15
19
  import { RequiredIndicator } from "../lib/required-indicator"
16
- import {
17
- useHideFieldErrors,
18
- useIsFieldRequired,
19
- useIsInvalid,
20
- } from "../lib/use-is-invalid"
21
20
 
22
21
  type RadioGroupFieldProps = Omit<
23
22
  ComponentProps<typeof RadioGroup>,
@@ -46,7 +45,7 @@ function RadioGroupField({
46
45
  const isRequired = required ?? schemaRequired
47
46
 
48
47
  return (
49
- <Field data-invalid={isInvalid}>
48
+ <Field data-field={field.name} data-invalid={isInvalid}>
50
49
  {label && (
51
50
  <FieldLabel>
52
51
  {label}
@@ -1,9 +1,8 @@
1
1
  "use client"
2
2
 
3
- import { Button } from "@zeno-lib/ui/button"
3
+ import { useFormContext } from "@zeno-lib/forms/lib/contexts"
4
4
  import type { ComponentProps, ReactNode } from "react"
5
-
6
- import { useFormContext } from "../lib/contexts"
5
+ import { Button } from "@/components/ui/button"
7
6
 
8
7
  type ResetButtonProps = Omit<ComponentProps<typeof Button>, "onClick" | "type">
9
8
 
@@ -1,28 +1,27 @@
1
1
  "use client"
2
2
 
3
+ import { describedBy } from "@zeno-lib/forms/lib/aria"
4
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
+ import {
6
+ useHideFieldErrors,
7
+ useIsFieldRequired,
8
+ useIsInvalid,
9
+ } from "@zeno-lib/forms/lib/use-is-invalid"
10
+ import type { ComponentProps, ReactNode } from "react"
11
+ import { Children, isValidElement, useMemo } from "react"
3
12
  import {
4
13
  Field,
5
14
  FieldDescription,
6
15
  FieldError,
7
16
  FieldLabel,
8
- } from "@zeno-lib/ui/field"
17
+ } from "@/components/ui/field"
9
18
  import {
10
19
  Select,
11
20
  SelectContent,
12
21
  SelectTrigger,
13
22
  SelectValue,
14
- } from "@zeno-lib/ui/select"
15
- import type { ComponentProps, ReactNode } from "react"
16
- import { Children, isValidElement, useMemo } from "react"
17
-
18
- import { describedBy } from "../lib/aria"
19
- import { useFieldContext } from "../lib/contexts"
23
+ } from "@/components/ui/select"
20
24
  import { RequiredIndicator } from "../lib/required-indicator"
21
- import {
22
- useHideFieldErrors,
23
- useIsFieldRequired,
24
- useIsInvalid,
25
- } from "../lib/use-is-invalid"
26
25
 
27
26
  type SelectFieldProps = Omit<
28
27
  ComponentProps<typeof Select>,
@@ -71,7 +70,7 @@ function SelectField({
71
70
  }, [children])
72
71
 
73
72
  return (
74
- <Field data-invalid={isInvalid}>
73
+ <Field data-field={field.name} data-invalid={isInvalid}>
75
74
  {label && (
76
75
  <FieldLabel htmlFor={field.name}>
77
76
  {label}
@@ -1,23 +1,22 @@
1
1
  "use client"
2
2
 
3
+ import { describedBy } from "@zeno-lib/forms/lib/aria"
4
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
+ import {
6
+ useHideFieldErrors,
7
+ useIsFieldRequired,
8
+ useIsInvalid,
9
+ } from "@zeno-lib/forms/lib/use-is-invalid"
10
+ import type { ComponentProps, ReactNode } from "react"
3
11
  import {
4
12
  Field,
5
13
  FieldContent,
6
14
  FieldDescription,
7
15
  FieldError,
8
16
  FieldLabel,
9
- } from "@zeno-lib/ui/field"
10
- import { Slider } from "@zeno-lib/ui/slider"
11
- import type { ComponentProps, ReactNode } from "react"
12
-
13
- import { describedBy } from "../lib/aria"
14
- import { useFieldContext } from "../lib/contexts"
17
+ } from "@/components/ui/field"
18
+ import { Slider } from "@/components/ui/slider"
15
19
  import { RequiredIndicator } from "../lib/required-indicator"
16
- import {
17
- useHideFieldErrors,
18
- useIsFieldRequired,
19
- useIsInvalid,
20
- } from "../lib/use-is-invalid"
21
20
 
22
21
  type SliderValue = number | number[]
23
22
 
@@ -56,7 +55,7 @@ function SliderField({
56
55
  const readout = formatValue && value !== undefined ? formatValue(value) : null
57
56
 
58
57
  return (
59
- <Field data-invalid={isInvalid}>
58
+ <Field data-field={field.name} data-invalid={isInvalid}>
60
59
  {(label || readout) && (
61
60
  <FieldContent className="flex-row items-center justify-between">
62
61
  {label && (
@@ -1,10 +1,9 @@
1
1
  "use client"
2
2
 
3
- import { Button } from "@zeno-lib/ui/button"
4
- import { Spinner } from "@zeno-lib/ui/spinner"
3
+ import { useFormContext } from "@zeno-lib/forms/lib/contexts"
5
4
  import type { ComponentProps } from "react"
6
-
7
- import { useFormContext } from "../lib/contexts"
5
+ import { Button } from "@/components/ui/button"
6
+ import { Spinner } from "@/components/ui/spinner"
8
7
 
9
8
  type SubmitButtonProps = Omit<ComponentProps<typeof Button>, "type">
10
9
 
@@ -1,23 +1,22 @@
1
1
  "use client"
2
2
 
3
+ import { describedBy } from "@zeno-lib/forms/lib/aria"
4
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
+ import {
6
+ useHideFieldErrors,
7
+ useIsFieldRequired,
8
+ useIsInvalid,
9
+ } from "@zeno-lib/forms/lib/use-is-invalid"
10
+ import type { ComponentProps, ReactNode } from "react"
3
11
  import {
4
12
  Field,
5
13
  FieldContent,
6
14
  FieldDescription,
7
15
  FieldError,
8
16
  FieldLabel,
9
- } from "@zeno-lib/ui/field"
10
- import { Switch } from "@zeno-lib/ui/switch"
11
- import type { ComponentProps, ReactNode } from "react"
12
-
13
- import { describedBy } from "../lib/aria"
14
- import { useFieldContext } from "../lib/contexts"
17
+ } from "@/components/ui/field"
18
+ import { Switch } from "@/components/ui/switch"
15
19
  import { RequiredIndicator } from "../lib/required-indicator"
16
- import {
17
- useHideFieldErrors,
18
- useIsFieldRequired,
19
- useIsInvalid,
20
- } from "../lib/use-is-invalid"
21
20
 
22
21
  type SwitchFieldProps = Omit<
23
22
  ComponentProps<typeof Switch>,
@@ -45,7 +44,11 @@ function SwitchField({
45
44
  const isRequired = required ?? schemaRequired
46
45
 
47
46
  return (
48
- <Field data-invalid={isInvalid} orientation="horizontal">
47
+ <Field
48
+ data-field={field.name}
49
+ data-invalid={isInvalid}
50
+ orientation="horizontal"
51
+ >
49
52
  <FieldContent>
50
53
  {label && (
51
54
  <FieldLabel htmlFor={field.name}>
@@ -1,23 +1,22 @@
1
1
  "use client"
2
2
 
3
+ import { describedBy } from "@zeno-lib/forms/lib/aria"
4
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
+ import {
6
+ useHideFieldErrors,
7
+ useIsFieldRequired,
8
+ useIsInvalid,
9
+ } from "@zeno-lib/forms/lib/use-is-invalid"
10
+ import { Children, type ComponentProps, type ReactNode } from "react"
3
11
  import {
4
12
  Field,
5
13
  FieldDescription,
6
14
  FieldError,
7
15
  FieldLabel,
8
- } from "@zeno-lib/ui/field"
9
- import { InputGroup, InputGroupTextarea } from "@zeno-lib/ui/input-group"
10
- import { Textarea } from "@zeno-lib/ui/textarea"
11
- import { Children, type ComponentProps, type ReactNode } from "react"
12
-
13
- import { describedBy } from "../lib/aria"
14
- import { useFieldContext } from "../lib/contexts"
16
+ } from "@/components/ui/field"
17
+ import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group"
18
+ import { Textarea } from "@/components/ui/textarea"
15
19
  import { RequiredIndicator } from "../lib/required-indicator"
16
- import {
17
- useHideFieldErrors,
18
- useIsFieldRequired,
19
- useIsInvalid,
20
- } from "../lib/use-is-invalid"
21
20
 
22
21
  type TextAreaFieldProps = Omit<
23
22
  ComponentProps<typeof Textarea>,
@@ -63,7 +62,7 @@ function TextAreaField({
63
62
  }
64
63
 
65
64
  return (
66
- <Field data-invalid={isInvalid}>
65
+ <Field data-field={field.name} data-invalid={isInvalid}>
67
66
  {label && (
68
67
  <FieldLabel htmlFor={field.name}>
69
68
  {label}
@@ -0,0 +1,56 @@
1
+ "use client"
2
+
3
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
4
+ import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
5
+ import { InputField, type InputFieldProps } from "./input-field"
6
+
7
+ type YearFieldProps = Omit<
8
+ InputFieldProps,
9
+ | "defaultValue"
10
+ | "inputMode"
11
+ | "max"
12
+ | "maxLength"
13
+ | "min"
14
+ | "onChange"
15
+ | "onPaste"
16
+ | "type"
17
+ | "value"
18
+ > & {
19
+ /** Earliest accepted year. The value is clamped to it on blur. */
20
+ min?: number
21
+ /** Latest accepted year. The value is clamped to it on blur. */
22
+ max?: number
23
+ }
24
+
25
+ /**
26
+ * Four-digit year input. The form value is an integer year or `null`. No
27
+ * thousands separator, digits only, clamped to `min`/`max` on blur.
28
+ */
29
+ function YearField({ max, min, ...props }: YearFieldProps) {
30
+ const field = useFieldContext<number | null | undefined>()
31
+
32
+ const inputProps = useFormattedNumber({
33
+ allowNegative: false,
34
+ max,
35
+ maxIntegerDigits: 4,
36
+ maximumFractionDigits: 0,
37
+ min,
38
+ onBlur: field.handleBlur,
39
+ onValueChange: (next) => field.handleChange(next),
40
+ useGrouping: false,
41
+ value: field.state.value,
42
+ })
43
+
44
+ return (
45
+ <InputField
46
+ autoComplete="off"
47
+ maxLength={4}
48
+ placeholder="YYYY"
49
+ {...props}
50
+ {...inputProps}
51
+ />
52
+ )
53
+ }
54
+
55
+ export type { YearFieldProps }
56
+ export { YearField }
@@ -0,0 +1,161 @@
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 { afterEach, describe, expect, test, vi } from "vitest"
9
+ import { z } from "zod"
10
+ import { useForm } from "./create-form"
11
+ import { FormDialog } from "./form-dialog"
12
+ import { useFormDialog } from "./lib/use-form-dialog"
13
+
14
+ const NAME = /Name/
15
+ const EMAIL = /Email/
16
+
17
+ afterEach(() => {
18
+ cleanup()
19
+ })
20
+
21
+ const schema = z.object({
22
+ email: z.string(),
23
+ name: z.string().min(1, "Required"),
24
+ })
25
+ type Values = z.infer<typeof schema>
26
+ const BLANK: Values = { email: "", name: "" }
27
+ const ROW: Values = { email: "ada@example.com", name: "Ada" }
28
+
29
+ function Harness({
30
+ onSubmit = vi.fn(),
31
+ closeOnSubmit,
32
+ }: {
33
+ onSubmit?: (values: Values) => unknown
34
+ closeOnSubmit?: boolean
35
+ }) {
36
+ const dialog = useFormDialog({ defaultValues: BLANK })
37
+ const form = useForm({
38
+ defaultValues: dialog.defaultValues,
39
+ onSubmit: ({ value }) => onSubmit(value),
40
+ schema,
41
+ })
42
+ const { InputField } = form
43
+ return (
44
+ <>
45
+ <button onClick={() => dialog.open()} type="button">
46
+ New
47
+ </button>
48
+ <button
49
+ onClick={() => dialog.open({ defaultValues: ROW, focus: "email" })}
50
+ type="button"
51
+ >
52
+ Edit
53
+ </button>
54
+ <FormDialog
55
+ closeOnSubmit={closeOnSubmit}
56
+ dialog={dialog}
57
+ form={form}
58
+ title="Person"
59
+ >
60
+ <InputField label="Name" name="name" />
61
+ <InputField label="Email" name="email" />
62
+ </FormDialog>
63
+ </>
64
+ )
65
+ }
66
+
67
+ // Open the "Edit" session and wait for its initial focus (on Email) to land,
68
+ // so later typing isn't raced by the dialog's focus management.
69
+ async function openEdit(user: ReturnType<typeof userEvent.setup>) {
70
+ await user.click(screen.getByRole("button", { name: "Edit" }))
71
+ const email = await screen.findByLabelText(EMAIL)
72
+ await waitFor(() => expect(document.activeElement).toBe(email))
73
+ return screen.getByLabelText(NAME) as HTMLInputElement
74
+ }
75
+
76
+ describe("FormDialog", () => {
77
+ test("opens with per-session defaults and focuses the requested field", async () => {
78
+ const user = userEvent.setup()
79
+ render(<Harness />)
80
+ const name = await openEdit(user)
81
+ expect(name.value).toBe("Ada")
82
+ })
83
+
84
+ test("closes without asking when nothing changed", async () => {
85
+ const user = userEvent.setup()
86
+ render(<Harness />)
87
+ await openEdit(user)
88
+ await user.click(screen.getByRole("button", { name: "Cancel" }))
89
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
90
+ })
91
+
92
+ test("asks before discarding, keeps editing on cancel, resets on discard", async () => {
93
+ const user = userEvent.setup()
94
+ render(<Harness />)
95
+ const name = await openEdit(user)
96
+ await user.type(name, "!")
97
+ await user.keyboard("{Escape}")
98
+
99
+ await screen.findByRole("alertdialog")
100
+ await user.click(screen.getByRole("button", { name: "Keep editing" }))
101
+ await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
102
+ expect((screen.getByLabelText(NAME) as HTMLInputElement).value).toBe("Ada!")
103
+
104
+ await user.click(screen.getByRole("button", { name: "Cancel" }))
105
+ await user.click(await screen.findByRole("button", { name: "Discard" }))
106
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
107
+
108
+ // Reopening a blank session shows the blank defaults, not the old edit.
109
+ await user.click(screen.getByRole("button", { name: "New" }))
110
+ expect(
111
+ ((await screen.findByLabelText(NAME)) as HTMLInputElement).value
112
+ ).toBe("")
113
+ })
114
+
115
+ test("reverting an edit counts as unchanged (isDefaultValue, not isDirty)", async () => {
116
+ const user = userEvent.setup()
117
+ render(<Harness />)
118
+ const name = await openEdit(user)
119
+ await user.type(name, "x{Backspace}")
120
+ await user.click(screen.getByRole("button", { name: "Cancel" }))
121
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
122
+ expect(screen.queryByRole("alertdialog")).toBeNull()
123
+ })
124
+
125
+ test("submits from the footer button and closes on success", async () => {
126
+ const user = userEvent.setup()
127
+ const onSubmit = vi.fn()
128
+ render(<Harness onSubmit={onSubmit} />)
129
+ const name = await openEdit(user)
130
+ await user.type(name, "!")
131
+ const save = screen.getByRole("button", { name: "Save" })
132
+ expect(save.closest("form")).toBeNull()
133
+ await user.click(save)
134
+ await waitFor(() =>
135
+ expect(onSubmit).toHaveBeenCalledWith({
136
+ email: "ada@example.com",
137
+ name: "Ada!",
138
+ })
139
+ )
140
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
141
+ expect(screen.queryByRole("alertdialog")).toBeNull()
142
+ })
143
+
144
+ test("stays open when validation fails or the submit throws", async () => {
145
+ const user = userEvent.setup()
146
+ const onSubmit = vi.fn(() => {
147
+ throw new Error("nope")
148
+ })
149
+ render(<Harness onSubmit={onSubmit} />)
150
+ await user.click(screen.getByRole("button", { name: "New" }))
151
+ await screen.findByLabelText(NAME)
152
+ await user.click(screen.getByRole("button", { name: "Save" }))
153
+ expect(onSubmit).not.toHaveBeenCalled()
154
+ expect(await screen.findByText("Required")).toBeTruthy()
155
+
156
+ await user.type(screen.getByLabelText(NAME), "Bob")
157
+ await user.click(screen.getByRole("button", { name: "Save" }))
158
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
159
+ expect(screen.getByRole("dialog")).toBeTruthy()
160
+ })
161
+ })