← All components

Command

A search over grouped commands that ranks as you type, inline or as a ⌘K palette.

Pages
Overview
Projects12
Deployments
Settings
Actions
New project⌘N
Invite a teammate
Switch to dark theme
Copy current URL
Help
Documentation
Keyboard shortcuts?
Contact support

The idea

Focus never leaves the field: the arrows move one sliding tile through the results and a screen reader is told which one it is on, so typing, moving and choosing happen without the caret going anywhere. Ranking is forgiving rather than clever — the start of the label beats the start of a word, which beats initials, which beat letters in order — so “np” finds New project. The list's height eases to fit its results, so a narrowing search shrinks under your eye instead of jumping.

Composes Kbd and Dialog.

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

Command — usage21 lines
import { Command, CommandDialog } from "mihirmodi-ui";

const groups = [
  {
    heading: "Pages",
    items: [
      { id: "settings", label: "Settings", keywords: ["preferences"], onSelect: () => go("/settings") },
      { id: "deployments", label: "Deployments", onSelect: () => go("/deployments") },
    ],
  },
  {
    heading: "Actions",
    items: [{ id: "new", label: "New project", shortcut: ["⌘", "N"], onSelect: create }],
  },
];

// Inline, on the page.
<Command groups={groups} placeholder="Search pages and actions…" />

// Or as the palette: ⌘K (Ctrl+K) toggles it from anywhere.
<CommandDialog open={open} onOpenChange={setOpen} groups={groups} />

Props

PropNotes
groups{ heading, items: CommandItem[] }[]Items have id, label, icon, shortcut, hint, keywords and onSelect.
item.keywordsstring[]Other words it is found by — “preferences” for Settings.
emptyReactNode | (query) => ReactNodeShown when nothing matches.
maxHeightnumberThe list grows to this, then scrolls.
CommandDialog{ open, onOpenChange, hotkey? }The palette: high on the screen over the scrim, toggled by ⌘K or Ctrl+K.

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/Command.tsx374 lines
"use client";

import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { Kbd } from "../actions/Kbd.js";
import { glyphProps } from "../../lib/glyph.js";
import { CLOSE, EASE_OUT_EXPO, FLAT, LIFT } from "../../lib/motion.js";
import { ModalLayer, useIsoLayoutEffect } from "./layer.js";
import { RowHighlight, useRowRect } from "./menu.js";

export type CommandItem = {
  id: string;
  label: string;
  icon?: ReactNode;
  /** Keys, drawn as caps on the right. */
  shortcut?: string[];
  /** Other words it should be found by — "settings" for Preferences. */
  keywords?: string[];
  /** Quiet text on the right, when there is no shortcut — a path, a count. */
  hint?: ReactNode;
  disabled?: boolean;
  onSelect?: () => void;
};

export type CommandGroup = { heading: string; items: CommandItem[] };

export type CommandProps = {
  groups: CommandGroup[];
  placeholder?: string;
  /** Shown when nothing matches. Gets the query. */
  empty?: ReactNode | ((query: string) => ReactNode);
  /** The search field's accessible name. */
  label?: string;
  /** Most the list grows to before it scrolls, in px. */
  maxHeight?: number;
  autoFocus?: boolean;
  /** After an item's own `onSelect` — CommandDialog closes itself here. */
  onItemSelect?: (item: CommandItem) => void;
  /** A row of hints along the foot. */
  footer?: ReactNode;
  /** Drop the box — for a palette that already stands on a panel. */
  bare?: boolean;
  className?: string;
};

/* ── Ranking ──────────────────────────────────────────────────────────────── */

const norm = (s: string) => s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
/** A caller's id, made safe to use inside an `id` attribute and an IDREF list. */
const safe = (s: string) => s.replace(/[^\w-]/g, "_");
const words = (s: string) => s.split(/[\s\-_/.:]+/).filter(Boolean);

/**
 * How well `q` finds `text`, 0 for not at all. In order: the whole label, the
 * start of it, the start of a word, the words' initials ("np" for New
 * project), anywhere inside, a keyword, and last the letters in order with
 * small gaps — scored down by how spread out they are, and not at all once
 * they are strewn across the label.
 */
export function commandScore(q: string, text: string, keywords: string[] = []): number {
  const query = norm(q.trim());
  if (!query) return 1;
  const t = norm(text);
  if (t === query) return 100;
  if (t.startsWith(query)) return 90;
  const w = words(t);
  if (w.some((x) => x.startsWith(query))) return 75;
  if (w.map((x) => x[0]).join("").startsWith(query.replace(/\s+/g, ""))) return 65;
  if (t.includes(query)) return 55;
  for (const k of keywords) {
    const kk = norm(k);
    if (kk.startsWith(query)) return 45;
    if (kk.includes(query)) return 35;
  }
  let at = -1;
  let first = -1;
  for (const ch of query.replace(/\s+/g, "")) {
    at = t.indexOf(ch, at + 1);
    if (at < 0) return 0;
    if (first < 0) first = at;
  }
  // Letters strewn across the whole label are a coincidence, not a match.
  const span = at - first + 1;
  const len = query.replace(/\s+/g, "").length;
  if (span > Math.max(len * 3, len + 4)) return 0;
  return Math.max(1, 25 - (span - len));
}

function SearchGlyph() {
  return (
    <svg {...glyphProps} className="h-4 w-4">
      <circle cx="11" cy="11" r="6.5" />
      <path d="M16 16l4 4" />
    </svg>
  );
}

/**
 * A search field over a grouped list of commands, filtered and ranked as you
 * type — the palette behind ⌘K, or a picker inline on a page.
 *
 * Focus never leaves the field. The arrows move one sliding tile through the
 * results and `aria-activedescendant` tells a screen reader which one it is on,
 * so typing, moving and choosing happen without the caret ever going
 * anywhere. Ranking is forgiving rather than clever: the start of the label
 * beats the start of a word, which beats the initials, which beat the letters
 * appearing anywhere in order, so "np" finds New project and the best
 * guess is the one already under the tile.
 *
 * The list's height follows its results, easing rather than snapping, so a
 * narrowing search shrinks the panel under your eye instead of leaving a hole
 * or jumping. Escape clears the field first; a second Escape is the
 * container's to use — CommandDialog closes on it.
 */
export function Command({
  groups,
  placeholder = "Type a command or search…",
  empty = (q: string) => `Nothing matches “${q}”.`,
  label = "Search commands",
  maxHeight = 300,
  autoFocus = false,
  onItemSelect,
  footer,
  bare = false,
  className = "",
}: CommandProps) {
  const reduce = useReducedMotion();
  const uid = useId();
  const listId = `${uid}-list`;
  const [query, setQuery] = useState("");
  const input = useRef<HTMLInputElement>(null);
  const content = useRef<HTMLDivElement>(null);
  const rowEls = useRef(new Map<string, HTMLElement>());
  const [height, setHeight] = useState<number | null>(null);

  const results = useMemo(() => {
    const out: { heading: string; items: CommandItem[] }[] = [];
    for (const g of groups) {
      const scored = g.items
        .map((it, i) => ({ it, i, s: commandScore(query, it.label, it.keywords) }))
        .filter((x) => x.s > 0)
        .sort((a, b) => b.s - a.s || a.i - b.i)
        .map((x) => x.it);
      if (scored.length) out.push({ heading: g.heading, items: scored });
    }
    // The group holding the best match comes first, so the tile starts on it.
    if (query.trim()) {
      const best = (g: { items: CommandItem[] }) => commandScore(query, g.items[0].label, g.items[0].keywords);
      out.sort((a, b) => best(b) - best(a));
    }
    return out;
  }, [groups, query]);

  const flat = useMemo(() => results.flatMap((g) => g.items).filter((i) => !i.disabled), [results]);
  const [activeId, setActiveId] = useState<string | null>(null);
  const active = flat.find((i) => i.id === activeId) ?? flat[0] ?? null;
  const rect = useRowRect(() => (active ? rowEls.current.get(active.id) : null), [active?.id, results]);

  // A new query puts the tile back on the best match.
  useEffect(() => setActiveId(null), [query]);

  useEffect(() => {
    if (active) rowEls.current.get(active.id)?.scrollIntoView({ block: "nearest" });
  }, [active?.id]);

  useEffect(() => {
    if (autoFocus) input.current?.focus({ preventScroll: true });
  }, [autoFocus]);

  useIsoLayoutEffect(() => {
    const el = content.current;
    if (!el) return;
    const measure = () => setHeight(Math.min(el.offsetHeight, maxHeight));
    measure();
    if (typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, [maxHeight]);

  const choose = (item: CommandItem) => {
    if (item.disabled) return;
    item.onSelect?.();
    onItemSelect?.(item);
  };

  const move = (d: 1 | -1) => {
    if (!flat.length) return;
    const i = active ? flat.indexOf(active) : -1;
    setActiveId(flat[(i + d + flat.length) % flat.length].id);
  };

  const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) move(1);
    else if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p")) move(-1);
    else if (e.key === "Enter") {
      if (active) choose(active);
    } else if (e.key === "Escape" && query) setQuery("");
    else return;
    e.preventDefault();
    e.stopPropagation();
  };

  return (
    <div
      className={`flex flex-col ${
        bare
          ? ""
          : "rounded-[14px] border-[0.5px] border-gray-alpha-400 bg-background-100 transition-[border-color,box-shadow] duration-150 ease-out focus-within:border-gray-alpha-600 focus-within:shadow-[0_0_0_3px_var(--color-gray-alpha-200)]"
      } ${className}`}
    >
      <div className="flex h-12 shrink-0 items-center gap-2.5 border-b-[0.5px] border-gray-alpha-400 px-3.5 text-quiet">
        <SearchGlyph />
        <input
          ref={input}
          type="text"
          role="combobox"
          aria-label={label}
          aria-expanded
          aria-controls={listId}
          aria-autocomplete="list"
          aria-activedescendant={active ? `${uid}-${safe(active.id)}` : undefined}
          autoComplete="off"
          spellCheck={false}
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onKeyDown={onKeyDown}
          placeholder={placeholder}
          // The box around it carries the focus, as a composer's does.
          className="min-w-0 flex-1 bg-transparent text-[14px] text-gray-1000 outline-none placeholder:text-faint"
        />
      </div>
      <LazyMotion features={domAnimation} strict>
        <m.div
          className="overflow-hidden"
          initial={false}
          animate={{ height: height ?? "auto" }}
          transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0, duration: 0.26 }}
        >
          <div className="overflow-y-auto overscroll-contain" style={{ maxHeight }}>
            <div ref={content} id={listId} role="listbox" aria-label={label} className="relative p-1">
              <RowHighlight rect={rect} className="rounded-[8px] bg-gray-alpha-200" />
              {results.length === 0 && (
                <p role="presentation" className="px-2.5 py-6 text-center text-[13px] text-muted">
                  {typeof empty === "function" ? empty(query.trim()) : empty}
                </p>
              )}
              {results.map((g, gi) => (
                <div key={g.heading} role="group" aria-labelledby={`${uid}-h${gi}`}>
                  <div id={`${uid}-h${gi}`} className="flex h-7 items-end px-2.5 pb-1 text-[12px] text-faint">
                    {g.heading}
                  </div>
                  {g.items.map((it) => (
                    <div
                      key={it.id}
                      id={`${uid}-${safe(it.id)}`}
                      ref={(el) => {
                        if (el) rowEls.current.set(it.id, el);
                        else rowEls.current.delete(it.id);
                      }}
                      role="option"
                      aria-selected={active?.id === it.id}
                      aria-disabled={it.disabled || undefined}
                      onPointerMove={() => {
                        if (!it.disabled && active?.id !== it.id) setActiveId(it.id);
                      }}
                      onMouseDown={(e) => e.preventDefault()}
                      onClick={() => choose(it)}
                      className={`relative flex h-9 cursor-default select-none items-center gap-2.5 rounded-[8px] px-2.5 text-[13px] text-gray-1000 ${
                        it.disabled ? "opacity-50" : ""
                      }`}
                    >
                      {it.icon && <span className="flex w-4 shrink-0 items-center justify-center text-quiet">{it.icon}</span>}
                      <span className="min-w-0 flex-1 truncate">{it.label}</span>
                      {it.shortcut ? (
                        <Kbd keys={it.shortcut} className="ml-4 shrink-0" />
                      ) : (
                        it.hint && <span className="ml-4 shrink-0 text-[12px] text-faint">{it.hint}</span>
                      )}
                    </div>
                  ))}
                </div>
              ))}
            </div>
          </div>
        </m.div>
      </LazyMotion>
      {footer && <div className="flex h-10 shrink-0 items-center gap-4 border-t-[0.5px] border-gray-alpha-400 px-3.5 text-[12px] text-faint">{footer}</div>}
    </div>
  );
}

export type CommandDialogProps = Omit<CommandProps, "bare" | "autoFocus"> & {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  /** The letter that, with ⌘ or Ctrl, toggles it from anywhere. `false` for none. */
  hotkey?: string | false;
  width?: number;
};

/**
 * Command, as a palette: high on the screen in a glass panel over the scrim,
 * toggled by ⌘K (or Ctrl+K) from anywhere on the page.
 *
 * It sits in the upper third rather than the centre, so the list has room to
 * grow downward as you type without the field moving under your eye. Choosing
 * a command closes it; Escape clears the field, then closes; focus goes back
 * to where it was.
 */
export function CommandDialog({ open, onOpenChange, hotkey = "k", width = 560, footer, ...command }: CommandDialogProps) {
  const reduce = useReducedMotion();
  const toggle = useRef(onOpenChange);
  toggle.current = onOpenChange;
  const isOpen = useRef(open);
  isOpen.current = open;

  useEffect(() => {
    if (!hotkey) return;
    const onKey = (e: globalThis.KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === hotkey.toLowerCase()) {
        e.preventDefault();
        toggle.current(!isOpen.current);
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [hotkey]);

  const hints = footer ?? (
    <>
      <span className="inline-flex items-center gap-1.5">
        <Kbd keys={["↑", "↓"]} /> to move
      </span>
      <span className="inline-flex items-center gap-1.5">
        <Kbd keys={["↵"]} /> to choose
      </span>
      <span className="inline-flex items-center gap-1.5">
        <Kbd keys={["esc"]} /> to close
      </span>
    </>
  );

  return (
    <ModalLayer open={open} onDismiss={() => onOpenChange(false)} placement="top">
      <m.div
        data-modal-panel
        role="dialog"
        aria-modal="true"
        aria-label={command.label ?? "Command palette"}
        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.97, y: -6 }}
        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.18, ease: EASE_OUT_EXPO } }}
      >
        <div className="glass-panel dock-shadow overflow-hidden rounded-2xl border-[0.5px] border-gray-alpha-400">
          <Command
            {...command}
            bare
            autoFocus
            footer={hints}
            onItemSelect={(item) => {
              onOpenChange(false);
              command.onItemSelect?.(item);
            }}
          />
        </div>
      </m.div>
    </ModalLayer>
  );
}
packages/ui/src/components/overlays/menu.tsx426 lines
"use client";

import { Fragment, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { Kbd } from "../actions/Kbd.js";
import { CheckIcon, glyphProps } from "../../lib/glyph.js";
import { SLIDE } from "../../lib/motion.js";
import { Floating, focusQuietly, useIsoLayoutEffect } from "./layer.js";

/* ── Entries ──────────────────────────────────────────────────────────────── */

/** A command. Choosing it closes the menu. */
export type MenuItemEntry = {
  type?: "item";
  label: string;
  icon?: ReactNode;
  /** Keys, drawn as caps: `["⌘", "N"]`. */
  shortcut?: string[];
  disabled?: boolean;
  onSelect?: () => void;
};

/** A setting that is on or off. The menu stays open, so you see the mark land. */
export type MenuCheckboxEntry = {
  type: "checkbox";
  label: string;
  checked: boolean;
  onCheckedChange: (checked: boolean) => void;
  shortcut?: string[];
  disabled?: boolean;
};

/** One of several. A labelled group of rows; the menu stays open. */
export type MenuRadioEntry = {
  type: "radio";
  label?: string;
  value: string;
  onValueChange: (value: string) => void;
  options: { value: string; label: string; shortcut?: string[]; disabled?: boolean }[];
};

/** Rows under a small heading. */
export type MenuGroupEntry = { type: "group"; label?: string; items: MenuEntry[] };

export type MenuSeparatorEntry = { type: "separator" };

/** A row that opens another menu beside it. */
export type MenuSubmenuEntry = { type: "submenu"; label: string; icon?: ReactNode; disabled?: boolean; items: MenuEntry[] };

export type MenuEntry = MenuItemEntry | MenuCheckboxEntry | MenuRadioEntry | MenuGroupEntry | MenuSeparatorEntry | MenuSubmenuEntry;

type Row =
  | { kind: "item"; label: string; disabled: boolean; entry: MenuItemEntry }
  | { kind: "checkbox"; label: string; disabled: boolean; entry: MenuCheckboxEntry }
  | { kind: "radio"; label: string; disabled: boolean; entry: MenuRadioEntry; option: MenuRadioEntry["options"][number] }
  | { kind: "submenu"; label: string; disabled: boolean; entry: MenuSubmenuEntry };

/** Every row the keyboard can land on, in the order they are drawn. */
function flatten(entries: MenuEntry[], out: Row[] = []): Row[] {
  for (const e of entries) {
    if (e.type === "separator") continue;
    if (e.type === "group") flatten(e.items, out);
    else if (e.type === "radio") for (const o of e.options) out.push({ kind: "radio", label: o.label, disabled: !!o.disabled, entry: e, option: o });
    else if (e.type === "checkbox") out.push({ kind: "checkbox", label: e.label, disabled: !!e.disabled, entry: e });
    else if (e.type === "submenu") out.push({ kind: "submenu", label: e.label, disabled: !!e.disabled, entry: e });
    else out.push({ kind: "item", label: e.label, disabled: !!e.disabled, entry: e });
  }
  return out;
}

/** Does any row want the leading column — an icon, a check, a dot? Then every row keeps it, so the labels line up. */
function wantsGutter(entries: MenuEntry[]): boolean {
  return entries.some((e) =>
    e.type === "group" ? wantsGutter(e.items) : e.type === "checkbox" || e.type === "radio" || ((e.type === undefined || e.type === "item" || e.type === "submenu") && !!e.icon)
  );
}

/** The next enabled row from `from` in direction `dir`, wrapping. -1 when there is none. */
function step(rows: Row[], from: number, dir: 1 | -1, wrap = true): number {
  const n = rows.length;
  for (let k = 1; k <= n; k++) {
    let j = from + dir * k;
    if (wrap) j = ((j % n) + n) % n;
    else if (j < 0 || j >= n) return -1;
    if (!rows[j].disabled) return j;
  }
  return -1;
}

/* ── The shared highlight ─────────────────────────────────────────────────── */

export type RowRect = { x: number; y: number; w: number; h: number };

/**
 * The box of whichever row is current, relative to its positioned parent —
 * measured after every child has committed, so a list that just re-ranked is
 * measured as it now stands.
 */
export function useRowRect(get: () => HTMLElement | null | undefined, deps: unknown[]): RowRect | null {
  const [rect, setRect] = useState<RowRect | null>(null);
  useIsoLayoutEffect(() => {
    const el = get();
    const next = el ? { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight } : null;
    setRect((p) => (p && next && p.x === next.x && p.y === next.y && p.w === next.w && p.h === next.h ? p : next));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);
  return rect;
}

/**
 * One tile behind the current row, that slides from row to row instead of each
 * row lighting its own background. The keyboard and the pointer move the same
 * tile, so the eye follows one thing. Arriving from nothing, it appears where it
 * is needed rather than sliding in from wherever it last was; leaving, it fades
 * in place.
 */
export function RowHighlight({ rect, className = "" }: { rect: RowRect | null; className?: string }) {
  const reduce = useReducedMotion();
  const last = useRef<RowRect | null>(null);
  const shown = useRef(false);
  const at = rect ?? last.current;
  const jump = !!rect && !shown.current;
  useEffect(() => {
    shown.current = !!rect;
    if (rect) last.current = rect;
  });
  if (!at) return null;
  const move = reduce || jump ? { duration: 0 } : SLIDE;
  return (
    <LazyMotion features={domAnimation} strict>
      <m.span
        aria-hidden
        className={`pointer-events-none absolute left-0 top-0 ${className}`}
        initial={false}
        animate={{ x: at.x, y: at.y, width: at.w, height: at.h, opacity: rect ? 1 : 0 }}
        transition={{ x: move, y: move, width: move, height: move, opacity: { duration: rect ? 0.1 : 0.15, ease: "easeOut" } }}
      />
    </LazyMotion>
  );
}

/* ── The panel ────────────────────────────────────────────────────────────── */

function ChevronRight() {
  return (
    <svg {...glyphProps} strokeWidth="1.8" className="h-3.5 w-3.5">
      <path d="M9.5 6l6 6-6 6" />
    </svg>
  );
}

export type MenuPanelProps = {
  entries: MenuEntry[];
  id?: string;
  /** The trigger, which names the menu. */
  labelledBy?: string;
  "aria-label"?: string;
  /** Which row is current on open: the first (opened from the keyboard), the last (ArrowUp), or none (a click). */
  focus?: "first" | "last" | "none";
  /** Close every level. `refocus` gives focus back to the trigger. */
  onCloseAll: (refocus: boolean) => void;
  /** Close just this level — set on a submenu. */
  onBack?: () => void;
  /** Left and Right at the edge of a menu — a menubar moves to its neighbour. */
  onHorizontal?: (dir: 1 | -1) => void;
  /** The pointer arrived in this panel — its parent keeps its submenu open. */
  onPointerEnter?: () => void;
  inkFrom?: () => Element | null | undefined;
};

/**
 * The body every menu here shares — dropdown, context menu, menubar — so they
 * read and answer the keyboard the same way.
 *
 * Focus sits on the menu itself and `aria-activedescendant` names the current
 * row, as Select does: the rows are never focused one by one, so the one
 * sliding tile is the only mark of where you are, and it is the same mark for
 * the pointer and the keys. Up and Down move and wrap, Home and End jump,
 * typing a few letters finds the row that starts with them, Enter and Space
 * choose, Right opens a submenu and Left or Escape closes it.
 *
 * A command closes the menu; a checkbox or a radio does not, because the point
 * of changing a setting in a menu is to see that it changed.
 */
export function MenuPanel({
  entries,
  id,
  labelledBy,
  "aria-label": ariaLabel,
  focus = "none",
  onCloseAll,
  onBack,
  onHorizontal,
  onPointerEnter,
  inkFrom,
}: MenuPanelProps) {
  const uid = useId();
  const rows = useMemo(() => flatten(entries), [entries]);
  const gutter = useMemo(() => wantsGutter(entries), [entries]);
  const [active, setActive] = useState(() => (focus === "first" ? step(rows, -1, 1) : focus === "last" ? step(rows, rows.length, -1) : -1));
  const [sub, setSub] = useState<{ index: number; focus: "first" | "none" } | null>(null);
  const panel = useRef<HTMLDivElement>(null);
  const rowEls = useRef<(HTMLElement | null)[]>([]);
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const typed = useRef({ text: "", at: 0 });
  const rect = useRowRect(() => (active >= 0 ? rowEls.current[active] : null), [active, rows]);
  const rowId = (i: number) => `${uid}-r${i}`;

  useEffect(() => {
    focusQuietly(panel.current);
    return () => clearTimeout(timer.current);
  }, []);

  useEffect(() => {
    if (active >= 0) rowEls.current[active]?.scrollIntoView({ block: "nearest" });
  }, [active]);

  const openSub = (i: number, f: "first" | "none") => {
    clearTimeout(timer.current);
    setActive(i);
    setSub({ index: i, focus: f });
  };

  const closeSub = () => {
    clearTimeout(timer.current);
    setSub(null);
    focusQuietly(panel.current);
  };

  const activate = (i: number, fromKeys: boolean) => {
    const r = rows[i];
    if (!r || r.disabled) return;
    if (r.kind === "item") {
      onCloseAll(true);
      r.entry.onSelect?.();
    } else if (r.kind === "checkbox") r.entry.onCheckedChange(!r.entry.checked);
    else if (r.kind === "radio") r.entry.onValueChange(r.option.value);
    else openSub(i, fromKeys ? "first" : "none");
  };

  const hover = (i: number) => {
    if (document.activeElement !== panel.current) focusQuietly(panel.current);
    if (i === active && (rows[i]?.kind !== "submenu" || sub?.index === i)) return;
    clearTimeout(timer.current);
    const r = rows[i];
    if (r.disabled) {
      setActive(-1);
      return;
    }
    setActive(i);
    // Intent, both ways: a submenu opens once the pointer has settled on its row,
    // and an open one survives the pointer crossing a neighbour on its way over.
    if (r.kind === "submenu") timer.current = setTimeout(() => setSub({ index: i, focus: "none" }), 110);
    else if (sub) timer.current = setTimeout(() => setSub(null), 220);
  };

  const typeAhead = (key: string) => {
    const now = Date.now();
    const t = typed.current;
    t.text = now - t.at > 600 ? key.toLowerCase() : t.text + key.toLowerCase();
    t.at = now;
    const n = rows.length;
    // A fresh letter starts from the row after this one, so pressing it again walks the matches.
    const start = t.text.length === 1 ? active + 1 : Math.max(active, 0);
    for (let k = 0; k < n; k++) {
      const j = (start + k + n) % n;
      if (!rows[j].disabled && rows[j].label.toLowerCase().startsWith(t.text)) {
        setActive(j);
        return;
      }
    }
  };

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    // Every key stops here: a submenu's keys must not reach the menu it opened from.
    e.stopPropagation();
    if (e.metaKey || e.ctrlKey || e.altKey) return;
    const k = e.key;
    let handled = true;
    if (k === "ArrowDown") setActive((a) => step(rows, a < 0 ? -1 : a, 1));
    else if (k === "ArrowUp") setActive((a) => step(rows, a < 0 ? rows.length : a, -1));
    else if (k === "Home") setActive(step(rows, -1, 1));
    else if (k === "End") setActive(step(rows, rows.length, -1));
    else if (k === "Enter" || k === " ") {
      if (active >= 0) activate(active, true);
    } else if (k === "ArrowRight") {
      if (active >= 0 && rows[active].kind === "submenu" && !rows[active].disabled) openSub(active, "first");
      else onHorizontal?.(1);
    } else if (k === "ArrowLeft") {
      if (onBack) onBack();
      else onHorizontal?.(-1);
    } else if (k === "Escape") {
      if (onBack) onBack();
      else onCloseAll(true);
    } else if (k === "Tab") onCloseAll(true);
    else if (k.length === 1 && /\S/.test(k)) typeAhead(k);
    else handled = false;
    if (handled) e.preventDefault();
  };

  let n = 0;
  const row = (r: Row, key: string) => {
    const i = n++;
    const mark =
      r.kind === "checkbox" ? (
        <span className={`transition-opacity duration-150 ease-out motion-reduce:transition-none ${r.entry.checked ? "opacity-100" : "opacity-0"}`}>
          <CheckIcon className="h-3.5 w-3.5" />
        </span>
      ) : r.kind === "radio" ? (
        <span
          className={`h-1.5 w-1.5 rounded-full bg-current transition-[opacity,transform] duration-150 ease-geist motion-reduce:transition-none ${
            r.entry.value === r.option.value ? "scale-100 opacity-100" : "scale-50 opacity-0"
          }`}
        />
      ) : r.kind === "item" || r.kind === "submenu" ? (
        <span className="text-quiet">{r.entry.icon}</span>
      ) : null;
    const shortcut = r.kind === "radio" ? r.option.shortcut : r.kind === "submenu" ? undefined : r.entry.shortcut;
    const checked = r.kind === "checkbox" ? r.entry.checked : r.kind === "radio" ? r.entry.value === r.option.value : undefined;
    return (
      <div
        key={key}
        id={rowId(i)}
        ref={(el) => {
          rowEls.current[i] = el;
        }}
        role={r.kind === "checkbox" ? "menuitemcheckbox" : r.kind === "radio" ? "menuitemradio" : "menuitem"}
        aria-checked={checked}
        aria-disabled={r.disabled || undefined}
        aria-haspopup={r.kind === "submenu" ? "menu" : undefined}
        aria-expanded={r.kind === "submenu" ? sub?.index === i : undefined}
        onPointerMove={(e) => {
          if (e.pointerType !== "touch") hover(i);
        }}
        onClick={() => activate(i, false)}
        className={`relative flex h-8 cursor-default select-none items-center gap-2.5 rounded-[8px] px-2.5 text-[13px] text-gray-1000 ${
          r.disabled ? "opacity-50" : ""
        }`}
      >
        {gutter && <span className="flex w-4 shrink-0 items-center justify-center">{mark}</span>}
        <span className="min-w-0 flex-1 truncate">{r.label}</span>
        {shortcut && <Kbd keys={shortcut} className="ml-4 shrink-0" />}
        {r.kind === "submenu" && (
          <span className="-mr-1 ml-4 shrink-0 text-quiet">
            <ChevronRight />
          </span>
        )}
      </div>
    );
  };

  const render = (list: MenuEntry[], path: string): ReactNode[] =>
    list.map((e, j) => {
      const key = `${path}.${j}`;
      if (e.type === "separator") return <div key={key} role="separator" className="mx-2.5 my-1 h-[0.5px] bg-gray-alpha-400" />;
      if (e.type === "group" || e.type === "radio") {
        const head = `${uid}-g${key}`;
        return (
          <div key={key} role="group" aria-labelledby={e.label ? head : undefined}>
            {e.label && (
              <div id={head} className="flex h-7 items-end px-2.5 pb-1 text-[12px] text-faint">
                {e.label}
              </div>
            )}
            {e.type === "group" ? render(e.items, key) : e.options.map((o, k) => row(rows[n], `${key}.${k}.${o.value}`))}
          </div>
        );
      }
      return row(rows[n], key);
    });

  return (
    <>
      <div
        ref={panel}
        id={id}
        role="menu"
        tabIndex={-1}
        aria-orientation="vertical"
        aria-labelledby={labelledBy}
        aria-label={ariaLabel}
        aria-activedescendant={active >= 0 ? rowId(active) : undefined}
        onKeyDown={onKeyDown}
        onPointerEnter={onPointerEnter}
        onPointerLeave={() => {
          if (!sub) setActive(-1);
        }}
        // The focus holder, not a control: the sliding tile is its focus mark,
        // as the panel of a SlideOver carries none of its own.
        className="glass-menu dock-shadow relative max-h-[min(420px,calc(100dvh-16px))] min-w-[208px] max-w-[320px] overflow-y-auto rounded-[12px] border-[0.5px] border-gray-alpha-400 p-1 outline-none"
      >
        <RowHighlight rect={rect} className="rounded-[8px] bg-gray-alpha-200" />
        {render(entries, "m")}
      </div>
      {rows.map((r, i) =>
        r.kind === "submenu" ? (
          <Fragment key={i}>
            <Floating
              open={sub?.index === i}
              anchor={() => rowEls.current[i]?.getBoundingClientRect()}
              side="right"
              align="start"
              offset={6}
              alignOffset={-5}
              inkFrom={inkFrom}
            >
              <MenuPanel
                entries={r.entry.items}
                labelledBy={rowId(i)}
                focus={sub?.focus ?? "none"}
                onCloseAll={onCloseAll}
                onBack={closeSub}
                onHorizontal={onHorizontal}
                onPointerEnter={() => {
                  clearTimeout(timer.current);
                  setActive(i);
                }}
                inkFrom={inkFrom}
              />
            </Floating>
          </Fragment>
        ) : null
      )}
    </>
  );
}
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);
}