v1.0

ConfirmPopover

Preview

Basic

Loading…

Preview

Code

ts
import ConfirmPopover from '@/components/overlay/ConfirmPopover';

src/components/overlay/ConfirmPopover.tsx

AI prompt

text
Build an inline confirm popover component, anchored to its trigger, in React + TypeScript + Tailwind CSS.

## Look
- Trigger: root `relative inline-flex` around a span `inline-flex w-full items-center justify-center cursor-pointer` holding `children`. Use flex, not inline-block, so an icon-button trigger lines up with its flex-centred neighbours instead of sitting on a text baseline.
- Panel: portalled to <body>, `fixed z-[200]`, 256px wide (`w-64`), `p-3 text-left`, on the frosted card surface.
- Content row `flex items-start gap-2`: a 16px lucide `TriangleAlert` (`shrink-0 mt-0.5`; rose-500 when destructive, indigo-500 otherwise); then the title (text-xs font-semibold slate-800 / dark slate-100) and the description (`mt-0.5` 11px slate-500 / dark slate-400).
- Buttons `mt-3 flex justify-end gap-2`: a ghost Cancel and a primary Confirm. Destructive Confirm is `bg-rose-600 hover:bg-rose-700`.

## Behaviour
- A click on the trigger toggles the panel.
- Placement: right-aligned to the trigger (`left = rect.right - 256`), clamped to `[8, vw - 256 - 8]`. Below the trigger with a 4px gap, unless that would pass `innerHeight - 8` (a Delete at the foot of a form); then it opens above (`rect.top - 4 - height`, min 8). Estimate the height at 120px on the first placement, then re-place on the next animation frame with the measured height, so the flip decision is real. Re-place on resize and on scroll (capture phase: triggers live inside table scrollers).
- Outside click: the panel is portalled, so both the trigger root AND the panel count as inside. Otherwise the click on Confirm would close the panel before Confirm runs.
- Confirm has `autoFocus`; it closes the panel, then calls `onConfirm`. Cancel only closes.
- Set `data-overlay="popover"` on the panel. A dialog or drawer containing the trigger then treats clicks in it as inside and leaves Escape to it.

## API
- `onConfirm(): void`, `children` (the trigger)
- `title?` = "Are you sure?", `description?` = "This cannot be undone.", `confirmText?` = "Confirm", `cancelText?` = "Cancel"
- `variant?: 'destructive' | 'default'` = `'destructive'`. `default` is for a confirm that is significant rather than irreversible (publish, release); a rose button would miscolour it.

## Accessibility
- Panel `role="dialog" aria-modal="false" aria-label={title}`.

## Demo
A small ghost "Remove" trigger in rose text ("Remove this member?" / "They lose access immediately." / Remove). Next to it, a default-variant "Publish" trigger ("Publish these changes?" / "Everyone in the workspace will see them." / Publish / "Not yet"), and a counter reading "confirmed N×".

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { TriangleAlert } from 'lucide-react';
import { useDismiss } from '@/lib/use-dismiss';

/**
 * A confirm step that opens next to its trigger instead of taking the whole
 * screen. Used for destructive row actions, where a full modal is heavier than
 * the decision warrants.
 *
 * PORTALLED and `position: fixed`, not absolute. Its triggers sit inside table
 * shells that are `overflow-hidden` (to keep a panel's rounded corners) or
 * `overflow-x-auto` (to scroll a wide table) — and CSS computes the other axis
 * to `auto` once one is not `visible`. An absolutely-positioned panel therefore
 * OPENED BUT WAS CLIPPED, which reads exactly like a broken button.
 *
 * Two refs, not one: with the panel in a portal it is no longer a DOM
 * descendant of the trigger, so an outside-click check against the trigger
 * alone would close the panel on the very click that hit Confirm.
 *
 * MERGED from two copies. bonus-adjustment's is the base — it is the one that
 * portals, so it cannot be clipped by the `overflow-hidden` table shell its
 * triggers live in. `variant` and `cancelText` came across from
 * marketing-stats: this confirm is not always destructive (publishing, or
 * releasing a batch, is a confirm too), and a rose Confirm button on a
 * non-destructive action miscolours the decision.
 */
const PANEL_WIDTH = 256; // w-64
const EDGE = 8;
const GAP = 4;

export default function ConfirmPopover({
  onConfirm,
  children,
  title = 'Are you sure?',
  description = 'This cannot be undone.',
  confirmText = 'Confirm',
  cancelText = 'Cancel',
  variant = 'destructive',
}: {
  onConfirm: () => void;
  children: React.ReactNode;
  title?: string;
  description?: string;
  confirmText?: string;
  cancelText?: string;
  /**
   * Tones the warning icon and the Confirm button. Defaults to `destructive`,
   * which is what most row actions behind this are — `default` is for a confirm
   * that is merely significant rather than irreversible.
   */
  variant?: 'destructive' | 'default';
}) {
  const [open, setOpen] = useState(false);
  const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
  const triggerRef = useRef<HTMLDivElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);

  const place = useCallback(() => {
    const el = triggerRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    // Right-aligned to the trigger, then clamped so a button near either edge
    // still shows the whole panel.
    const left = Math.min(
      Math.max(r.right - PANEL_WIDTH, EDGE),
      Math.max(EDGE, window.innerWidth - PANEL_WIDTH - EDGE),
    );
    // Below the trigger, unless that runs off the bottom of the window — a
    // Delete at the foot of a form panel is exactly there — in which case it
    // opens above. The height is estimated before the panel has mounted and
    // measured on the re-place right after, so the flip decision is real.
    const height = panelRef.current?.offsetHeight ?? 120;
    const below = r.bottom + GAP;
    const top = below + height <= window.innerHeight - EDGE ? below : Math.max(EDGE, r.top - GAP - height);
    setPos({ top, left });
  }, []);

  useLayoutEffect(() => {
    if (!open) return;
    place();
    // Once more after the panel has mounted, with its measured height.
    const frame = requestAnimationFrame(place);
    // `true` for capture: a scroll inside the table's own scroll box does not
    // bubble, and that is exactly the container these triggers live in.
    window.addEventListener('scroll', place, true);
    window.addEventListener('resize', place);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', place, true);
      window.removeEventListener('resize', place);
    };
  }, [open, place]);

  // The panel is portalled, so it is not a descendant of the trigger: both roots count as inside.
  useDismiss([triggerRef, panelRef], open, () => setOpen(false));

  // `inline-flex`, not `inline-block`, on BOTH the root and the trigger.
  //
  // Inline-block put the child button on a text baseline, so a trash icon in a
  // row of icon buttons sat a couple of pixels lower than its neighbours — the
  // others being flex-centred. Flex on both makes the trigger fill the root and
  // centre whatever it wraps, so a caller's icon lines up with any sibling.
  return (
    <div className="relative inline-flex" ref={triggerRef}>
      <span
        onClick={() => setOpen((o) => !o)}
        className="inline-flex w-full items-center justify-center cursor-pointer"
      >
        {children}
      </span>

      {open &&
        pos &&
        typeof document !== 'undefined' &&
        createPortal(
          <div
            ref={panelRef}
            role="dialog"
            aria-modal="false"
            aria-label={title}
            // An AnchoredPanel treats a click in any `[data-overlay]` as inside,
            // so confirming from within one does not close the form behind it.
            data-overlay="popover"
            style={{ top: pos.top, left: pos.left, width: PANEL_WIDTH }}
            className="fixed z-[200] panel p-3 text-left"
          >
            <div className="flex items-start gap-2">
              <TriangleAlert
                className={
                  variant === 'destructive'
                    ? 'w-4 h-4 text-rose-500 shrink-0 mt-0.5'
                    : 'w-4 h-4 text-indigo-500 shrink-0 mt-0.5'
                }
              />
              <div>
                <h3 className="text-xs font-semibold text-slate-800 dark:text-slate-100">{title}</h3>
                <p className="mt-0.5 text-[11px] text-slate-500 dark:text-slate-400">
                  {description}
                </p>
              </div>
            </div>
            <div className="mt-3 flex justify-end gap-2">
              <button type="button" onClick={() => setOpen(false)} className="btn-ghost">
                {cancelText}
              </button>
              <button
                type="button"
                autoFocus
                onClick={() => {
                  setOpen(false);
                  onConfirm();
                }}
                className={
                  variant === 'destructive'
                    ? 'btn-primary bg-rose-600 hover:bg-rose-700'
                    : 'btn-primary'
                }
              >
                {confirmText}
              </button>
            </div>
          </div>,
          document.body,
        )}
    </div>
  );
}

Props

PropTypeDefaultDescription
onConfirm*() => void—
children*React.ReactNode—
titlestring'Are you sure?'
descriptionstring'This cannot be undone.'
confirmTextstring'Confirm'
cancelTextstring'Cancel'
variant'destructive' | 'default''destructive'Tones the warning icon and the Confirm button. Defaults to `destructive`, which is what most row actions behind this are — `default` is for a confirm that is merely significant rather than irreversible.