Undo window
ProAn action just taken ("Deleted 3 rows"), with Undo for as long as the app says it can be undone: until (when the server said the window closes) or seconds (the window the server granted, counted from when this appears). There is no default window: without either, the action shows with the app's cannotUndo text and no Undo. The time left is read from the clock, so a tab that slept catches up, and the text says what the count means. Undo shows pending until onUndo settles (the button stays in the tab order, marked aria-disabled), then "Undone." or "Couldn't undo: …" with the rejection's own reason. When the window closes, the notice stays, says the action can't be undone now, and keeps what happened. Undo never runs past the deadline, even between ticks. After a failed undo with time left the button stays so the person can retry. When the button goes away while it had focus, focus moves to the notice.
Preview
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/undo-window
Usage
"use client"
import * as React from "react"
import { UndoWindow } from "@/components/undo-window"
import { Button } from "@/components/ui/button"
/*
* The windows here stand in for what a server would return with the action's result: how long it keeps the rows, or
* that it can't take the action back at all. UndoWindow has no window of its own.
*/
export default function Example() {
const [run, setRun] = React.useState(0)
return (
<div className="flex flex-col gap-4">
<UndoWindow
key={run}
className="max-w-md"
action="Archived 3 threads"
detail="Moved to Archive. They’re still searchable."
seconds={20}
onUndo={() => new Promise((resolve) => setTimeout(resolve, 600))}
/>
<UndoWindow
key={`fail-${run}`}
className="max-w-md"
action="Deleted 3 rows from Leads"
seconds={20}
onUndo={() => new Promise((_, reject) => setTimeout(() => reject(new Error("Row 2 was changed by someone else since.")), 600))}
/>
<UndoWindow className="max-w-md" action="Sent the export to 240 people" cannotUndo="Sent email can’t be recalled." onUndo={async () => {}} />
<Button variant="outline" size="sm" className="self-start" onClick={() => setRun((n) => n + 1)}>
Reset the examples
</Button>
</div>
)
}<UndoWindow>
| Prop | Type | Default | Description |
|---|---|---|---|
| actionrequired | string | — | What was done, in the app's words, past tense: "Deleted 3 rows", "Sent to 240 people". |
| detail | React.ReactNode | undefined | — | More about it: which rows, where they went. |
| until | string | undefined | — | When the window closes, as an ISO timestamp from the server. Used over `seconds` when both are given. |
| seconds | number | undefined | — | How many seconds the server granted, counted from the render where it first arrives (a value that arrives after mount starts the window then). Not a default: pass what the server said. |
| onUndorequired | () => Promise<void> | — | Undoes the action. Pending until it settles; a rejection's message is shown as the reason it couldn't. |
| onClose | (() => void) | undefined | — | Called once, when the window closes without an undo. |
| cannotUndo | string | undefined | — | Why the action can't be undone, shown when no window is given: "Sent email can't be recalled." |
| className | string | undefined | — | Extra classes for the outer element. |
Strings
The fixed words, in English by default. Change any of them under undoWindow in <LocaleProvider strings={{ … }}>; the table is exported as undoWindowStrings.
| Key | English |
|---|---|
| undo | Undo |
| left | {time} left to undo. |
| undoing | Undoing… |
| undone | Undone. |
| failed | Couldn't undo: {reason} |
| failedNoReason | Couldn't undo. No reason was given. |
| closed | Can't be undone now. |