Version History
A day-grouped snapshot list for documents and configs — pick one to preview, tick two to compare, and roll one back behind a real confirmation with pending, failure and retry.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/version-history.jsonPrompt
Build a React + TypeScript + Tailwind "VersionHistory" component with
lucide-react icons.
Contract
- VersionSnapshot: { id: string; label?: string; author: { name: string;
avatarUrl?: string }; savedAt: string /* ISO 8601 */; current?: boolean;
restorable?: boolean; summary?: string; changeCount?: number }.
- Props: versions: VersionSnapshot[]; now: string | number | Date (required —
see Behavior); selectedId? / defaultSelectedId? (controlled and uncontrolled
selection); onPreview?: (id: string) => void; onCompare?: (a: string, b:
string) => void; onRestore?: (id: string) => Promise<void>; maxVisible = 8;
locale = "en-US"; timeZone = "UTC"; heading?: ReactNode = "Version history"
(pass null to drop the header row); emptyState?: ReactNode; className.
forwardRef to the root div, remaining props spread onto it.
- Deliberately no zod contract + status prop: this component fetches nothing
and owns no four-state machine. `versions.length === 0` IS the empty branch,
and the only async surface is the single `onRestore` promise. Loading and
load-failure belong to whatever renders around it.
Behavior
- Rows are sorted newest-first internally so the caller can hand over any
order. An unparseable `savedAt` sinks to the bottom rather than poisoning the
comparator with NaN, and renders as "Unknown time" under an "Unknown date"
group instead of "Invalid Date". A `now` that does not parse degrades the
relative column to absolute timestamps and stops any day from being labelled
Today/Yesterday — Intl.RelativeTimeFormat.format() THROWS on a non-finite
value, which would otherwise take the whole tree down.
- Day grouping: buckets come from Intl.DateTimeFormat(...).formatToParts read
in the `timeZone` prop — never the runtime's own zone, or a UTC server and a
UTC+8 browser bucket the same row into different days and hydration tears.
Headers read Today / Yesterday / "July 24" (with the year appended once it
differs from `now`) and stick to the top of the scroll container on an OPAQUE
background; a translucent sticky header shows the rows sliding underneath it.
- Time is relative in the row ("2 hours ago", coarsest-fit across
minute/hour/day/week/month/year with numeric:"auto" so locales can say
"yesterday") and absolute in a `title` attribute plus an sr-only span, so the
exact instant reaches mouse and screen-reader users alike. `now` is a prop
and never Date.now(): render stays pure and SSR/CSR agree on the first paint.
A row with no `label` is titled by its time of day ("3:42 PM") — the day is
already carried by the group header.
- Selection: activating a row always calls onPreview(id), even a re-click, so a
consumer may re-fetch. This component renders the list only; the content pane
is the consumer's.
- The current version is badged, carries aria-current="true" and is never
offered a Restore button (restoring what you are already on is a no-op).
`restorable: false` suppresses the button for one row (imports, tombstones).
- Compare (rendered only when onCompare is passed): a header toggle with
aria-pressed reveals one checkbox per row. Ticking a third replaces the
oldest pick, so the pair is always the last two ticked; the second tick fires
onCompare with the pair ordered oldest -> newest (base -> target) regardless
of tick order. Turning the toggle off clears the picks.
- Restore is a disclosure and is never optimistic:
1. "Restore" opens an inline confirmation that spells the guarantee out in
words — it adds a NEW version at the top, the current content stays in the
list and can be restored back. Users refuse rollback buttons that do not
say this.
2. Confirming enters pending: spinner, "Restoring…", a polite live-region
announcement, and every other row's Restore button disabled.
3. Resolve -> close the panel, announce the restore, move focus back to the
trigger. Reject -> KEEP EVERYTHING AS IT WAS: panel open, row untouched,
the rejection's message in a role="alert", button relabelled "Try again".
Never report a success that did not happen.
- Four traps this flow has to dodge, each worth keeping in a variant:
- Wrap the callback as `new Promise(resolve => resolve(onRestore(id)))`, not
`Promise.resolve(onRestore(id))`: a synchronous throw escapes before
Promise.resolve can convert it into a rejection, stranding the button in
pending forever.
- After the await, re-check a mountedRef that is SET TO TRUE IN THE EFFECT
BODY (not merely cleared in cleanup) — StrictMode's mount → cleanup →
mount otherwise leaves it false for a live instance and pending never
clears.
- The confirm and cancel buttons use aria-disabled, not `disabled`: browsers
blur a focused element the instant it becomes disabled, which drops a
keyboard user onto <body> for the whole request. The handler guards itself
against re-entry instead.
- Focus moves that can only run after the next commit — the trigger button is
still disabled while the handler executes, and a newly loaded row is not
rendered yet — go through a focus-intent ref consumed by an effect, and are
skipped unless the target `isConnected` (the consumer may have dropped the
row mid-flight).
- `maxVisible` caps rendered rows; the footer button loads one more page and
moves focus to the first row it revealed, because the footer itself vanishes
once nothing is left to load. It is clamped to >= 1 (0 would hide every row
behind a button that reveals nothing) and a non-finite value means "no cap".
- Avatars are initials by default. A supplied avatarUrl that fails falls back
to initials, including the prerendered case where the image already failed
before hydration attached onError — re-check `complete && naturalWidth === 0`
in the ref callback, or the broken image sticks forever.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground shell, bg-muted for the
selected row and the confirm panel, text-muted-foreground for secondary text,
bg-primary + text-primary-foreground for the Current badge, the active
Compare toggle and the confirm button, border-primary/50 and ring-primary/60
for accents, text-destructive for failure, focus-visible:ring-2 ring-ring on
every control. cn() merges the consumer className into the root.
- The selected row is bg-muted + an inset primary ring + a 2px left bar rather
than a translucent primary tint: measured on a monochrome palette,
text-muted-foreground reads 4.00:1 over a 10% primary tint and 4.46:1 over a
5% tint — both under AA — while bg-muted holds 4.54:1.
- Accessibility: role="list" + li rows, and one role="group" per day named by
its sticky header. Do NOT use a section with aria-labelledby (its implicit
role is a landmark, and a handful inside one panel wrecks landmark
navigation), and do NOT use listbox/option even though "selected" fits the
semantics: options must not contain focusable descendants and a listbox is a
single-tab-stop composite, while every row here owns a select button, a
restore button and a compare checkbox. A plain list of native buttons keeps
all three Tab-reachable with no roving tabindex to maintain.
- Long labels truncate, summaries wrap with break-words, and every growing flex
child carries min-w-0 — a flex item's automatic minimum size otherwise lets
an unbreakable 100-character title push the shrink-0 badge and Restore button
clean out of the card.
- One polite live region for the whole component reports restore progress,
restore failure and the chosen compare pair. The only animation is the
pending spinner, and it carries motion-reduce:animate-none.
Customization levers
- Density: rows are px-2 py-2 with a size-7 avatar and text-sm/text-xs. Drop to
py-1.5 + size-6 for a dense sidebar, or raise maxVisible instead of shrinking
rows when the panel is tall.
- Metadata: `summary` and `changeCount` are optional — omit both for a
timestamp-only list, or add fields (branch name, byte delta, device) to the
meta row; keep them inside the same min-w-0 column so truncation still works.
- Restore copy is the part worth rewriting per product: name the artefact
("Restore this config revision?") and keep the "nothing is lost" sentence.
- Compare: swap the checkbox column for A/B pills, or drop onCompare entirely
and the toggle plus tick column never render.
- Scrolling: the root is a flex column. Give it a height (h-[26rem], or
max-h-full inside a drawer) and the list scrolls with sticky day headers;
leave it unset and the card just grows.
- Time and locale: pass `locale` for a non-English UI and `timeZone` once the
reader's real zone is known (a cookie, a profile field) — the default "UTC"
is the deterministic choice, not the friendly one.Concepts
- Restore-as-append, not overwrite — the confirmation promises that rolling back adds a version instead of destroying the current one, and the callback contract matches: the consumer prepends a new snapshot. That promise is why a destructive-looking button only needs a light inline confirmation instead of a modal.
- Non-optimistic rollback — a rejected
onRestorechanges nothing: the panel stays open, the row is untouched, the rejection message surfaces in arole="alert", and the button becomes "Try again". The component never renders a success it did not observe. - Passed-in
now— the reference instant is a prop and day bucketing runs in an explicittimeZone, so render stays pure, the server and the client agree on "2 hours ago", and a UTC host does not scatter a UTC+8 reader's rows across the wrong days. - Relative label, absolute truth — the row shows "2 hours ago" for scanning while the exact timestamp lives in
titleand an sr-only span, so precision is one hover (or one screen-reader stop) away instead of being lost. - List of buttons, not a listbox — "selected" fits
optionsemantically, but options may not contain focusable descendants and a listbox is a single-tab-stop composite; each row here owns three controls, so the component stays arole="list"of native buttons with a plain Tab order. - Focus intent deferred to commit — after a restore the trigger button is still disabled, and a freshly loaded row does not exist yet, so
focus()in the handler is a silent no-op. Focus targets are queued in a ref and applied by an effect, guarded byisConnected.
Toolbar
A grouped application toolbar that measures itself and folds its lowest-priority actions into a "More" menu — no breakpoints, roving tabindex, portalled overflow menu.
Mini Map
A thumbnail navigator for any scroll container — a draggable viewport box over an auto-measured silhouette of the content, kept in sync by a passive rAF-throttled listener that writes transform straight to the DOM.