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.
Install
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| open / onOpenChangeboolean / (open) => void | boolean / (open) => void | Controlled. |
| title / descriptionReactNode | ReactNode | The question, and what will happen, plainly. |
| confirmLabelstring | string | The consequence: “Delete project”, not “OK”. |
| cancelLabelstring | string= "Cancel" | Focused on open. |
| onConfirm() => void | Promise | () => void | Promise | Return 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.
"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>
);
}
"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>
);
}
"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);
}