← All components

AlertDialog

A dialog that asks before something that cannot be undone, and cannot be waved away.

A press outside does nothing; Escape keeps it.

The idea

A press on the scrim does nothing, because a decision this size should not be made by missing the panel; Escape still works, and means Cancel. Focus starts on Cancel, the safe answer, so a reflexive Enter does no harm. The confirm button is the one ink fill and carries the consequence in its own words — there is no red, because what makes it safe is the words and the question in front of them. Give it a promise and the button spins in place until the work is done.

Composes Dialog and Button and Spinner.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

AlertDialog — usage12 lines
import { AlertDialog } from "mihirmodi-ui";

// The button says the consequence. Return a promise and it spins in
// place until the work settles; reject and the dialog stays open.
<AlertDialog
  open={open}
  onOpenChange={setOpen}
  title="Delete atlas-web?"
  description="Its deployments and domains go with it. This can’t be undone."
  confirmLabel="Delete project"
  onConfirm={() => api.deleteProject("atlas-web")}
/>

Props

PropNotes
open / onOpenChangeboolean / (open) => voidControlled.
title / descriptionReactNodeThe question, and what will happen, plainly.
confirmLabelstringThe consequence: “Delete project”, not “OK”.
cancelLabelstringFocused on open.
onConfirm() => void | PromiseReturn a promise to hold the dialog with the button loading. Reject to keep it open.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/overlays/AlertDialog.tsx86 lines
"use client";

import { useRef, useState, type ReactNode } from "react";
import { Button } from "../actions/Button.js";
import { focusQuietly, ModalLayer } from "./layer.js";
import { DialogSurface } from "./Dialog.js";

export type AlertDialogProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  title: ReactNode;
  /** What will happen, plainly. */
  description?: ReactNode;
  children?: ReactNode;
  /** The consequence, in the button's own words: "Delete 3 files", not "OK". */
  confirmLabel: string;
  cancelLabel?: string;
  /**
   * Return a promise to keep the dialog up with the button spinning until it
   * settles. Resolve and it closes; reject and it stays, for you to say why.
   */
  onConfirm: () => void | Promise<unknown>;
  width?: number;
};

/**
 * A dialog that asks before something that cannot be undone.
 *
 * It cannot be waved away. A press on the scrim does nothing, because a
 * decision this size should not be made by missing the panel; Escape still
 * works, and means Cancel. Focus starts on Cancel, the safe answer, so a
 * reflexive Enter does no harm. The confirm button is the one ink fill and
 * says the consequence in its words — there is no red here: what makes it
 * safe is the words and the question in front of them, not a colour.
 *
 * With an async `onConfirm` the button keeps its width and turns its mark into
 * a spinner while the work runs, and the dialog will not close under it.
 */
export function AlertDialog({ open, onOpenChange, title, description, children, confirmLabel, cancelLabel = "Cancel", onConfirm, width = 400 }: AlertDialogProps) {
  const [pending, setPending] = useState(false);
  const cancel = useRef<HTMLButtonElement>(null);
  const confirm = useRef<HTMLButtonElement>(null);

  const run = async () => {
    const result = onConfirm();
    if (!result || typeof (result as Promise<unknown>).then !== "function") {
      onOpenChange(false);
      return;
    }
    setPending(true);
    // The buttons are about to be disabled; keep focus inside rather than let it fall to the page.
    focusQuietly(cancel.current?.closest("[data-modal-panel]"));
    try {
      await result;
      setPending(false);
      onOpenChange(false);
    } catch {
      setPending(false);
      requestAnimationFrame(() => focusQuietly(confirm.current));
    }
  };

  return (
    <ModalLayer open={open} onDismiss={() => !pending && onOpenChange(false)} backdrop={false} escape={!pending} initialFocus={() => cancel.current}>
      <DialogSurface
        role="alertdialog"
        title={title}
        description={description}
        width={width}
        busy={pending}
        footer={
          <>
            <Button ref={cancel} variant="secondary" disabled={pending} onClick={() => onOpenChange(false)}>
              {cancelLabel}
            </Button>
            <Button ref={confirm} variant="primary" loading={pending} onClick={run}>
              {confirmLabel}
            </Button>
          </>
        }
      >
        {children}
      </DialogSurface>
    </ModalLayer>
  );
}
packages/ui/src/components/overlays/Dialog.tsx116 lines
"use client";

import { useId, type ReactNode, type RefObject } from "react";
import { m, useReducedMotion } from "motion/react";
import { CloseIcon } from "../../lib/glyph.js";
import { CLOSE, EASE_OUT_EXPO, FLAT, LIFT } from "../../lib/motion.js";
import { ModalLayer } from "./layer.js";

export type DialogProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  /** Names the dialog for assistive tech as well as on screen. */
  title: ReactNode;
  /** A line under the title — what this is for. Read out after the title. */
  description?: ReactNode;
  children?: ReactNode;
  /** Buttons, right-aligned along the foot. */
  footer?: ReactNode;
  /** Most it grows to, in px. */
  width?: number;
  /** The close mark in the corner. */
  closeButton?: boolean;
  /** What takes focus on open. Default: the first control, skipping the corner close. */
  initialFocus?: RefObject<HTMLElement | null>;
};

type SurfaceProps = {
  role: "dialog" | "alertdialog";
  title: ReactNode;
  description?: ReactNode;
  children?: ReactNode;
  footer?: ReactNode;
  width: number;
  onClose?: () => void;
  busy?: boolean;
};

/** The panel both dialogs stand on. */
export function DialogSurface({ role, title, description, children, footer, width, onClose, busy }: SurfaceProps) {
  const reduce = useReducedMotion();
  const titleId = useId();
  const descId = useId();
  return (
    <m.div
      data-modal-panel
      role={role}
      aria-modal="true"
      aria-labelledby={titleId}
      aria-describedby={description ? descId : undefined}
      aria-busy={busy || undefined}
      tabIndex={-1}
      // The focus holder of last resort, not a control: no mark of its own.
      className="w-full outline-none"
      style={{ maxWidth: width }}
      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 8 }}
      animate={{ opacity: 1, scale: 1, y: 0 }}
      exit={reduce ? { opacity: 0, transition: CLOSE } : { opacity: 0, scale: 0.98, transition: CLOSE }}
      transition={reduce ? FLAT : { scale: LIFT, y: LIFT, opacity: { duration: 0.2, ease: EASE_OUT_EXPO } }}
    >
      {/* The glass holds still inside the element that moves, or the blur is dropped. */}
      <div className="glass-panel dock-shadow flex max-h-[calc(100dvh-32px)] flex-col overflow-hidden rounded-2xl border-[0.5px] border-gray-alpha-400 pb-5">
        <div className="flex shrink-0 items-start gap-3 px-5 pt-5">
          <div className="min-w-0 flex-1">
            <h2 id={titleId} className="text-[15px] font-medium leading-[1.4] text-gray-1000">
              {title}
            </h2>
            {description && (
              <p id={descId} className="mt-1 text-[13px] leading-[1.55] text-muted">
                {description}
              </p>
            )}
          </div>
          {onClose && (
            <button
              type="button"
              data-dialog-close
              onClick={onClose}
              aria-label="Close"
              className="-mr-2 -mt-1.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] text-quiet transition-colors duration-150 ease-out hover:bg-gray-alpha-200 hover:text-gray-1000 motion-reduce:transition-none"
            >
              <CloseIcon />
            </button>
          )}
        </div>
        {children && <div className="min-h-0 flex-1 overflow-y-auto px-5 pt-4 text-[14px] leading-[1.55] text-gray-1000">{children}</div>}
        {footer && <div className="flex shrink-0 flex-wrap justify-end gap-2 px-5 pt-5">{footer}</div>}
      </div>
    </m.div>
  );
}

/**
 * A panel in the middle of the screen that asks for one thing and holds the
 * page still until it gets it.
 *
 * It arrives with a small rise and a scale from 96%, on the spring the other
 * arrivals use, so it reads as lifted out of the page rather than dropped on
 * it; it leaves fast and flat, because a dismissal is not an event. The scrim
 * dims and very lightly blurs the page, enough to say "not now" about
 * everything behind it without hiding where you were.
 *
 * Focus moves in on open — to the first control, not the close mark in the
 * corner — and stays in: Tab cycles inside, Escape or a press on the scrim
 * closes, and focus goes back to whatever opened it. The title names the
 * dialog for a screen reader and the description is read after it.
 */
export function Dialog({ open, onOpenChange, title, description, children, footer, width = 440, closeButton = true, initialFocus }: DialogProps) {
  const close = () => onOpenChange(false);
  return (
    <ModalLayer open={open} onDismiss={close} initialFocus={initialFocus ? () => initialFocus.current : undefined}>
      <DialogSurface role="dialog" title={title} description={description} footer={footer} width={width} onClose={closeButton ? close : undefined}>
        {children}
      </DialogSurface>
    </ModalLayer>
  );
}
packages/ui/src/components/overlays/layer.tsx543 lines
"use client";

import {
  Fragment,
  cloneElement,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  version,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactElement,
  type ReactNode,
  type RefObject,
} from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, LazyMotion, domAnimation, m } from "motion/react";
import { DropMenu } from "../DropMenu.js";
import { CLOSE } from "../../lib/motion.js";

/*
 * The one layer every overlay in this directory stands on: a portal, a scroll
 * lock, a focus trap that gives focus back, Escape, a press outside, and a
 * position anchored to a trigger that flips when there is no room. Nothing here
 * is exported from the package — the components are the API; this is how they
 * agree with each other when one opens inside another.
 */

/** Layout effects warn on the server in React 18, and there is nothing to measure there anyway. */
export const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;

/* ── Portal ───────────────────────────────────────────────────────────────── */

export type PortalProps = {
  children: ReactNode;
  /** Re-read the ink each time this turns true. */
  open: boolean;
  /**
   * Where the layer was opened from. A host can recolour `--color-ink` for one
   * region only (the docs site does it per stage); a layer moved to the body
   * would lose that, so it copies the opener's ink onto its own root.
   */
  inkFrom?: () => Element | null | undefined;
};

/**
 * Renders into a root of its own at the end of `<body>`, so a layer escapes any
 * `overflow: hidden`, transform or stacking context its trigger sits in. Nothing
 * is rendered until the client has mounted, so server and client markup agree.
 */
export function Portal({ children, open, inkFrom }: PortalProps) {
  const [host, setHost] = useState<HTMLDivElement | null>(null);
  const from = useRef(inkFrom);
  from.current = inkFrom;

  useEffect(() => {
    const el = document.createElement("div");
    el.setAttribute("data-overlay-root", "");
    document.body.appendChild(el);
    setHost(el);
    return () => {
      el.remove();
      setHost(null);
    };
  }, []);

  useIsoLayoutEffect(() => {
    if (!host || !open) return;
    const source = from.current?.();
    const ink = source ? getComputedStyle(source).getPropertyValue("--color-ink").trim() : "";
    const page = getComputedStyle(document.documentElement).getPropertyValue("--color-ink").trim();
    if (ink && ink !== page) host.style.setProperty("--color-ink", ink);
    else host.style.removeProperty("--color-ink");
  }, [host, open]);

  return host ? createPortal(children, host) : null;
}

/* ── Scroll lock ──────────────────────────────────────────────────────────── */

let locks = 0;
let unlock: (() => void) | null = null;

/**
 * Stops the page scrolling under a modal. Counted, so a dialog opened from a
 * drawer does not release the lock when it closes. The scrollbar's gutter is
 * kept rather than padded back in, so neither the page nor anything fixed to the
 * viewport shifts sideways when the bar disappears.
 */
export function useScrollLock(on: boolean) {
  useEffect(() => {
    if (!on) return;
    if (locks++ === 0) {
      const html = document.documentElement;
      const before = { overflow: html.style.overflow, gutter: html.style.scrollbarGutter };
      const hasBar = window.innerWidth - html.clientWidth > 0;
      html.style.overflow = "hidden";
      if (hasBar) html.style.scrollbarGutter = "stable";
      unlock = () => {
        html.style.overflow = before.overflow;
        html.style.scrollbarGutter = before.gutter;
      };
    }
    return () => {
      if (--locks === 0) {
        unlock?.();
        unlock = null;
      }
    };
  }, [on]);
}

/* ── Focus ────────────────────────────────────────────────────────────────── */

const TABBABLE =
  'a[href],area[href],button:not([disabled]),input:not([disabled]):not([type="hidden"]),select:not([disabled]),textarea:not([disabled]),iframe,audio[controls],video[controls],[contenteditable]:not([contenteditable="false"]),[tabindex]';

/** What Tab would visit inside `root`, in order. */
export function tabbables(root: Element): HTMLElement[] {
  return Array.from(root.querySelectorAll<HTMLElement>(TABBABLE)).filter(
    (el) => el.tabIndex >= 0 && !el.closest("[inert]") && el.getClientRects().length > 0
  );
}

/** Keep Tab inside `root`: off the last element wraps to the first, and back. */
export function trapTab(e: ReactKeyboardEvent, root: HTMLElement | null) {
  if (e.key !== "Tab" || !root || !root.contains(e.target as Node)) return;
  const list = tabbables(root);
  if (list.length === 0) {
    e.preventDefault();
    return;
  }
  // From the panel itself (not in the list), Tab goes to the first control and
  // Shift+Tab to the last, rather than out to the page behind.
  const i = list.indexOf(document.activeElement as HTMLElement);
  if (e.shiftKey && i <= 0) {
    e.preventDefault();
    list[list.length - 1].focus();
  } else if (!e.shiftKey && (i === -1 || i === list.length - 1)) {
    e.preventDefault();
    list[0].focus();
  }
}

/** Focus something without the page jumping to it. */
export function focusQuietly(el: Element | null | undefined) {
  if (el && "focus" in el && (el as HTMLElement).isConnected) (el as HTMLElement).focus({ preventScroll: true });
}

/** The tabbable that comes after `el` in the page, skipping every overlay root. */
export function nextInPage(el: Element | null, dir: 1 | -1 = 1): HTMLElement | null {
  if (!el) return null;
  const list = tabbables(document.body).filter((n) => !n.closest("[data-overlay-root]"));
  const i = list.indexOf(el as HTMLElement);
  return i < 0 ? null : (list[i + dir] ?? null);
}

/* ── Pressing outside ─────────────────────────────────────────────────────── */

type Layer = { nodes: () => (Element | null | undefined)[] };
const stack: Layer[] = [];

/**
 * Registers an open layer, and calls `onOutside` for a press that lands outside
 * it. A press inside any layer opened *after* this one — a submenu, a popover
 * opened from inside a popover — counts as inside, since those belong to it even
 * though they live elsewhere in the DOM.
 *
 * It listens in the capture phase, so it hears the press even when something
 * under the pointer stops it from bubbling.
 */
export function useOutside(open: boolean, nodes: () => (Element | null | undefined)[], onOutside?: () => void) {
  const get = useRef(nodes);
  get.current = nodes;
  const cb = useRef(onOutside);
  cb.current = onOutside;

  useEffect(() => {
    if (!open) return;
    const me: Layer = { nodes: () => get.current() };
    stack.push(me);
    const onDown = (e: PointerEvent) => {
      if (!cb.current) return;
      const t = e.target as Node;
      const mine = stack.slice(stack.indexOf(me));
      if (!mine.some((l) => l.nodes().some((n) => n?.contains(t)))) cb.current();
    };
    document.addEventListener("pointerdown", onDown, true);
    return () => {
      document.removeEventListener("pointerdown", onDown, true);
      stack.splice(stack.indexOf(me), 1);
    };
  }, [open]);
}

/* ── Anchored position ────────────────────────────────────────────────────── */

export type Side = "top" | "bottom" | "left" | "right";
export type Align = "start" | "center" | "end";
export type Placed = { top: number; left: number; side: Side; align: Align };
export type PlaceOptions = { side: Side; align: Align; offset?: number; alignOffset?: number; margin?: number };

const opposite: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };

/**
 * Where a box of `w × h` goes beside `a`. It takes the side asked for unless the
 * box does not fit there and the opposite side has more room; an edge-aligned
 * box swaps to the other edge before it is pushed; and then it is clamped inside
 * the viewport, so a menu is never cut off — it just stops being perfectly
 * aligned, which is the smaller loss.
 */
export function place(a: DOMRect, w: number, h: number, o: PlaceOptions): Placed {
  const vw = document.documentElement.clientWidth;
  const vh = window.innerHeight;
  const gap = o.offset ?? 6;
  const shift = o.alignOffset ?? 0;
  const margin = o.margin ?? 8;
  const room: Record<Side, number> = {
    bottom: vh - a.bottom - gap - margin,
    top: a.top - gap - margin,
    right: vw - a.right - gap - margin,
    left: a.left - gap - margin,
  };
  const need = o.side === "top" || o.side === "bottom" ? h : w;
  const side = room[o.side] < need && room[opposite[o.side]] > room[o.side] ? opposite[o.side] : o.side;
  let align = o.align;
  let top: number;
  let left: number;

  if (side === "top" || side === "bottom") {
    top = side === "bottom" ? a.bottom + gap : a.top - gap - h;
    const at = (al: Align) => (al === "start" ? a.left + shift : al === "end" ? a.right - w - shift : a.left + a.width / 2 - w / 2);
    left = at(align);
    if (align === "start" && left + w > vw - margin) align = "end";
    else if (align === "end" && left < margin) align = "start";
    left = at(align);
  } else {
    left = side === "right" ? a.right + gap : a.left - gap - w;
    const at = (al: Align) => (al === "start" ? a.top + shift : al === "end" ? a.bottom - h - shift : a.top + a.height / 2 - h / 2);
    top = at(align);
    if (align === "start" && top + h > vh - margin) align = "end";
    else if (align === "end" && top < margin) align = "start";
    top = at(align);
  }
  left = Math.max(margin, Math.min(left, vw - w - margin));
  top = Math.max(margin, Math.min(top, vh - h - margin));
  return { top: Math.round(top), left: Math.round(left), side, align };
}

/**
 * Keeps `box` placed beside the anchor while open — measured before paint, and
 * again on any scroll (in any container), resize, or change in the box's own
 * size. The box is measured by its layout size, not its bounding rect, so the
 * opening scale does not shrink what is measured.
 */
export function useAnchored(
  open: boolean,
  anchor: () => DOMRect | null | undefined,
  box: RefObject<HTMLElement | null>,
  options: PlaceOptions
): Placed | null {
  const [pos, setPos] = useState<Placed | null>(null);
  const a = useRef(anchor);
  a.current = anchor;
  const o = useRef(options);
  o.current = options;

  useIsoLayoutEffect(() => {
    if (!open) return;
    let raf = 0;
    const update = () => {
      raf = 0;
      const r = a.current();
      const el = box.current;
      if (!r || !el) return;
      const next = place(r, el.offsetWidth, el.offsetHeight, o.current);
      setPos((p) =>
        p && p.top === next.top && p.left === next.left && p.side === next.side && p.align === next.align ? p : next
      );
    };
    const schedule = () => {
      if (!raf) raf = requestAnimationFrame(update);
    };
    update();
    window.addEventListener("scroll", schedule, { capture: true, passive: true });
    window.addEventListener("resize", schedule);
    const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule);
    if (box.current) ro?.observe(box.current);
    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener("scroll", schedule, { capture: true });
      window.removeEventListener("resize", schedule);
      ro?.disconnect();
    };
  }, [open]);

  return pos;
}

const ORIGIN: Record<Side, Record<Align, string>> = {
  bottom: { start: "origin-top-left", center: "origin-top", end: "origin-top-right" },
  top: { start: "origin-bottom-left", center: "origin-bottom", end: "origin-bottom-right" },
  right: { start: "origin-top-left", center: "origin-left", end: "origin-bottom-left" },
  left: { start: "origin-top-right", center: "origin-right", end: "origin-bottom-right" },
};

export type FloatingProps = {
  open: boolean;
  anchor: () => DOMRect | null | undefined;
  side?: Side;
  align?: Align;
  offset?: number;
  alignOffset?: number;
  /** Where the ink is copied from — usually the trigger. */
  inkFrom?: () => Element | null | undefined;
  /** Extra elements a press may land on without counting as outside — the trigger. */
  inside?: () => (Element | null | undefined)[];
  /** A press outside the panel, the trigger, and anything opened from it. */
  onOutside?: () => void;
  children: ReactNode;
};

/**
 * A panel anchored to a trigger, in the body, growing out of whichever edge it
 * ended up on with DropMenu's squash-and-stretch. The transform-origin follows
 * the flip, so a menu that had to open upward still grows out of its trigger
 * rather than toward it.
 */
export function Floating({
  open,
  anchor,
  side = "bottom",
  align = "start",
  offset,
  alignOffset,
  inkFrom,
  inside,
  onOutside,
  children,
}: FloatingProps) {
  const box = useRef<HTMLDivElement>(null);
  const pos = useAnchored(open, anchor, box, { side, align, offset, alignOffset });
  useOutside(open, () => [box.current, ...(inside?.() ?? [])], onOutside);
  const s = pos?.side ?? side;
  const al = pos?.align ?? align;
  // Each opening gets fresh contents. Reopened while it is still leaving, the
  // panel is caught mid-exit and reused, and without a new key whatever it
  // does on mount (take focus, start on the first row) would not happen again.
  const opens = useRef(0);
  const was = useRef(false);
  if (open && !was.current) opens.current++;
  was.current = open;

  return (
    <Portal open={open} inkFrom={inkFrom}>
      <div
        ref={box}
        // Not hidden before it is measured: the measure happens before paint, and
        // a hidden ancestor would make the panel refuse focus on mount.
        className="pointer-events-none fixed z-[62] w-max"
        style={{ top: pos?.top ?? 0, left: pos?.left ?? 0 }}
      >
        <DropMenu open={open} placement={s === "top" ? "up" : "down"} origin={ORIGIN[s][al]} className={open ? "pointer-events-auto" : ""}>
          <Fragment key={opens.current}>{children}</Fragment>
        </DropMenu>
      </div>
    </Portal>
  );
}

/* ── Modal layer ──────────────────────────────────────────────────────────── */

export type ModalLayerProps = {
  open: boolean;
  onDismiss: () => void;
  /** A press on the scrim dismisses. Off for a decision that must be made. */
  backdrop?: boolean;
  /** Escape dismisses. */
  escape?: boolean;
  /** What takes focus on open. Default: the first control that is not the corner close button. */
  initialFocus?: () => HTMLElement | null | undefined;
  /** Where the panel stands: centred, high (a palette), or on the bottom edge (a sheet). */
  placement?: "center" | "top" | "bottom";
  /** The panel — a motion element with its own enter and exit. */
  children: ReactNode;
};

/* The last thing pressed, for browsers that do not focus a button on click
   (Safari): without it a dialog opened by a click would have no opener to give
   focus back to, or to take its ink from. */
let lastPressed: Element | null = null;
let listening = false;
function listenForPresses() {
  if (listening || typeof document === "undefined") return;
  listening = true;
  document.addEventListener("pointerdown", (e) => (lastPressed = e.target as Element), true);
}

function currentOpener(): Element | null {
  const a = document.activeElement;
  if (a && a !== document.body) return a;
  return lastPressed?.isConnected ? (lastPressed.closest("button, a[href], [tabindex]") ?? lastPressed) : null;
}

const PLACE = {
  center: "items-center p-4",
  top: "items-start px-4 pt-[14vh] pb-4",
  bottom: "items-end p-2",
} as const;

/**
 * Everything a modal shares: the scrim, the scroll lock, focus moved in and given
 * back to whatever had it, Tab kept inside, Escape and a press on the scrim.
 *
 * While it is up, keys and the wheel stop at its edge. A page is free to listen
 * for arrows or scroll on the window (a carousel, a slide deck), and a modal is
 * a promise that nothing behind it reacts. Modified keys still pass, so a global
 * shortcut can close what it opened.
 */
export function ModalLayer({ open, onDismiss, backdrop = true, escape = true, initialFocus, placement = "center", children }: ModalLayerProps) {
  const [layer, setLayer] = useState<HTMLDivElement | null>(null);
  const opener = useRef<Element | null>(null);
  const was = useRef(false);
  const pressed = useRef(false);
  const first = useRef(initialFocus);
  first.current = initialFocus;

  // Who opened it is whoever had focus (or was pressed) when `open` turned true.
  if (open && !was.current && typeof document !== "undefined") opener.current = currentOpener();
  was.current = open;

  useEffect(listenForPresses, []);

  useScrollLock(open);

  useEffect(() => {
    if (!open || !layer) return;
    const target =
      first.current?.() ??
      tabbables(layer).find((el) => !el.hasAttribute("data-dialog-close")) ??
      layer.querySelector<HTMLElement>("[data-modal-panel]");
    focusQuietly(target);
  }, [open, layer]);

  useEffect(() => {
    if (!open) return;
    return () => {
      const was = document.activeElement as HTMLElement | null;
      focusQuietly(opener.current);
      // Opened from nowhere focusable (a hotkey on the page): let go of the
      // field in the leaving panel rather than leave typing aimed at it.
      if (was && document.activeElement === was && was.closest("[data-overlay-root]")) was.blur();
    };
  }, [open]);

  return (
    <Portal open={open} inkFrom={() => opener.current}>
      <LazyMotion features={domAnimation} strict>
        <AnimatePresence>
          {open && (
            <m.div
              key="scrim"
              aria-hidden
              className="overlay-scrim fixed inset-0 z-[58]"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0, transition: CLOSE }}
              transition={{ duration: 0.2, ease: "easeOut" }}
            />
          )}
          {open && (
            <div
              key="layer"
              ref={setLayer}
              className={`fixed inset-0 z-[58] flex justify-center ${PLACE[placement]}`}
              onPointerDown={(e) => {
                pressed.current = e.target === e.currentTarget;
              }}
              onClick={(e) => {
                if (backdrop && pressed.current && e.target === e.currentTarget) onDismiss();
                pressed.current = false;
              }}
              onKeyDown={(e) => {
                if (e.key === "Escape" && !e.defaultPrevented) {
                  e.preventDefault();
                  if (escape) onDismiss();
                } else trapTab(e, layer);
                if (!e.metaKey && !e.ctrlKey) e.stopPropagation();
              }}
              onWheel={(e) => e.stopPropagation()}
            >
              {children}
            </div>
          )}
        </AnimatePresence>
      </LazyMotion>
    </Portal>
  );
}

/* ── Triggers ─────────────────────────────────────────────────────────────── */

type AnyProps = Record<string, unknown>;
type Handler = (e: { defaultPrevented?: boolean }) => void;
const REACT_19 = parseInt(version, 10) >= 19;

function assignRef(ref: unknown, el: HTMLElement | null) {
  if (typeof ref === "function") ref(el);
  else if (ref && typeof ref === "object" && "current" in ref) (ref as { current: unknown }).current = el;
}

/** The trigger's own `id` if it has one, so ARIA can point at it; otherwise ours. */
export function triggerId(child: ReactElement, fallback: string): string {
  const id = (child.props as AnyProps).id;
  return typeof id === "string" && id ? id : fallback;
}

/**
 * Hands a trigger its ARIA, its handlers and a ref without wrapping it in
 * anything, so your own Button stays the element that is focused and pressed.
 * Handlers are chained: the child's own runs first, and calling
 * `preventDefault()` in it keeps ours from running.
 */
export function withTrigger(child: ReactElement, ours: AnyProps, ref: (el: HTMLElement | null) => void) {
  const own = child.props as AnyProps;
  const merged: AnyProps = { ...ours };
  for (const k of Object.keys(ours)) {
    const a = own[k];
    const b = ours[k];
    if (/^on[A-Z]/.test(k) && typeof a === "function" && typeof b === "function") {
      merged[k] = (e: { defaultPrevented?: boolean }) => {
        (a as Handler)(e);
        if (!e?.defaultPrevented) (b as Handler)(e);
      };
    }
  }
  const childRef = REACT_19 ? own.ref : (child as unknown as { ref?: unknown }).ref;
  merged.ref = (el: HTMLElement | null) => {
    ref(el);
    assignRef(childRef, el);
  };
  return cloneElement(child, merged);
}