KQ-1681 · LEAVE/GUARD
Leave Guard
An unsaved-changes guard that shows exactly what would be lost: it wraps the page and lists every edited field as a compact diff, old value struck and new value in the tone. Discard plays the edits backwards one row at a time inside the 600ms budget — each new value slides back onto the old one by its measured offset on springs.glide while the strike retracts on glide — before the dialog leaves on the exit ease; Save fills as fields report in and draws a check per row on springs.flick, and a failure stays inside the dialog. It is an alertdialog with a focus trap: focus starts on Keep editing, Escape keeps editing and focus goes back to the field that was being edited.
4 unsaved · all stations to leave
Install
One command — the source lands in your repo. Or copy it from the Code tab.
$ pnpm dlx shadcn@latest add @kinetiq/leave-guard
Props
- changes
- LeaveGuardChange[]
- default defaultLeaveGuardChanges
- Every edited field: { id, label, section?, from, to } with string, number, boolean or null values.
- open / defaultOpen
- boolean
- default — / false
- Whether the guard stands over the page, controlled or initial.
- onOpenChange
- (open) => void
- default —
- Fires when the guard closes after a save, a discard or Keep editing.
- onSave
- (changes, report) => void | Promise<void>
- default —
- Save was pressed. Call report(id) as each field lands to fill the progress; a promise holds the dialog, and a rejection (with optional fields messages) keeps it open with the reason inside.
- onDiscard
- (changes) => void
- default —
- The unwind finished: every change was let go.
- onRevert
- (change) => void
- default —
- One row let go during the unwind, so the page behind can revert in step.
- onCancel
- () => void
- default —
- Keep editing or Escape: the guard closed and focus went back to the page.
- children
- ReactNode
- default —
- The guarded page; it goes inert behind the scrim while the guard is open.
- title
- string | (count) => string
- default "You changed four things"
- The heading; the default spells the count in words.
- description
- string
- default "Keep them or let go."
- The line under the heading.
- labels
- { save?, discard?, cancel? }
- default Save changes · Discard · Keep editing
- The three actions' words.
- formatValue
- (value, change) => string
- default On/Off, "empty", String()
- How a value is written in the diff.
- diffStyle
- "inline" | "stacked" | "split"
- default "inline"
- Old struck → new on one line, a − line and a + line, or Before and After columns.
- density
- "compact" | "cozy" | "roomy"
- default "cozy"
- Row padding and type size.
- tone
- "calm" | "caution" | "stern"
- default "caution"
- The badge, the new values' ink, the markers and Discard's weight: cobalt, amber or brick.
- disabled
- boolean
- default false
- Save and Discard are unavailable; Keep editing still works.
- className
- string
- default —
- Classes for the wrapper.
| Prop | Type | Default | Description |
|---|---|---|---|
| changes | LeaveGuardChange[] | defaultLeaveGuardChanges | Every edited field: { id, label, section?, from, to } with string, number, boolean or null values. |
| open / defaultOpen | boolean | — / false | Whether the guard stands over the page, controlled or initial. |
| onOpenChange | (open) => void | — | Fires when the guard closes after a save, a discard or Keep editing. |
| onSave | (changes, report) => void | Promise<void> | — | Save was pressed. Call report(id) as each field lands to fill the progress; a promise holds the dialog, and a rejection (with optional fields messages) keeps it open with the reason inside. |
| onDiscard | (changes) => void | — | The unwind finished: every change was let go. |
| onRevert | (change) => void | — | One row let go during the unwind, so the page behind can revert in step. |
| onCancel | () => void | — | Keep editing or Escape: the guard closed and focus went back to the page. |
| children | ReactNode | — | The guarded page; it goes inert behind the scrim while the guard is open. |
| title | string | (count) => string | "You changed four things" | The heading; the default spells the count in words. |
| description | string | "Keep them or let go." | The line under the heading. |
| labels | { save?, discard?, cancel? } | Save changes · Discard · Keep editing | The three actions' words. |
| formatValue | (value, change) => string | On/Off, "empty", String() | How a value is written in the diff. |
| diffStyle | "inline" | "stacked" | "split" | "inline" | Old struck → new on one line, a − line and a + line, or Before and After columns. |
| density | "compact" | "cozy" | "roomy" | "cozy" | Row padding and type size. |
| tone | "calm" | "caution" | "stern" | "caution" | The badge, the new values' ink, the markers and Discard's weight: cobalt, amber or brick. |
| disabled | boolean | false | Save and Discard are unavailable; Keep editing still works. |
| className | string | — | Classes for the wrapper. |