@zeno-lib/forms 0.5.0 → 0.7.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 (67) hide show
  1. package/dist/create-zeno-form.d.mts +27 -6
  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 +3 -1
  6. package/dist/index.mjs +3 -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/submit-action.d.mts +8 -2
  18. package/dist/lib/submit-action.mjs +4 -2
  19. package/dist/lib/use-field-control.d.mts +83 -0
  20. package/dist/lib/use-field-control.mjs +70 -0
  21. package/package.json +1 -1
  22. package/src/control-field.test.tsx +338 -0
  23. package/src/create-form.tsx +36 -28
  24. package/src/create-zeno-form.tsx +144 -52
  25. package/src/date-picker-field.test.tsx +108 -0
  26. package/src/default-props.test-d.ts +66 -0
  27. package/src/default-props.test.tsx +165 -0
  28. package/src/fields/checkbox-field.tsx +5 -0
  29. package/src/fields/checkbox-group-field.tsx +6 -2
  30. package/src/fields/combobox-field.tsx +107 -40
  31. package/src/fields/control-field.tsx +57 -0
  32. package/src/fields/date-picker-field.tsx +34 -11
  33. package/src/fields/field-shell.tsx +80 -0
  34. package/src/fields/index.ts +2 -0
  35. package/src/fields/input-field.tsx +16 -48
  36. package/src/fields/multi-select-field.tsx +100 -42
  37. package/src/fields/otp-field.tsx +8 -2
  38. package/src/fields/radio-group-field.tsx +9 -1
  39. package/src/fields/reset-button.tsx +4 -0
  40. package/src/fields/select-field.tsx +9 -1
  41. package/src/fields/slider-field.tsx +9 -1
  42. package/src/fields/submit-button.tsx +3 -1
  43. package/src/fields/switch-field.tsx +5 -0
  44. package/src/fields/textarea-field.tsx +16 -48
  45. package/src/form-dialog.test.tsx +102 -1
  46. package/src/form-dialog.tsx +143 -42
  47. package/src/form-disabled.test.tsx +329 -0
  48. package/src/form-element.tsx +64 -29
  49. package/src/grouped-options.test-d.ts +59 -0
  50. package/src/grouped-options.test.tsx +251 -0
  51. package/src/index.ts +7 -0
  52. package/src/lib/date-value.test.ts +14 -0
  53. package/src/lib/date-value.ts +70 -0
  54. package/src/lib/default-props.ts +19 -0
  55. package/src/lib/empty-value.test.ts +19 -0
  56. package/src/lib/empty-value.ts +45 -1
  57. package/src/lib/form-disabled.ts +95 -0
  58. package/src/lib/item-options.test.ts +11 -0
  59. package/src/lib/item-options.ts +165 -0
  60. package/src/lib/schema-required.test.ts +9 -0
  61. package/src/lib/schema-required.ts +33 -0
  62. package/src/lib/submit-action.ts +18 -4
  63. package/src/lib/use-field-control.ts +191 -0
  64. package/src/submit-action.test.tsx +103 -0
  65. package/src/use-app-fields.test-d.ts +51 -1
  66. package/src/with-form.test-d.ts +76 -0
  67. package/src/with-form.test.tsx +60 -0
@@ -1,23 +1,11 @@
1
1
  "use client"
2
2
 
3
- import { describedBy } from "@zeno-lib/forms/lib/aria"
4
3
  import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
- import {
6
- useHideFieldErrors,
7
- useIsFieldRequired,
8
- useIsFieldRequiredBySchema,
9
- useIsInvalid,
10
- } from "@zeno-lib/forms/lib/use-is-invalid"
11
- import { Children, type ComponentProps, type ReactNode, useId } from "react"
12
- import {
13
- Field,
14
- FieldDescription,
15
- FieldError,
16
- FieldLabel,
17
- } from "@/components/ui/field"
4
+ import { useFieldControl } from "@zeno-lib/forms/lib/use-field-control"
5
+ import { Children, type ComponentProps, type ReactNode } from "react"
18
6
  import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group"
19
7
  import { Textarea } from "@/components/ui/textarea"
20
- import { RequiredIndicator } from "../lib/required-indicator"
8
+ import { FieldShell } from "./field-shell"
21
9
 
22
10
  type TextAreaFieldProps = Omit<
23
11
  ComponentProps<typeof Textarea>,
@@ -36,33 +24,25 @@ type TextAreaFieldProps = Omit<
36
24
  function TextAreaField({
37
25
  children,
38
26
  description,
27
+ disabled,
39
28
  label,
40
29
  required,
41
30
  ...props
42
31
  }: TextAreaFieldProps) {
43
32
  const field = useFieldContext<string>()
44
- const id = useId()
45
- const errorId = `${id}-error`
46
- const descriptionId = `${id}-description`
47
- const isInvalid = useIsInvalid(field)
48
- const hideErrors = useHideFieldErrors(field)
49
- const showError = isInvalid && !hideErrors
33
+ // The label names the textarea through `htmlFor`, so the textarea needs no
34
+ // `aria-labelledby` and the hook doesn't get `label`.
35
+ const { controlProps, ...shell } = useFieldControl<string>({
36
+ description,
37
+ disabled,
38
+ required,
39
+ })
50
40
  const hasAddons = Children.count(children) > 0
51
- const schemaRequired = useIsFieldRequired(field)
52
- const isRequired = required ?? schemaRequired
53
- const schemaRequiresValue = useIsFieldRequiredBySchema(field)
54
- const requiresValue = required ?? schemaRequiresValue
55
41
 
42
+ // A textarea keeps the `""` the user typed, where `controlProps.onChange`
43
+ // would store the field's empty value.
56
44
  const textareaProps = {
57
- "aria-describedby": describedBy(
58
- [description, descriptionId],
59
- [showError, errorId]
60
- ),
61
- "aria-invalid": isInvalid || undefined,
62
- "aria-required": requiresValue || undefined,
63
- id,
64
- name: field.name,
65
- onBlur: field.handleBlur,
45
+ ...controlProps,
66
46
  onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) =>
67
47
  field.handleChange(event.target.value),
68
48
  value: field.state.value ?? "",
@@ -70,13 +50,7 @@ function TextAreaField({
70
50
  }
71
51
 
72
52
  return (
73
- <Field data-field={field.name} data-invalid={isInvalid}>
74
- {label && (
75
- <FieldLabel htmlFor={id}>
76
- {label}
77
- {isRequired && <RequiredIndicator />}
78
- </FieldLabel>
79
- )}
53
+ <FieldShell {...shell} description={description} label={label}>
80
54
  {hasAddons ? (
81
55
  <InputGroup>
82
56
  <InputGroupTextarea {...textareaProps} />
@@ -85,13 +59,7 @@ function TextAreaField({
85
59
  ) : (
86
60
  <Textarea {...textareaProps} />
87
61
  )}
88
- {description && (
89
- <FieldDescription id={descriptionId}>{description}</FieldDescription>
90
- )}
91
- {showError && (
92
- <FieldError errors={field.state.meta.errors} id={errorId} />
93
- )}
94
- </Field>
62
+ </FieldShell>
95
63
  )
96
64
  }
97
65
 
@@ -11,7 +11,11 @@ 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
21
  import { useFormDialog } from "./lib/use-form-dialog"
@@ -51,8 +55,10 @@ function Harness({
51
55
  onSubmit = vi.fn(),
52
56
  closeOnSubmit,
53
57
  disabled,
58
+ discardPrompt,
54
59
  saveFromPrompt,
55
60
  submitErrorMessage,
61
+ submitLabel,
56
62
  }: {
57
63
  /** Submit through `submitAction` with this server action instead. */
58
64
  action?: (values: Values) => Promise<ActionResult<unknown>>
@@ -60,8 +66,10 @@ function Harness({
60
66
  onSubmit?: (values: Values) => unknown
61
67
  closeOnSubmit?: boolean
62
68
  disabled?: boolean
69
+ discardPrompt?: DiscardPromptText
63
70
  saveFromPrompt?: boolean
64
71
  submitErrorMessage?: (error: unknown) => ReactNode
72
+ submitLabel?: ReactNode
65
73
  }) {
66
74
  const dialog = useFormDialog({ defaultValues: BLANK })
67
75
  const form = useForm({
@@ -86,9 +94,11 @@ function Harness({
86
94
  closeOnSubmit={closeOnSubmit}
87
95
  dialog={dialog}
88
96
  disabled={disabled}
97
+ discardPrompt={discardPrompt}
89
98
  form={form}
90
99
  saveFromPrompt={saveFromPrompt}
91
100
  submitErrorMessage={submitErrorMessage}
101
+ submitLabel={submitLabel}
92
102
  title="Person"
93
103
  >
94
104
  <InputField label="Name" name="name" />
@@ -650,3 +660,94 @@ describe("FormDialog", () => {
650
660
  expect(outer).not.toHaveBeenCalled()
651
661
  })
652
662
  })
663
+
664
+ describe("FormDialogProvider", () => {
665
+ test("sets the defaults of the dialogs below it; a dialog's own prop wins", async () => {
666
+ const user = userEvent.setup()
667
+ render(
668
+ <FormDialogProvider
669
+ cancelLabel="Abbrechen"
670
+ discardPrompt={{
671
+ confirmLabel: "Verwerfen",
672
+ saveLabel: "Sichern",
673
+ saveTitle: "Speichern?",
674
+ title: "Verwerfen?",
675
+ }}
676
+ >
677
+ <FormDialogProvider saveFromPrompt submitLabel="Speichern">
678
+ <Harness discardPrompt={{ title: "Leave this person?" }} />
679
+ </FormDialogProvider>
680
+ </FormDialogProvider>
681
+ )
682
+ const name = await openEdit(user)
683
+ const dialog = screen.getByRole("dialog")
684
+ within(dialog).getByRole("button", { name: "Speichern" })
685
+ await user.type(name, "!")
686
+ await user.click(within(dialog).getByRole("button", { name: "Abbrechen" }))
687
+
688
+ // The prompt's copy merges key by key, and the nearest level wins: the
689
+ // dialog's title over the outer provider's `saveTitle`, its Discard
690
+ // label, and Save labelled by the inner provider's `submitLabel` over the
691
+ // outer one's `saveLabel`.
692
+ const prompt = await screen.findByRole("alertdialog")
693
+ within(prompt).getByText("Leave this person?")
694
+ within(prompt).getByRole("button", { name: "Verwerfen" })
695
+ within(prompt).getByRole("button", { name: "Speichern" })
696
+ })
697
+
698
+ test.each([
699
+ [true, "Unsaved changes", "Save them before you leave?"],
700
+ [false, "Discard changes?", "They will be lost."],
701
+ ])(
702
+ "a provider words the prompt for each mode (saveFromPrompt: %s)",
703
+ async (saveFromPrompt, title, description) => {
704
+ const user = userEvent.setup()
705
+ render(
706
+ <FormDialogProvider
707
+ discardPrompt={{
708
+ description: "They will be lost.",
709
+ saveDescription: "Save them before you leave?",
710
+ saveTitle: "Unsaved changes",
711
+ title: "Discard changes?",
712
+ }}
713
+ >
714
+ <Harness saveFromPrompt={saveFromPrompt} />
715
+ </FormDialogProvider>
716
+ )
717
+ const name = await openEdit(user)
718
+ await user.type(name, "!")
719
+ await user.keyboard("{Escape}")
720
+ const prompt = await screen.findByRole("alertdialog")
721
+ within(prompt).getByText(title)
722
+ within(prompt).getByText(description)
723
+ }
724
+ )
725
+
726
+ test("a dialog's submitLabel labels the prompt's Save over a provider's saveLabel", async () => {
727
+ const user = userEvent.setup()
728
+ render(
729
+ <FormDialogProvider discardPrompt={{ saveLabel: "Speichern" }}>
730
+ <Harness saveFromPrompt submitLabel="Create invoice" />
731
+ </FormDialogProvider>
732
+ )
733
+ const name = await openEdit(user)
734
+ await user.type(name, "!")
735
+ await user.keyboard("{Escape}")
736
+ const prompt = await screen.findByRole("alertdialog")
737
+ within(prompt).getByRole("button", { name: "Create invoice" })
738
+ })
739
+
740
+ test("a dialog's own false wins over the provider's true", async () => {
741
+ const user = userEvent.setup()
742
+ render(
743
+ <FormDialogProvider saveFromPrompt>
744
+ <Harness saveFromPrompt={false} />
745
+ </FormDialogProvider>
746
+ )
747
+ const name = await openEdit(user)
748
+ await user.type(name, "!")
749
+ await user.keyboard("{Escape}")
750
+ const prompt = await screen.findByRole("alertdialog")
751
+ expect(within(prompt).queryByRole("button", { name: "Save" })).toBeNull()
752
+ })
753
+ })
@@ -1,10 +1,12 @@
1
1
  "use client"
2
2
 
3
3
  import { FormProvider } from "@zeno-lib/forms"
4
+ import { withDefaultProps } from "@zeno-lib/forms/lib/default-props"
4
5
  import {
5
6
  focusFirstInvalid,
6
7
  restoreFocus,
7
8
  } from "@zeno-lib/forms/lib/focus-first-invalid"
9
+ import { useIsFormDisabled } from "@zeno-lib/forms/lib/form-disabled"
8
10
  import { reportSubmitError } from "@zeno-lib/forms/lib/submit-error"
9
11
  import {
10
12
  type FormDialogController,
@@ -14,10 +16,13 @@ import {
14
16
  import { useUnsavedChangesWarning } from "@zeno-lib/forms/lib/use-unsaved-changes-warning"
15
17
  import { type AnyFormApi, useSelector } from "@zeno-lib/forms/tanstack"
16
18
  import {
19
+ type ComponentType,
20
+ createContext,
17
21
  type FormEvent,
18
22
  type KeyboardEvent,
19
23
  type ReactElement,
20
24
  type ReactNode,
25
+ useContext,
21
26
  useId,
22
27
  useLayoutEffect,
23
28
  useRef,
@@ -45,7 +50,7 @@ import {
45
50
  DialogTrigger,
46
51
  } from "@/components/ui/dialog"
47
52
  import { Spinner } from "@/components/ui/spinner"
48
- import { FormError } from "./fields/form-error"
53
+ import { FormError, type FormErrorProps } from "./fields/form-error"
49
54
 
50
55
  type DiscardPromptText = {
51
56
  title?: ReactNode
@@ -54,6 +59,10 @@ type DiscardPromptText = {
54
59
  cancelLabel?: ReactNode
55
60
  /** The Save action's label (`saveFromPrompt`). Defaults to `submitLabel`. */
56
61
  saveLabel?: ReactNode
62
+ /** The title with `saveFromPrompt`. Defaults to `title`. */
63
+ saveTitle?: ReactNode
64
+ /** The description with `saveFromPrompt`. Defaults to `description`. */
65
+ saveDescription?: ReactNode
57
66
  }
58
67
 
59
68
  // The slice of the `useForm()` API the dialog uses. Structural, so any Zeno /
@@ -77,6 +86,11 @@ type DialogForm<TValues> = {
77
86
  readonly state: DialogFormState
78
87
  handleSubmit: () => unknown
79
88
  reset: (values?: TValues) => void
89
+ /**
90
+ * The form's own `FormError` (`useForm` attaches it), rendered with the
91
+ * app's `defaultProps`. The dialog falls back to the kit's.
92
+ */
93
+ FormError?: ComponentType<Pick<FormErrorProps, "submitErrorMessage">>
80
94
  }
81
95
 
82
96
  type FormDialogProps<TValues> = {
@@ -99,9 +113,9 @@ type FormDialogProps<TValues> = {
99
113
  /** Close after a successful submit. Defaults to `true`. */
100
114
  closeOnSubmit?: boolean
101
115
  /**
102
- * Shown above the footer when the submit handler throws. Defaults to
103
- * `FormError`'s `"Something went wrong. Try again."`. A function gets what
104
- * was thrown.
116
+ * Shown above the footer when the submit handler throws. Defaults to the
117
+ * form's `FormError` default: your `defaultProps`, or
118
+ * `"Something went wrong. Try again."`. A function gets what was thrown.
105
119
  */
106
120
  submitErrorMessage?: ReactNode | ((error: unknown) => ReactNode)
107
121
  /**
@@ -117,8 +131,9 @@ type FormDialogProps<TValues> = {
117
131
  */
118
132
  saveFromPrompt?: boolean
119
133
  /**
120
- * Disable Save, ⌘/Ctrl+Enter, and the native controls in the form (through
121
- * a `<fieldset disabled>`). Defaults to `false`.
134
+ * Disable every field, Save and ⌘/Ctrl+Enter, as `useForm({ disabled })`
135
+ * does. A `<fieldset disabled>` also disables native controls of your own.
136
+ * Defaults to `false`.
122
137
  */
123
138
  disabled?: boolean
124
139
  /** Class for the dialog popup (e.g. a wider `sm:max-w-lg`). */
@@ -127,6 +142,87 @@ type FormDialogProps<TValues> = {
127
142
  formClassName?: string
128
143
  }
129
144
 
145
+ /** The `FormDialog` props a `FormDialogProvider` can set for every dialog. */
146
+ type FormDialogDefaults = Omit<
147
+ FormDialogProps<unknown>,
148
+ | "children"
149
+ | "description"
150
+ | "dialog"
151
+ | "disabled"
152
+ | "footer"
153
+ | "form"
154
+ | "title"
155
+ | "trigger"
156
+ >
157
+
158
+ const FormDialogDefaultsContext = createContext<FormDialogDefaults | undefined>(
159
+ undefined
160
+ )
161
+
162
+ // `props` over `defaults`, with the discard prompt's copy merged key by key.
163
+ function withDialogDefaults<P extends FormDialogDefaults>(
164
+ defaults: FormDialogDefaults | undefined,
165
+ props: P
166
+ ): P {
167
+ if (defaults === undefined) {
168
+ return props
169
+ }
170
+ const merged = withDefaultProps(defaults as Partial<P>, props)
171
+ // With `saveFromPrompt`, a `save*` key falls back to `title`, `description`
172
+ // or `submitLabel`. The nearest level wins: one that sets the fallback but
173
+ // not the `save*` key drops the `save*` key set further up.
174
+ const own = props.discardPrompt ?? {}
175
+ const inherited: DiscardPromptText = { ...defaults.discardPrompt }
176
+ if (props.submitLabel !== undefined) {
177
+ inherited.saveLabel = undefined
178
+ }
179
+ if (own.title !== undefined) {
180
+ inherited.saveTitle = undefined
181
+ }
182
+ if (own.description !== undefined) {
183
+ inherited.saveDescription = undefined
184
+ }
185
+ merged.discardPrompt = withDefaultProps(inherited, own)
186
+ return merged
187
+ }
188
+
189
+ // The prompt's title and description for its mode.
190
+ function getPromptCopy(
191
+ text: DiscardPromptText | undefined,
192
+ saveFromPrompt: boolean
193
+ ) {
194
+ if (saveFromPrompt) {
195
+ return {
196
+ description:
197
+ text?.saveDescription ??
198
+ text?.description ??
199
+ "Your unsaved changes will be lost unless you save them.",
200
+ title: text?.saveTitle ?? text?.title ?? "Save changes?",
201
+ }
202
+ }
203
+ return {
204
+ description: text?.description ?? "Your unsaved changes will be lost.",
205
+ title: text?.title ?? "Discard changes?",
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Sets the default props of every `FormDialog` below it, such as its copy or
211
+ * `saveFromPrompt`. A prop passed to a dialog wins, and `discardPrompt` merges
212
+ * key by key. A nested provider merges over its parent the same way.
213
+ */
214
+ function FormDialogProvider({
215
+ children,
216
+ ...defaults
217
+ }: FormDialogDefaults & { children: ReactNode }) {
218
+ const parent = useContext(FormDialogDefaultsContext)
219
+ return (
220
+ <FormDialogDefaultsContext value={withDialogDefaults(parent, defaults)}>
221
+ {children}
222
+ </FormDialogDefaultsContext>
223
+ )
224
+ }
225
+
130
226
  /**
131
227
  * A dialog-hosted form: opens with fresh values and fields per session,
132
228
  * submits from a footer button outside the `<form>` (via `form="id"`), shows a
@@ -135,26 +231,30 @@ type FormDialogProps<TValues> = {
135
231
  * submit) above the footer, closes on success, and asks before discarding
136
232
  * unsaved changes (Cancel, ×, Escape, outside press) or leaving the page.
137
233
  */
138
- function FormDialog<TValues>({
139
- cancelLabel = "Cancel",
140
- children,
141
- className,
142
- closeOnSubmit = true,
143
- description,
144
- dialog,
145
- disabled = false,
146
- discardPrompt,
147
- footer,
148
- form,
149
- formClassName,
150
- guard = true,
151
- saveFromPrompt = false,
152
- submitErrorMessage,
153
- submitLabel = "Save",
154
- title,
155
- trigger,
156
- }: FormDialogProps<TValues>) {
234
+ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
235
+ const {
236
+ cancelLabel = "Cancel",
237
+ children,
238
+ className,
239
+ closeOnSubmit = true,
240
+ description,
241
+ dialog,
242
+ disabled = false,
243
+ discardPrompt,
244
+ footer,
245
+ form,
246
+ formClassName,
247
+ guard = true,
248
+ saveFromPrompt = false,
249
+ submitErrorMessage,
250
+ submitLabel = "Save",
251
+ title,
252
+ trigger,
253
+ } = withDialogDefaults(useContext(FormDialogDefaultsContext), props)
157
254
  const formId = useId()
255
+ // `useForm({ disabled })` locks the dialog as this prop does.
256
+ const isDisabled =
257
+ useIsFormDisabled(form as unknown as AnyFormApi) || disabled
158
258
  const popupRef = useRef<HTMLDivElement>(null)
159
259
  const formRef = useRef<HTMLFormElement>(null)
160
260
  // Set when a Save from the discard prompt fails, so the prompt hands focus
@@ -167,6 +267,8 @@ function FormDialog<TValues>({
167
267
  const announceOnPromptClosed = useRef<boolean>(false)
168
268
  const [formErrorKey, setFormErrorKey] = useState(0)
169
269
  const { close, defaultValues, focus, isOpen, open, session } = dialog
270
+ const DialogFormError = form.FormError ?? FormError
271
+ const promptCopy = getPromptCopy(discardPrompt, saveFromPrompt)
170
272
  // Read after a submit's await: the dialog may have closed, or reopened on
171
273
  // another record, while a slow save was in flight.
172
274
  const latest = useRef({ isOpen, session })
@@ -216,7 +318,7 @@ function FormDialog<TValues>({
216
318
  // the very first attempt, and `isOpen` is false through the exit animation,
217
319
  // while the fields still hold the values just saved.
218
320
  function canSubmit(): boolean {
219
- return isOpen && !(disabled || form.state.isSubmitting)
321
+ return isOpen && !(isDisabled || form.state.isSubmitting)
220
322
  }
221
323
 
222
324
  // Resolves whether the form saved. A submit handler that throws leaves
@@ -308,7 +410,7 @@ function FormDialog<TValues>({
308
410
  initialFocus={() => {
309
411
  // A disabled control can't take focus.
310
412
  const target =
311
- focus && !disabled
413
+ focus && !isDisabled
312
414
  ? findFieldElement(popupRef.current, focus)
313
415
  : null
314
416
  return target ?? true
@@ -320,11 +422,11 @@ function FormDialog<TValues>({
320
422
  <DialogTitle>{title}</DialogTitle>
321
423
  {description && <DialogDescription>{description}</DialogDescription>}
322
424
  </DialogHeader>
323
- <FormProvider form={form}>
425
+ <FormProvider disabled={disabled} form={form}>
324
426
  {/* Around the <form>, not inside it, so `formClassName` child
325
427
  selectors (`space-y-4`) still reach the fields. `min-w-0` is
326
428
  for a browser that ignores `contents` on a fieldset. */}
327
- <fieldset className="contents min-w-0" disabled={disabled}>
429
+ <fieldset className="contents min-w-0" disabled={isDisabled}>
328
430
  {/* Keyed on the session so every open mounts fresh fields. The
329
431
  popup stays mounted through its exit animation, so reopening
330
432
  before it ends would keep each field's local state (and any
@@ -343,7 +445,7 @@ function FormDialog<TValues>({
343
445
  {/* Next to the Save button that failed, and outside the <form>, so
344
446
  it stays in view when the fields scroll and adds no child for
345
447
  `formClassName` selectors to match. */}
346
- <FormError
448
+ <DialogFormError
347
449
  key={formErrorKey}
348
450
  submitErrorMessage={submitErrorMessage}
349
451
  />
@@ -354,7 +456,7 @@ function FormDialog<TValues>({
354
456
  {cancelLabel}
355
457
  </DialogClose>
356
458
  <Button
357
- disabled={disabled || isSubmitting}
459
+ disabled={isDisabled || isSubmitting}
358
460
  form={formId}
359
461
  type="submit"
360
462
  >
@@ -395,15 +497,9 @@ function FormDialog<TValues>({
395
497
  size={saveFromPrompt ? "default" : "sm"}
396
498
  >
397
499
  <AlertDialogHeader>
398
- <AlertDialogTitle>
399
- {discardPrompt?.title ??
400
- (saveFromPrompt ? "Save changes?" : "Discard changes?")}
401
- </AlertDialogTitle>
500
+ <AlertDialogTitle>{promptCopy.title}</AlertDialogTitle>
402
501
  <AlertDialogDescription>
403
- {discardPrompt?.description ??
404
- (saveFromPrompt
405
- ? "Your unsaved changes will be lost unless you save them."
406
- : "Your unsaved changes will be lost.")}
502
+ {promptCopy.description}
407
503
  </AlertDialogDescription>
408
504
  </AlertDialogHeader>
409
505
  <AlertDialogFooter>
@@ -419,7 +515,7 @@ function FormDialog<TValues>({
419
515
  </AlertDialogAction>
420
516
  {saveFromPrompt && (
421
517
  <AlertDialogAction
422
- disabled={disabled || isSaving || isSubmitting}
518
+ disabled={isDisabled || isSaving || isSubmitting}
423
519
  onClick={saveAndLeave}
424
520
  >
425
521
  {isSaving && <Spinner />}
@@ -433,5 +529,10 @@ function FormDialog<TValues>({
433
529
  )
434
530
  }
435
531
 
436
- export type { DialogForm, DiscardPromptText, FormDialogProps }
437
- export { FormDialog }
532
+ export type {
533
+ DialogForm,
534
+ DiscardPromptText,
535
+ FormDialogDefaults,
536
+ FormDialogProps,
537
+ }
538
+ export { FormDialog, FormDialogProvider }