v1.0

AnchoredPanel

Preview

A form that opens a form

Preview

Beside or below

Loading…

Preview

Code

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

src/components/overlay/AnchoredPanel.tsx

AI prompt

text
Build an anchored dialog panel component in React + TypeScript + Tailwind CSS. It opens beside the thing it edits and can open another panel beside itself (a form that opens a form).

## Look
- Portalled to <body>, `fixed z-[200] flex flex-col overflow-hidden` on the opaque floating surface with `shadow-2xl` and no padding. Fades in (opacity 0 → 1, 200ms ease-out). Width = `min(width, innerWidth - 16)`.
- Header: `min-h-[2.75rem] shrink-0 flex items-center gap-2 border-b border-slate-200 dark:border-slate-700 px-3 py-2`. Title: text-xs font-semibold leading-4 slate-900 / dark slate-100, truncating. Optional subtitle: 11px slate-500 / dark slate-400, truncating. Then an optional `titleHint` slot (an ⓘ tooltip), then the close button: `rounded p-1`, 14px X, `text-slate-400 hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-700 dark:hover:text-slate-200`.
- Body: `min-h-0 flex-1 overflow-y-auto`, with no padding (the caller pads). Footer: `shrink-0 border-t border-slate-200 dark:border-slate-700 px-3 py-2`. Only the body scrolls, so Save and close stay reachable.
- On a phone (`max-width: 639px`, read with matchMedia after mount to avoid a hydration mismatch) it becomes a bottom sheet: `fixed inset-x-0 bottom-0 w-full max-h-[85vh] rounded-t-xl border-t border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-800`.

## Placement
- `anchor` is a viewport rect `{ top, left, right, bottom }` the caller measures on click (export `anchorOf(el)`). A null anchor centres the panel horizontally with its top at `vh / 4`.
- Horizontal candidates, tried in order:
  - `beside` (default): `anchor.right + gap`, then `anchor.left - width - gap`.
  - `below`: `anchor.left` (left edges aligned), then `anchor.right - width` (right edges aligned).
- A candidate fits when it is inside the window with an 8px margin AND horizontally clear of every other open panel or menu (`[data-overlay="panel"]`, `[data-overlay="menu"]`, excluding itself). Take the first that fits both, else the first inside the window, else `vw - width - 8`. A cascade therefore keeps going outward instead of flipping back over the panel the reader started from.
- Vertical: top-aligned to `anchor.top` (`below`: `anchor.bottom + gap`) and pushed up only as far as it must be to fit: `y = clamp(wanted, 8, vh - height - 8)`. Set `maxHeight = vh - y - 8` so the footer is never off-screen. Don't centre vertically: the panel would jump as its height changes.
- Place in a layout effect, before paint. Until then render at `top/left: -9999` (never 0,0, which flashes in the corner). Re-place on resize.
- Export `closestPanelRect(node)`: the rect of the nearest `[data-overlay="panel"]` ancestor (or the node itself). Anchor a child panel with it. Measuring the button inside the outer panel gives a box inset by that panel's padding, and every `beside` candidate would land on top of the outer panel.

## Behaviour
- The root carries `data-overlay="panel"`.
- Outside click: a mousedown whose `composedPath()` does not include this panel AND whose target is not inside any `[data-overlay]` element. A click in a child panel does not close its parent, a click in the parent does not close the child, and a click outside both closes both.
- Escape closes ONLY the topmost panel, which is the last `[data-overlay="panel"]` in the document (portals mount in opening order). An open `[data-overlay="picker"]` takes Escape first.

## API
- `open`, `anchor: Anchor | null`, `onClose`, `title: string`, `subtitle?`, `titleHint?: ReactNode`, `footer?: ReactNode`, `children`
- `width?` = 340, `placement?: 'beside' | 'below'` = `'beside'`, `gap?` = 8 (8 reads as two surfaces, 3 as a submenu)

## Accessibility
- `role="dialog" aria-label={title}`; close `aria-label="Close"`.

## Demo
An "Edit field" button opens the "Edit field" panel (subtitle "Status · single select"). It holds a Label input, option pills (To do, In progress, In review, Done), a "Manage options…" button, and a Cancel / Save footer. "Manage options…" opens a 300px "Options" panel ("4 choices") anchored with `closestPanelRect`. That panel lists the options with × remove buttons and has a "New option" input with an Add button.

## 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';

/* Origin: ticket-management (96S2) `components/ui/AnchoredPanel.tsx`. */

import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';

export type Anchor = { top: number; left: number; right: number; bottom: number };

/** An element's viewport rect as an `Anchor` — what a caller measures on click. */
export function anchorOf(node: Element | null | undefined): Anchor | null {
  const r = node?.getBoundingClientRect();
  return r ? { top: r.top, left: r.left, right: r.right, bottom: r.bottom } : null;
}

/**
 * The rect of the PANEL a node sits inside, not the node's own — for a panel
 * opened from INSIDE another panel.
 *
 * Anchoring the second panel to the button that opened it measures a box
 * inset by the outer panel's own padding and border, tens of pixels short of
 * its REAL edges. Every `beside` candidate computed from that inset anchor
 * lands INSIDE the outer panel, so the collision check can never find a
 * candidate that clears it — an overlap, not a cascade. Climbing to the
 * nearest `[data-overlay="panel"]` ancestor and measuring THAT gives the
 * outer panel's real edges. A node with no such ancestor measures itself.
 */
export function closestPanelRect(node: HTMLElement | null): Anchor | null {
  return anchorOf(node?.closest<HTMLElement>('[data-overlay="panel"]') ?? node);
}

/** Below Tailwind's `sm` — a phone, where a card beside an anchor has nowhere to be. */
const PHONE_QUERY = '(max-width: 639px)';

/**
 * A form that opens BESIDE what it belongs to, rather than in a drawer at the
 * edge of the window — and that can open ANOTHER one beside itself.
 *
 * ── Why not the Drawer or the Modal ─────────────────────────────────────────
 *
 * The drawer is the right shape for a record: it is the subject of the screen
 * while it is open. Editing one field is not that. It is a follow-up to a
 * click in a small popup that sits mid-screen, and answering it 900px away —
 * past everything the reader was just looking at — makes a two-field edit
 * feel like leaving the page. This opens where the click was.
 *
 * ── Nesting ─────────────────────────────────────────────────────────────────
 *
 * Panels CASCADE: a columns panel opens a field editor beside it, the editor
 * opens an option manager beside itself. Three rules make that work, and they
 * are the whole reason this is not `Modal` with a different width:
 *
 *   1. PLACEMENT avoids every other open panel. A side that overlaps any
 *      `[data-overlay="panel"]` counts as not fitting, so the chain keeps going
 *      OUTWARD — a third panel that flipped back over the first, because that
 *      side was "free" as far as the window edge was concerned, would hide the
 *      thing the reader started from.
 *   2. AN OUTSIDE CLICK is one that lands in no `[data-overlay]` at all. A
 *      click in a child panel is not "outside" its parent, and a click in the
 *      parent (toggling something while the child is open) does not close the
 *      child either. This is why it does not use `useDismiss`, whose notion of
 *      inside is a fixed set of refs.
 *   3. ONE ESCAPE, ONE PANEL — the topmost. Portals mount in opening order, so
 *      the last `[data-overlay="panel"]` in the document is the one on top;
 *      only that one answers the key. A `[data-overlay="picker"]` (a dropdown
 *      that wants Escape for itself) takes it first.
 *
 * Vertically it is TOP-ALIGNED to the anchor and pushed up only as far as it
 * must be to fit — a form pinned to the middle of the screen would jump on
 * every open as its own height changed.
 *
 * Portalled at the overlay band (`z-[200]`, see `globals.css`), like the
 * other surfaces that escape their table shell. On a PHONE it is a bottom
 * sheet.
 */
export default function AnchoredPanel({
  open,
  anchor,
  onClose,
  title,
  subtitle,
  titleHint,
  width: panelWidth = 340,
  placement = 'beside',
  gap = 8,
  footer,
  children,
}: {
  open: boolean;
  /** Where the thing being edited is, in viewport coordinates. Null centres the panel. */
  anchor: Anchor | null;
  onClose: () => void;
  title: string;
  subtitle?: string;
  /** An `InfoTooltip` beside the title, for a hint about the form itself. */
  titleHint?: React.ReactNode;
  width?: number;
  /**
   * `beside` (default): right of the anchor, else left, top-aligned — a form
   * hanging off a panel. `below`: under the anchor like a dropdown, left edges
   * aligned when that fits, else right edges — a form opened from a column
   * header, which should read as belonging to that column.
   */
  placement?: 'beside' | 'below';
  /** Pixels between the anchor and the panel. 8 reads as two surfaces; 3 reads as a submenu. */
  gap?: number;
  /** A pinned row under the scrolling body — Cancel/Save, typically. */
  footer?: React.ReactNode;
  children: React.ReactNode;
}) {
  const box = useRef<HTMLDivElement>(null);
  const [pos, setPos] = useState<{ top: number; left: number; width: number; maxHeight: number } | null>(null);
  const [phone, setPhone] = useState(false);

  // False on the server and the first client render, then the real answer —
  // seeding from `matchMedia` during render would be a hydration mismatch.
  useEffect(() => {
    const m = window.matchMedia(PHONE_QUERY);
    const sync = () => setPhone(m.matches);
    sync();
    m.addEventListener('change', sync);
    return () => m.removeEventListener('change', sync);
  }, []);

  // Measured AFTER layout and before paint, so the panel is never visible in
  // the wrong place.
  useLayoutEffect(() => {
    if (!open || phone) return setPos(null);

    const place = () => {
      const EDGE = 8;
      const vw = window.innerWidth;
      const vh = window.innerHeight;
      const width = Math.min(panelWidth, vw - EDGE * 2);

      // A null anchor means the caller could not measure; centring beats a
      // form that refuses to open.
      const a = anchor ?? { top: vh / 4, left: vw / 2 - width / 2, right: vw / 2 - width / 2, bottom: vh / 4 };

      const [first, second] = placement === 'below' ? [a.left, a.right - width] : [a.right + gap, a.left - width - gap];
      const inWindow = (x: number) => x >= EDGE && x + width <= vw - EDGE;
      const others = Array.from(document.querySelectorAll<HTMLElement>('[data-overlay="panel"], [data-overlay="menu"]')).filter(
        (el) => el !== box.current,
      );
      const clear = (x: number) =>
        others.every((el) => {
          const r = el.getBoundingClientRect();
          return x + width <= r.left || x >= r.right;
        });
      const x = [first, second].find((c) => inWindow(c) && clear(c)) ?? [first, second].find(inWindow) ?? Math.max(EDGE, vw - width - EDGE);

      // `maxHeight` is the room BELOW where the panel actually lands, not the
      // window's height — a form two thirds of the way down must not run off
      // the bottom with its buttons unreachable.
      const room = vh - EDGE * 2;
      const height = Math.min(box.current?.offsetHeight || room, room);
      const wanted = placement === 'below' ? a.bottom + gap : a.top;
      const y = Math.max(EDGE, Math.min(wanted, vh - height - EDGE));

      setPos({ top: y, left: x, width, maxHeight: vh - y - EDGE });
    };

    place();
    window.addEventListener('resize', place);
    return () => window.removeEventListener('resize', place);
  }, [open, anchor, panelWidth, placement, gap, phone]);

  useEffect(() => {
    if (!open) return;
    const onDown = (e: MouseEvent) => {
      // `composedPath`, not `contains`: this panel may hold portalled
      // overlays of its own.
      const path = e.composedPath();
      if (box.current && path.includes(box.current)) return;
      if ((e.target as HTMLElement | null)?.closest?.('[data-overlay]')) return;
      onClose();
    };
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== 'Escape') return;
      if (document.querySelector('[data-overlay="picker"]')) return;
      const panels = document.querySelectorAll('[data-overlay="panel"]');
      if (panels.length && panels[panels.length - 1] !== box.current) return;
      onClose();
    };
    document.addEventListener('mousedown', onDown);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDown);
      document.removeEventListener('keydown', onKey);
    };
  }, [open, onClose]);

  if (!open || typeof document === 'undefined') return null;

  return createPortal(
    <div
      ref={box}
      role="dialog"
      aria-label={title}
      data-overlay="panel"
      style={
        phone
          ? undefined
          : {
              // Off-screen for the first frame, when `pos` is still null —
              // never at 0,0, which reads as a flash in the corner.
              top: pos?.top ?? -9999,
              left: pos?.left ?? -9999,
              width: pos?.width ?? panelWidth,
              maxHeight: pos?.maxHeight,
            }
      }
      className={
        phone
          ? 'fixed inset-x-0 bottom-0 z-[200] flex max-h-[85vh] w-full flex-col overflow-hidden rounded-t-xl border-t border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-800'
          : 'fixed z-[200] flex flex-col overflow-hidden panel panel-solid p-0 shadow-2xl animate-fade-in'
      }
    >
      <div className="flex min-h-[2.75rem] shrink-0 items-center gap-2 border-b border-slate-200 px-3 py-2 dark:border-slate-700">
        <div className="min-w-0 flex-1">
          <h2 className="truncate text-xs font-semibold leading-4 text-slate-900 dark:text-slate-100">{title}</h2>
          {subtitle && <p className="truncate text-[11px] text-slate-500 dark:text-slate-400">{subtitle}</p>}
        </div>
        {titleHint && <span className="flex shrink-0 items-center">{titleHint}</span>}
        <button
          type="button"
          onClick={onClose}
          aria-label="Close"
          className="shrink-0 rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-700 dark:hover:text-slate-200"
        >
          <X className="h-3.5 w-3.5" />
        </button>
      </div>

      {/* The body scrolls, the header and footer do not — a long list must
          not push the close button or the Save button off the screen. */}
      <div className="min-h-0 flex-1 overflow-y-auto">{children}</div>

      {footer && <div className="shrink-0 border-t border-slate-200 px-3 py-2 dark:border-slate-700">{footer}</div>}
    </div>,
    document.body,
  );
}

Props

PropTypeDefaultDescription
open*boolean—
anchor*Anchor | null—Where the thing being edited is, in viewport coordinates. Null centres the panel.
onClose*() => void—
title*string—
children*React.ReactNode—
subtitlestring—
titleHintReact.ReactNode—An `InfoTooltip` beside the title, for a hint about the form itself.
widthnumber340
placement'beside' | 'below''beside'`beside` (default): right of the anchor, else left, top-aligned — a form hanging off a panel. `below`: under the anchor like a dropdown, left edges aligned when that fits, else right edges — a form opened from a column header, which should read as belonging to that column.
gapnumber8Pixels between the anchor and the panel. 8 reads as two surfaces; 3 reads as a submenu.
footerReact.ReactNode—A pinned row under the scrolling body — Cancel/Save, typically.