Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, GitCompare, History, Loader2, RotateCcw } from "lucide-react"
import { cn } from "@/lib/utils"

const MINUTE_MS = 60_000
const HOUR_MS = 60 * MINUTE_MS
const DAY_MS = 24 * HOUR_MS

export interface VersionAuthor {
  name: string
  /** Optional remote avatar. Falls back to initials on error — including the
   *  "already failed before hydration" case (cached/prerendered images never

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/version-history.json

Prompt

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 onRestore changes nothing: the panel stays open, the row is untouched, the rejection message surfaces in a role="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 explicit timeZone, 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 title and 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 option semantically, 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 a role="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 by isConnected.

On This Page