← All components

Dialog

A panel that lifts out of the page, asks one thing, and holds everything behind it still.

The idea

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 onto it, and it leaves fast and flat, because a dismissal is not an event. The scrim dims and very lightly blurs, enough to say “not now” about the page without hiding where you were. Focus moves to the first control rather than the close mark, Tab stays inside, and focus goes back to whatever opened it.

Composes Button.

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

Dialog — usage22 lines
import { Button, Dialog } from "mihirmodi-ui";

const [open, setOpen] = useState(false);

<Button onClick={() => setOpen(true)}>Edit profile</Button>

// No trigger part: open it from anything. Focus goes to the first
// field, stays inside while it is up, and comes back to the button.
<Dialog
  open={open}
  onOpenChange={setOpen}
  title="Edit profile"
  description="This is how you appear on comments and shared links."
  footer={
    <>
      <Button onClick={() => setOpen(false)}>Cancel</Button>
      <Button variant="primary" onClick={save}>Save changes</Button>
    </>
  }
>
  <ProfileFields />
</Dialog>

Props

PropNotes
open / onOpenChangeboolean / (open) => voidControlled. There is no trigger part: open it from anything.
titleReactNodeShown, and names the dialog for a screen reader.
descriptionReactNodeA line under the title, read out after it.
footerReactNodeButtons along the foot, right-aligned. One of them primary.
widthnumberMost it grows to; on a phone it is the screen less a margin.
closeButtonbooleanThe close mark in the corner. Never the first thing focused.
initialFocusRefObject<HTMLElement>What takes focus on open, when the first control is the wrong one.

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/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);
}