Feedback

Diff Confirm

A confirmation dialog that shows the change list first — grouped create/update/delete counts, expandable before → after rows, type-to-confirm past a destructive threshold, and a failure state that keeps the plan on screen.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { ArrowRight, ChevronRight, LoaderCircle, Pencil, Plus, Trash2, TriangleAlert } from "lucide-react"

import { cn } from "@/lib/utils"

/**
 * The entrance animation ships with the component: React 19 hoists <style href>
 * into head and dedupes by href, so several instances on a page still emit one
 * copy of the keyframes.
 */
const KEYFRAMES = `@keyframes zg-diff-confirm-scrim-in{from{opacity:0}to{opacity:1}}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/diff-confirm.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "DiffConfirm" component (React 19 +
react-dom + lucide-react only; no Radix, no dialog library). It is a modal that
shows WHAT is about to change before asking for confirmation.

Contract
- export type DiffChangeKind = "create" | "update" | "delete"
- export interface DiffChange { id: string; label: string; kind: DiffChangeKind;
  before?: string; after?: string; detail?: string }
  - id is the identity used for React keys and for expand state
  - label is the thing being touched (resource name, setting key, file path)
  - before/after are the values shown when the row is expanded
  - detail is one muted line under the label, always visible, never truncated
- export const DiffConfirm = React.forwardRef<HTMLDivElement, DiffConfirmProps>,
  DiffConfirmProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">:
  - open: boolean and onOpenChange: (open: boolean) => void — CONTROLLED ONLY.
    There is no trigger prop and no internal open state: this dialog is opened by
    a flow ("plan finished, here is the diff"), not by a button next to it.
  - changes: DiffChange[]
  - title? = "Review changes", description?: ReactNode
  - confirmLabel? = "Apply changes", cancelLabel? = "Cancel",
    pendingLabel? = "Applying…", retryLabel? = "Retry"
  - onConfirm: () => Promise<void> — resolve = success, reject = failure
  - requireTyping?: string — type-to-confirm phrase
  - warnThreshold? = 3 — deletions above this auto-require typing
  - className merged onto the panel via cn(); remaining props spread onto it
- The forwarded ref points at the panel, which only exists while open.

Behavior — the change list
- Normalize once, in a useMemo over `changes`: drop entries whose id was already
  seen (duplicate React keys make deleting one row unmount a different one), and
  bucket by kind. A kind outside the union (a backend that grew a new verb) is
  filed under "update" rather than dropped — rendering one row wrongly is much
  safer than silently not showing a change that is about to happen.
- Groups render in create -> update -> delete order, each as a
  <section role="group" aria-labelledby> containing a heading and a <ul>. Keep
  <li> as direct children of <ul>: a role-less div in between breaks the list
  ownership chain and screen readers announce an empty list.
- Header shows the counts as "3 to create · 1 to update · 2 to delete" (only
  non-empty kinds; the separator is aria-hidden) and is the dialog's
  aria-describedby target, so opening announces the blast radius.
- Every row carries an icon AND a text badge ("Create" / "Update" / "Delete"),
  and the delete group additionally gets its own border, tinted background and an
  "Irreversible" line. Colour is never the only signal — the site's default
  palette is monochrome and a red-only cue disappears entirely there.
- A row is expandable when it has a value to show for its kind: before for
  update/delete, after for update/create. Expanding renders a two-column grid
  (auto-fit minmax so it stacks on narrow panels) with "Before" and "→ After"
  blocks. Collapsed rows are UNMOUNTED, not height-zeroed: a
  grid-rows-[0fr] collapse still lets Tab reach whatever is inside it.
- Long identifiers (ARNs, paths) use break-words, not break-all. break-all
  collapses the column's min-content width to a single character, so the label
  column volunteers to be squeezed even when there is room to spare.
- Empty list: the summary reads "Nothing to apply", the body explains itself, and
  confirm is blocked. It must never look like it applied something.

Behavior — the confirmation gate
- requiredPhrase = requireTyping (trimmed; an empty/whitespace string counts as
  not passed, otherwise the gate would be satisfied by typing nothing) OR, when
  the number of deletions exceeds warnThreshold, the literal word "delete".
  Comparison is trimmed but case-sensitive.
- Clamp warnThreshold: NaN falls back to the default (NaN comparisons are always
  false, which would silently disable the safety net), negatives clamp to 0
  (any deletion requires typing), Infinity is left alone as the legitimate way to
  say "never auto-require".
- Confirm is blocked while pending, while the list is empty, and while the phrase
  is unsatisfied. Blocked means aria-disabled + an early return in the handler,
  NEVER the native disabled attribute: a button that disables itself the moment
  it is pressed gets blurred by the browser, focus lands on <body>, and the user
  can no longer reach the Retry that appears seconds later. aria-disabled also
  keeps the button focusable and clickable, so a blocked click can do something
  useful — here it moves focus into the phrase field instead of dying silently.
- Submitting: setPending(true), then
  `await new Promise<void>(resolve => { resolve(onConfirm()) })`. Use the
  constructor, not Promise.resolve(onConfirm()): a synchronous throw inside
  onConfirm escapes before Promise.resolve ever sees it and the button stays
  pending forever, while the executor turns it into a rejection.
- After the await, re-check a mountedRef before touching state — the surrounding
  screen is often gone by the time a slow apply resolves. Set that ref to true in
  the effect BODY (not only false in cleanup), or StrictMode's
  mount → cleanup → mount leaves it false for a live instance and pending never
  clears.
- Success: request close via onOpenChange(false). Failure: stay open, keep the
  ENTIRE list and the typed phrase, render the message in a role="alert" block,
  and relabel confirm to retryLabel. Never report success you did not observe.
- While pending the dialog refuses every dismissal — Escape, backdrop, Cancel.
  The work is already in flight; closing would tell the user nothing happened.

Behavior — overlay mechanics
- Render through createPortal into document.body: a scrim (fixed inset-0, flex
  centering, overflow-y-auto) plus the panel. Portalling is what makes the dialog
  survive being mounted inside an overflow-hidden card, preview stage or
  scrollable region — an in-flow modal there is drawn inside the clip.
- Gate the portal on a client flag from
  useSyncExternalStore(subscribe, () => true, () => false) so SSR and the
  hydrating frame render nothing instead of touching document.
- Backdrop dismissal listens to pointerdown and compares
  event.target === event.currentTarget, so selecting text inside the panel and
  releasing over the scrim does not count as "clicked outside".
- Escape and the Tab trap live on the panel's own onKeyDown with
  stopPropagation, not on window: a window listener cannot tell which layer is
  on top, and one Escape would close this dialog and the drawer underneath it.
  aria-modal="true" claims the page behind is unreachable, so Tab must actually
  cycle between the first and last visible focusable inside the panel.
- Opening focuses the phrase input when there is one, otherwise Cancel — never
  the destructive confirm. Closing returns focus to whatever had it, guarded by
  isConnected: the action frequently unmounts the trigger, and focusing a
  detached node silently drops focus onto <body>.
- Body scroll lock while open, with its reentrancy count and pre-lock snapshot
  stored as data attributes on document.body (zyScrollLocks /
  zyScrollLockOverflow / zyScrollLockPadding), never in module-level variables:
  every component ships as its own copy, so two overlays with private counters
  cannot see each other and the second release writes back the "hidden" it
  recorded as the original — the page stays locked with nothing on screen to
  blame. Scrollbar compensation is MEASURED (read clientWidth, set
  overflow: hidden, read again, add the positive delta to paddingRight), because
  the innerWidth - clientWidth shortcut over-pads by ~15px on pages with
  scrollbar-gutter: stable and shifts the content left.

Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground / border /
  shadow-2xl for the panel, bg-background/80 + backdrop-blur-sm for the scrim,
  bg-muted for value blocks, text-destructive + border-destructive/50 +
  bg-destructive/5 for the delete group, ring-ring for focus rings.
- The destructive confirm is `bg-destructive text-background` — there is no
  --destructive-foreground token in this system, and hardcoding white gives ~2.3:1
  on the lighter red of the dark theme. text-background flips with the theme
  (measured 5.6:1 light, 7.3:1 dark).
- Layout is a three-part flex column with max-h-[85vh]: header (title +
  counts), `min-h-0 flex-1 overflow-y-auto` list, footer (phrase field, error,
  buttons). Only the middle scrolls, so a 30-row plan can never push the confirm
  button out of view — the single most important layout constraint here.
- Enter animation: scrim fade + panel fade/slide (150ms ease-out) shipped as one
  React 19 hoisted <style href precedence> tag, so N instances emit one rule set.
  motion-reduce:[animation:none] removes the motion; nothing depends on it.
- A permanently mounted role="status" span carries the pending / failure
  sentence. Inserting a live region only when it has content is the classic way
  to lose the first announcement.
- "use client": state, effects, portal, focus and DOM measurement.

Customization levers
- Kinds: KIND_ORDER + KIND_META are the whole vocabulary. Add "replace" or
  "no-op" by extending the union, the meta record (icon, badge word, summary
  wording) and the order array; nothing else branches on kind except the
  destructive styling test.
- Risk policy: warnThreshold (default 3) decides when typing becomes mandatory;
  AUTO_PHRASE ("delete") is what the user must copy — swap it for the resource
  name by passing requireTyping instead. Pass warnThreshold={Infinity} for a
  purely informational preview.
- Density: the panel is max-w-2xl / max-h-[85vh]; className overrides both.
  Drop `detail` from the data for a compact list, or render before/after as a
  single line instead of two blocks.
- Expansion: default everything expanded by seeding the expanded set from
  `changes`, or make it non-expandable by not passing before/after at all — the
  row silently renders as a static div instead of a button.
- Semantics: the same shell works for "apply / discard / dry-run" by swapping
  confirmLabel and adding a third footer button; keep Cancel first in DOM order
  so it is the first thing Shift+Tab reaches.

Concepts

  • Plan before apply — the dialog's job is not to ask "are you sure", it is to make the blast radius legible first: a count line you can read in one second, then a per-row list you can audit. The confirmation is the last step, not the whole interaction.
  • Grouped counts as the headline — the summary is the dialog's aria-describedby, so both the eye and the screen reader get "3 to create · 1 to update · 2 to delete" before anything else. It sits in the fixed header, above the scroll area, so it stays true no matter how far the list is scrolled.
  • Redundant coding for the destructive group — deletions are marked by an icon, a "Delete" word badge, a group border, a tinted panel and the word "Irreversible". Colour alone fails on a monochrome palette and for colour-vision deficiency, and this is exactly the row you cannot afford to have someone miss.
  • Expandable before → after — the row is the claim ("api-gateway will change"), the expansion is the evidence (cpu = 512cpu = 1024). Collapsed expansions are unmounted rather than height-collapsed, because a zero-height container is still reachable by Tab — an invisible keyboard trap.
  • Type-to-confirm as a threshold, not a mood — typing is demanded either explicitly (requireTyping="acme-prod") or automatically once the number of deletions passes warnThreshold. Tying the friction to the measured blast radius keeps the small cases fast and makes the big ones deliberate.
  • aria-disabled, not disabled — a button that becomes disabled the instant it is pressed is blurred by the browser, dropping focus on <body> and stranding the keyboard user before the Retry ever appears. aria-disabled plus an early return in the handler keeps the button focusable, keeps it clickable, and lets a blocked press do something useful — moving focus into the field that is blocking it.
  • Pending is uncloseable, failure is unclosed — while the request is in flight every dismissal path is refused, because closing would imply nothing happened. When it rejects, the dialog stays exactly where it was with the full list and the typed phrase intact, shows the real message in a role="alert", and relabels the primary button to Retry.

On This Page