> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Form feedback pattern

> Standard components and conventions for form validation, save feedback, and dirty-close confirmation in stralos-ui

`@stralos-co-nz/ui` ships a standardised set of components and hooks for form feedback. Use these primitives for new modal editors and calculator forms; a feature may add a specialised validator when its fields need domain-specific rules.

## Components at a glance

| Export | Location | Purpose |
| - | - | - |
| `useFormValidation` | `src/forms/useFormValidation.ts` | Field registration, blur validation, save-time validation with focus-move. |
| `FormGrid` | `src/forms/FormGrid.tsx` | Twelve-column grid that dialog forms use as their root layout element. `FormField` reads its position automatically via `useInFormGrid`; non-field children need `gridColumn="1 / -1"` to span the full row. |
| `FormField` | `src/forms/FormField.tsx` | Labelled control wrapper with inline error and helper text. Use the `span` prop (`"medium"`, `"short"`) to set field width inside a `FormGrid`. |
| `SubmitButton` | `src/forms/SubmitButton.tsx` | Save button that shows a spinner while saving and prevents double-submit. |
| `notify` | `src/feedback/notify.ts` | Outcome toasts — `notify.success`, `notify.error`, and `notify.info`. |
| `notifyValidationErrors` | `src/feedback/notify.ts` | Standard save-blocked toast wired to a `validateAll` result. |
| `Dialog` | `src/feedback/Dialog.tsx` | Accessible modal with fixed footer, autofocus, dirty-close guard, and automatic modal-stack deferral under a remote. |
| `DialogCancelButton` | `src/feedback/Dialog.tsx` | Footer cancel button pre-wired to the guarded close path. |
| `useDialogClose` | `src/feedback/Dialog.tsx` | Hook to access the guarded close handler from inside a `Dialog`. |
| `ConfirmDialog` | `src/feedback/ConfirmDialog.tsx` | Small centred confirmation popup, layered above an open `Dialog`. |
| `ConstructionSpinner` | `src/feedback/ConstructionSpinner.tsx` | Animated progress indicator with rotating site-flavoured copy for long-running operations. |
| `openForeignLayers` | `src/foreignLayers.ts` | Returns every currently open overlay that belongs to a different owner (shell or other remote). |
| `useForeignLayerGate` | `src/foreignLayers.ts` | Gate hook for remote overlays that are not `Dialog` — admits the `open` request only while no foreign layer is open. |
| `REMOTE_ROOT_ATTRIBUTE` | `src/foreignLayers.ts` | The DOM attribute name (`"data-stralos-remote"`) that `RemoteProvider`, `RemoteScope`, and `Portal` publish. Useful when building a selector for the owning remote of a DOM node. |
| `FOREIGN_LAYER_SCOPES` | `src/foreignLayers.ts` | Frozen tuple `["dialog", "popover", "menu"]` — the zag scopes treated as overlay layers by the modal-stack rule. `ForeignLayerScope` is the derived union type. |

***

## `useFormValidation`

`useFormValidation` owns field registration, per-field blur validation, and full-form validation on save. It surfaces errors as a `Record<string, string | null>` that feeds directly into `FormField`'s `error` prop.

```tsx theme={null}
const validation = useFormValidation();

// Register a field. Call on every render; the latest validator closure wins.
const name = validation.register(
  "clientName",
  (v) => (v.trim() ? null : "Enter a client name."),
);

// Spread onto the control and its wrapper:
<FormField label="Client name" required error={name.error}>
  <Input ref={name.ref} onBlur={name.onBlur} value={value} onChange={...} />
</FormField>
```

### Composite controls

Controls whose focusable element has no meaningful `.value` property (for
example, listbox triggers or date pickers) supply a `getValue` callback instead.
`Combobox` and `CurrencyInput` expose or forward their actual text input, so
attach the validation ref directly and let the hook read `.value`.

```tsx theme={null}
const rate = validation.register(
  "rateItem",
  (v) => (v ? null : "Select a rate item."),
  { getValue: () => selectedRateItemId ?? "" },
);

<FormField label="Rate item" required error={rate.error}>
  <SomePicker ref={rate.ref} onBlur={rate.onBlur} ... />
</FormField>
```

### Save handler

Call `validateAll` from the save handler. On failure it focuses the first invalid field in document order and returns metadata for the summary toast.

```tsx theme={null}
const save = async () => {
  const result = validation.validateAll();
  if (!result.valid) {
    notifyValidationErrors(result); // raises the "Can't save yet" error toast
    return;
  }
  // ... proceed with save
};
```

`validateAll` only validates fields whose element is currently mounted. A conditionally hidden or unmounted field is excluded and cannot block save.

### Server-side errors

Map server validation codes to field errors after a failed save:

```tsx theme={null}
try {
  await onSave(payload);
} catch (err) {
  validation.setError("endDate", "End date must be after start date.");
}
```

### API

| Member | Description |
| - | - |
| `register(name, validate, options?)` | Registers a field and returns `{ name, ref, onBlur, error }`. |
| `errors` | Current errors by field name. |
| `hasErrors` | `true` when any field shows an error. |
| `validateField(name)` | Validates one field; returns whether it is valid. |
| `validateAll()` | Validates every mounted field. Focuses the first invalid one. Returns `{ valid, invalidCount, firstInvalidName }`. |
| `setError(name, message \| null)` | Injects or clears an error — e.g. from a server response. |
| `clearErrors()` | Clears all errors. |

***

## `FormGrid`

`FormGrid` is the twelve-column grid that a dialog's fields sit on. Use it whenever a form has fields of varying widths side-by-side.

A `FormField` with no `span` fills the whole row, so a form that never sets spans reads exactly like a stack of fields. Use the `span` prop on `FormField` to create multi-column rows: `"short"` occupies 4 columns, `"medium"` 6, and `"full"` 12.

```tsx theme={null}
<FormGrid>
  <FormField label="Category" span="medium" required>…</FormField>
  <FormField label="Unit" span="medium" required>…</FormField>
  <FormField label="Item name" span="full" required>…</FormField>
  <FormField label="Bulking %" span="short">…</FormField>
</FormGrid>
```

**Raw children fill the row.** Any direct child that does not state its own `gridColumn` — a helper `<Text>`, a `LocationPicker`, or a checkbox `<Stack>` — spans all twelve columns automatically. This rule is applied through Chakra's `recipes` layer so any explicit `gridColumn` or `FormField` span still wins, regardless of insertion order. You do not need to add `gridColumn="1 / -1"` to non-`FormField` children placed inside a `FormGrid`.

***

## `FormField`

`FormField` wraps Chakra v3's `Field`. It wires `htmlFor` automatically, renders the required indicator, and shows either an inline error or a helper text — never both at once.

```tsx theme={null}
<FormField label="Depth" required error={depth.error} helper="Metres below surface.">
  <Input ref={depth.ref} onBlur={depth.onBlur} type="number" ... />
</FormField>
```

When `error` is set, `FormField` marks the control `aria-invalid` and renders the message in a polite live region so screen readers announce it on focus.

***

## `SubmitButton`

`SubmitButton` prevents double-submit by keeping focus and refusing activation while `isSaving` or `disabled` is true. It shows a spinner while saving.

Blocked is `aria-disabled`, not a real `disabled`. A real `disabled` takes the button out of the tab order at the moment it matters most — the person presses Save, the button disables itself, and the browser drops their focus to `<body>`. `aria-disabled` keeps focus where it was, reads as unavailable, and refuses the click with `preventDefault`.

In tests, `button.disabled` is always `false`. Assert `aria-disabled="true"` instead; neither `toBeDisabled()` nor the `disabled` property sees it.

```tsx theme={null}
<SubmitButton isSaving={saving} disabled={!isDirty}>
  Save rate
</SubmitButton>
```

Pass `form={formId}` when the button sits outside its `<form>` element (the common case in a `Dialog` footer):

```tsx theme={null}
const formId = useId();

// Inside the dialog body:
<form id={formId} onSubmit={handleSubmit}>
  ...
</form>

// Inside the dialog footer:
<SubmitButton form={formId} isSaving={saving}>Save</SubmitButton>
```

Use `useId()` per modal instance — a module-level constant would make a second mounted instance submit the first one's form.

***

## `notify` and `notifyValidationErrors`

`notify` is the only permitted way to raise outcome toasts. Every toast requires both a `title` (what happened) and a `message` (what it means or what to do next). A bare one-liner title is rejected at runtime.

| Method | When to use |
| - | - |
| `notify.success` | The operation finished and succeeded. |
| `notify.error` | The operation failed. Pair a blocked save with `notifyValidationErrors`. |
| `notify.info` | Work accepted but still running — a state to know about before the result lands. |

```ts theme={null}
notify.success({
  title: "Rate saved",
  message: "The excavator hourly rate now applies to new estimates.",
});

notify.error({
  title: "Could not save",
  message: "Check your connection and try again.",
});

notify.info({
  title: "Recalculating the estimate",
  message: "The totals will update when the calculation finishes.",
});
```

Use `notify.info` when work has been accepted but not yet completed — for example, when a background calculation has started and the user should know the result is on its way. Use `notify.success` only when the operation is finished. Do not use `notify.error` for in-flight states.

### Follow-up action on a toast

Pass an optional `action` to add one follow-up button to the toast. The toast dismisses itself after the handler runs, so the handler does not need to close it.

```ts theme={null}
notify.success({
  title: "Assignment removed",
  message: "The crew booking has been cancelled.",
  action: {
    label: "Undo",
    onClick: () => restoreAssignment(id),
  },
});
```

`NotifyAction` is exported from `@stralos-co-nz/ui` for typing the shape separately:

```ts theme={null}
import type { NotifyAction } from "@stralos-co-nz/ui";
```

One action per toast. A choice between several things belongs in a `ConfirmDialog`, not on a toast.

### Blank `message` at runtime

The `message` field is required by the type. At runtime, if the value passed is blank or empty (for example, `message: err.reason` where the server returned an empty reason), the toast raises with the title alone rather than throwing. A blank `title` still throws — it is written in code, not from server data.

This matters when surfacing server errors:

```ts theme={null}
notify.error({
  title: "Could not save",
  message: err.reason, // safe even when err.reason is ""
});
```

### `notifyValidationErrors`

`notifyValidationErrors` is the standard save-blocked toast. Pass the result of `validateAll()` directly:

```ts theme={null}
const result = validation.validateAll();
if (!result.valid) return notifyValidationErrors(result);
```

It renders "1 field needs attention — the first one is highlighted." or "N fields need attention — the first one is highlighted." depending on `invalidCount`.

### When not to use `notify`

Toasts are for outcome feedback only. Never use a toast to ask a question or seek confirmation. A toast raised while a `Dialog` is open sits in an `aria-hidden` subtree and outside the focus trap — use `ConfirmDialog` instead.

***

## `Dialog`

`Dialog` is the standard accessible modal. It provides a focus trap, autofocus of the first focusable field, Escape-close, backdrop-close, and `aria-labelledby` wired from `title`. The body scrolls independently so the header and footer stay pinned — never place a primary action inside `children`.

The preferred way to add a footer is the `actions` prop. Pass `footer` only when the standard layout does not fit.

```tsx theme={null}
<Dialog
  open={isOpen}
  onClose={handleClose}
  title="Add rate item"
  size="md"
  actions={{ primary: { label: "Save item", form: "rate-item-form", loading: saving } }}
>
  <form id="rate-item-form" onSubmit={handleSubmit}>
    <FormGrid>…</FormGrid>
  </form>
</Dialog>
```

`actions` renders a hairline, a **Cancel** button, and exactly one primary action. Cancel routes through the dirty-close guard automatically.

### `actions.primary`

| Field | Type | Notes |
| - | - | - |
| `label` | `string` | Button label at rest. |
| `loading` | `boolean` | Shows the spinner and blocks re-submission. |
| `loadingText` | `string` | Replaces `label` while `loading`. Use for real progress: `"Sending 2 of 5"`, `"Saving…"`. Without it the button shows only a spinner. |
| `disabled` | `boolean` | Disables the button independently of `loading`. |
| `destructive` | `boolean` | Styles the button red. Reserve for irreversible actions. |
| `form` | `string` | `id` of the `<form>` in the dialog body. Makes the button a real submit — the browser runs native field validation before calling `onSubmit`. Use this for any dialog with a `<form>`. |
| `onClick` | `() => void` | Handler for a primary that is not backed by a form. **Skips native form validation** — `required`, `min`, `pattern` and `type="email"` are never checked. Cannot be set alongside `form`. |

> [!WARNING]
>
> Use `form` for any dialog that contains a `<form>`. A primary wired only to `onClick` is a `type="button"` and bypasses every browser-level field constraint, so required fields and format checks are silently skipped.

### Other `actions` fields

| Field | Type | Notes |
| - | - | - |
| `cancelLabel` | `string` | Label of the Cancel button. Defaults to `"Cancel"`. |
| `secondary` | `ReactNode` | Slot beside Cancel for non-destructive secondary actions. |
| `destructive` | `ReactNode` | Slot on the far left for actions such as **Delete location** or **Mark as cancelled**, separated from Cancel. |
| `cancelDisabled` | `boolean` | Disables Cancel independently of `primary.loading`. Use when an inline confirmation step is up. |
| `onCancel` | `() => void` | Cancel handler **only when `DialogActions` is rendered outside a `Dialog`** (gallery page, docs example). Ignored inside a `Dialog`, where Cancel must route through the dirty-close guard. When not provided outside a `Dialog`, the Cancel button is not rendered. |

### Props

| Prop | Type | Default | Description |
| - | - | - | - |
| `open` | `boolean` | — | Controlled open state. |
| `onClose` | `() => void` | — | Called when the dialog should close. |
| `title` | `string` | — | Accessible dialog title. |
| `children` | `ReactNode` | — | Body content (typically the form). |
| `footer` | `ReactNode` | — | Footer slot, overrides the `actions` footer when both are set. |
| `size` | Chakra size token | `"md"` | Dialog size. Three allowed values: `sm` (480 px), `md` (640 px), `lg` (960 px). Never use `xl` or a custom `maxW`. |
| `scrollBehavior` | `"inside" \| "outside"` | `"inside"` | Where long content scrolls. |
| `confirmOnDirtyClose` | `boolean` | `false` | When `true` and `isDirty`, built-in close affordances raise a "Discard changes?" confirm popup. |
| `isDirty` | `boolean` | `false` | Whether the dialog holds unsaved edits. |
| `recordName` | `string` | — | Names the record in the "Discard changes?" confirm message when `confirmOnDirtyClose` is active. Pass the record's current title (for example, the estimate or rate item name). When omitted the message falls back to the dialog title. |
| `suppressDismiss` | `boolean` | `false` | Suppresses all built-in dismissal while `true` — used when a nested step owns the only close paths. |
| `actions` | `DialogActionsSpec` | — | Renders the standard footer (hairline, Cancel, one primary). See `DialogActions` below; a custom `footer` takes precedence. |

### Dirty-close guard

When `confirmOnDirtyClose` and `isDirty` are both `true`, the close button, Escape, and backdrop click raise a `ConfirmDialog` popup above the modal rather than closing immediately. The popup is centred and visible regardless of how far the user has scrolled.

The backdrop guard covers the full lifetime of the dialog. Edits made after the dialog opened are guarded just as edits present at open time are — the backdrop handler always reads the current dirty state rather than the state captured at open time.
The confirm uses `emphasis="cancel"` so **Keep editing** is the solid primary button and **Discard changes** is the ghost action. This ensures the button that loses work is not the most prominent thing on screen.

Pass `recordName` to name the record in the confirm message. The message reads `Your unsaved changes to "<recordName>" will be lost.` When `recordName` is omitted, the message falls back to a generic form using the dialog title.

```tsx theme={null}
const isDirty = JSON.stringify(draft) !== pristine.current;

<Dialog
  open={isOpen}
  onClose={handleClose}
  title="Edit calculator"
  recordName={calculatorName}
  confirmOnDirtyClose
  isDirty={isDirty}
  footer={...}
>
  ...
</Dialog>
```

### Dirty-close confirmation in remote contexts

When `Dialog` is rendered inside a `RemoteProvider` (a micro-frontend remote), the discard confirmation popup is rendered outside the remote's foreign-layer gate. This means it is never inadvertently hidden by the gate while the parent dialog is waiting to be admitted — the popup remains accessible to assistive technology and focus is correctly trapped inside it regardless of the dialog's remote admission state.

No extra configuration is needed. The behaviour is automatic whenever `confirmOnDirtyClose` and `isDirty` are both `true`.

### `DialogCancelButton` and `useDialogClose`

A footer **Cancel** button must route through the same dirty-close guard as the built-in affordances. Use `DialogCancelButton` as a drop-in:

```tsx theme={null}
<DialogCancelButton />           // renders "Cancel"
<DialogCancelButton>Discard</DialogCancelButton>  // custom label
```

For a custom control, call `useDialogClose` to get `requestClose`:

```tsx theme={null}
const { requestClose } = useDialogClose();
<Button onClick={requestClose}>Go back</Button>
```

Calling `onClose` directly from a footer control bypasses the dirty-close guard — don't do it.

### Modal-stack rule (remotes)

Every federated remote bundles its own copy of Chakra, so a page running several remotes holds multiple independent overlay machinery stacks that know nothing of each other. Stacking two Chakra dialog instances on top of each other causes focus-trap recursion, incorrect Escape targeting, and early scroll-lock release.

`Dialog` applies the modal-stack rule automatically when it is rendered under a `RemoteProvider`. While any foreign layer — the shell's or another remote's — is open, the dialog waits. It opens as soon as the foreign layer closes. An already-open dialog is left alone if a foreign layer opens beneath it; only a new open request is deferred. No prop or template wiring is required.

The shell's own `Provider` is a pass-through: the rule does not apply there because the shell gates its own modals on the remote side.

**Ownership boundary.** The rule reads overlay ownership from the DOM. A remote overlay must be portalled through this package's `Portal` (or wrapped in a `RemoteScope`) so the `data-stralos-remote` attribute — the value of `REMOTE_ROOT_ATTRIBUTE` — travels with the portalled subtree. An overlay rendered through Chakra's raw `Portal` carries no attribute; both halves of the rule misread it and neither defers correctly.

**Remote overlays that are not `Dialog`.** If a remote renders a popover, menu, or other overlay outside of `Dialog`, apply the gate manually using the exported helpers:

```tsx theme={null}
import { openForeignLayers, useForeignLayerGate } from "@stralos-co-nz/ui";
import { useRemoteScope } from "@stralos-co-nz/ui";

function MyCustomOverlay({ open, onClose }: { open: boolean; onClose: () => void }) {
  const remoteKey = useRemoteScope();
  const admittedOpen = useForeignLayerGate(open, remoteKey);
  // use admittedOpen as the actual open state passed to the overlay
}
```

`useForeignLayerGate(open, remoteKey)` returns `true` only while no foreign layer is open and the caller is requesting open. Pass `remoteKey` from `useRemoteScope()`; under the shell's own `Provider` `remoteKey` is `null` and the gate is a pass-through.

`openForeignLayers(remoteKey)` is an imperative helper that returns the list of currently open layers belonging to a different owner. Use it for one-off checks outside of React rendering.

***

## `ConfirmDialog`

`ConfirmDialog` is a small centred popup for confirmations that interrupt a flow. It is a real nested modal: it takes its own focus trap and hides the dialog behind it from assistive technology while it is open. Escape and the backdrop resolve to `onCancel`.

The **Cancel** action takes initial focus. A confirmation is a speed bump — the safe choice is the one under the user's fingers.

```tsx theme={null}
<ConfirmDialog
  open={confirmingDelete}
  title="Delete rate item?"
  message={`"${rateName}" will be removed from the library permanently.`}
  confirmLabel="Delete rate item"
  destructive
  onConfirm={handleDelete}
  onCancel={() => setConfirmingDelete(false)}
/>
```

### Props

| Prop | Type | Default | Description |
| - | - | - | - |
| `open` | `boolean` | — | Controlled open state. |
| `title` | `string` | — | Question being asked. |
| `message` | `ReactNode` | — | What confirming will do. |
| `confirmLabel` | `string` | — | Label of the confirming action. |
| `cancelLabel` | `string` | `"Cancel"` | Label of the dismissing action. |
| `destructive` | `boolean` | `false` | Styles the confirm button as destructive (red). |
| `confirmLoading` | `boolean` | `false` | Shows the confirm button's spinner, blocks re-confirmation, and disables Cancel for the duration. Use when the confirm action fires a request. |
| `confirmLoadingText` | `string` | — | Replaces `confirmLabel` while `confirmLoading`: `"Deleting…"`, `"Cancelling booking"`. |
| `emphasis` | `"confirm" \| "cancel"` | `"confirm"` | Which action carries the visual weight. `"confirm"` keeps the confirm as the solid primary button (default). `"cancel"` makes the cancel button solid and demotes the confirm to a ghost — use this when the safe answer should be the easiest one to hit, for example a "Discard changes?" guard. |
| `onConfirm` | `() => void` | — | Called when the confirming action is chosen. |
| `onCancel` | `() => void` | — | Called when the user backs out. |

> [!NOTE]
>
> When `confirmLoading` is `true`, Cancel is also disabled and Escape and the backdrop are suppressed. A confirm already in flight cannot be called back, so an enabled Cancel would promise something it cannot do.

***

## `ConstructionSpinner`

`ConstructionSpinner` is the standard progress indicator for operations that run long enough that a bare spinner reads as a hung page. It rotates site-flavoured copy to prove the request is still alive. AI extraction jobs are the primary driver — a crosstab workbook can take tens of seconds.

The copy never implies progress toward a percentage the caller cannot actually measure. Phrases describe work being done to material, not how close the job is to finishing.

```tsx theme={null}
// Simple usage — rotating phrases, no stage label
<ConstructionSpinner />

// With a known stage (multi-stage extraction)
<ConstructionSpinner
  stage="Reading the workbook"
  detail="rates.xlsx"
/>
```

When `stage` is provided, it becomes the primary text line and the rotating phrase drops to a smaller subtitle. This keeps the meaningful status visible while the rotating copy proves liveness.

### Props

| Prop | Type | Default | Description |
| - | - | - | - |
| `stage` | `string` | — | The operation currently being performed. Rendered as the primary line; the rotating phrase becomes a subtitle. |
| `detail` | `string` | — | Static secondary text, for example the file being read. |
| `phrases` | `readonly string[]` | `CONSTRUCTION_WORKING_PHRASES` | Overrides the rotating copy. |
| `intervalMs` | `number` | `2500` | Milliseconds between phrase changes. Pass `0` to disable rotation. |
| `startIndex` | `number` | `0` | First phrase index. Fixed rather than random so tests and screenshots are deterministic. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Spinner size. |

### Accessibility

The component renders a `role="status"` container with `aria-live="polite"`. The live region sits on one element only — announcing every rotation from a region that also contains the stage would re-read the stage on each tick.

### Inline phrases arrays

Pass a stable reference when overriding phrases. An inline array (`phrases={["one", "two"]}`) creates a new reference on every render. The effect keys on the phrase list's **length**, not its identity, so a new reference does not restart the interval and freeze the copy on phrase one.

```tsx theme={null}
// Stable — defined outside the component or memoised
const CUSTOM_PHRASES = ["Crunching the numbers", "Almost there"] as const;

<ConstructionSpinner phrases={CUSTOM_PHRASES} />
```

### `CONSTRUCTION_WORKING_PHRASES`

The default phrase list is exported as `CONSTRUCTION_WORKING_PHRASES` for callers that want to extend or inspect it. It ships with at least 20 unique phrases. Import from `@stralos-co-nz/ui`.

***

## Save-failure surface

When `onSave` throws (server rejects the payload), surface the error inline inside the modal rather than closing it. Keep the modal open so the user can correct the problem without re-entering their data.

Use `ModalError` from `./estimateShared` and render it **unconditionally** — pass `message={saveError}` and let it manage the empty state. `ModalError` is an ARIA live region (`role="alert"`, `aria-live="assertive"`). A live region that is inserted together with its content is not reliably announced by screen readers; the region must already be in the DOM before the error arrives. Wrapping in `{saveError && <ModalError ... />}` restores the silent-failure bug.

```tsx theme={null}
import { ModalError } from "./estimateShared";

const [saveError, setSaveError] = useState<string | null>(null);

const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault();
  setSaveError(null);

  const result = validation.validateAll();
  if (!result.valid) return notifyValidationErrors(result);

  try {
    await onSave(payload);
  } catch (err) {
    setSaveError(err instanceof Error ? err.message : "Could not save.");
  }
};

// Render inside the dialog body — always mounted, never conditional:
<ModalError message={saveError} />
```

***

## Complete example

```tsx theme={null}
function EditRateDialog({ rate, onSave, onClose }: Props) {
  const formId = useId();
  const validation = useFormValidation();
  const [name, setName] = useState(rate.name);
  const [saving, setSaving] = useState(false);
  const [saveError, setSaveError] = useState<string | null>(null);

  const pristine = useRef(JSON.stringify({ name }));
  const isDirty = JSON.stringify({ name }) !== pristine.current;

  const nameField = validation.register(
    "name",
    (v) => (v.trim() ? null : "Enter a rate name."),
  );

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setSaveError(null);
    const result = validation.validateAll();
    if (!result.valid) return notifyValidationErrors(result);

    setSaving(true);
    try {
      await onSave({ name });
      notify.success({ title: "Rate saved", message: "Changes apply to new estimates." });
      onClose();
    } catch (err) {
      setSaveError(err instanceof Error ? err.message : "Could not save.");
    } finally {
      setSaving(false);
    }
  };

  return (
    <Dialog
      open
      onClose={onClose}
      title="Edit rate"
      confirmOnDirtyClose
      isDirty={isDirty}
      footer={
        <HStack justify="flex-end" width="100%">
          <DialogCancelButton />
          <SubmitButton form={formId} isSaving={saving}>Save rate</SubmitButton>
        </HStack>
      }
    >
      <form id={formId} onSubmit={handleSubmit}>
        <ModalError message={saveError} />
        <FormField label="Rate name" required error={nameField.error}>
          <Input
            ref={nameField.ref}
            onBlur={nameField.onBlur}
            value={name}
            onChange={(e) => setName(e.target.value)}
          />
        </FormField>
      </form>
    </Dialog>
  );
}
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.