← All components

DropdownMenu

A full menu behind a button: shortcuts, checkboxes, radios, a submenu, one sliding tile.

The idea

The rows share one tile that slides between them rather than each lighting its own background, so the pointer and the keys move the same object and the eye follows it down the list. Focus stays on the menu and names the current row, so the tile is the only mark of where you are. Up and Down wrap, Home and End jump, typing finds a row, Right opens a submenu and Left closes it. A command closes the menu; a checkbox or a radio does not, because you changed a setting to see it change.

Composes DropMenu and Kbd and 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

DropdownMenu — usage32 lines
import { Button, DropdownMenu, type MenuEntry } from "mihirmodi-ui";

const items: MenuEntry[] = [
  { label: "New file", icon: <FileIcon />, shortcut: ["⌘", "N"], onSelect: newFile },
  {
    type: "submenu",
    label: "Share",
    items: [
      { label: "Copy link", onSelect: copyLink },
      { label: "Email", onSelect: email },
    ],
  },
  { type: "separator" },
  // Checkboxes and radios keep the menu open, so you see the change.
  { type: "checkbox", label: "Word wrap", checked: wrap, onCheckedChange: setWrap },
  {
    type: "radio",
    label: "Sort by",
    value: sort,
    onValueChange: setSort,
    options: [
      { value: "name", label: "Name" },
      { value: "modified", label: "Date modified" },
    ],
  },
  { type: "separator" },
  { label: "Archive", disabled: true },
];

<DropdownMenu items={items}>
  <Button>Options</Button>
</DropdownMenu>

Props

PropNotes
itemsMenuEntry[]Items, checkbox, radio, group (with a heading), separator and submenu entries.
childrenReactElementThe trigger. Click opens with nothing current; Enter, Space or Down opens on the first row, Up on the last.
side / align"top" | "bottom" / "start" | "center" | "end"Flips and clamps to stay on screen.
item.shortcutstring[]Drawn as keys: [“⌘”, “N”]. Display only — bind the keys yourself.
open / onOpenChangeboolean / (open) => voidOptional.

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/DropdownMenu.tsx95 lines
"use client";

import { useId, useRef, useState, type ReactElement } from "react";
import { Floating, focusQuietly, triggerId, withTrigger, type Align } from "./layer.js";
import { MenuPanel, type MenuEntry } from "./menu.js";

export type DropdownMenuProps = {
  /** Commands, checkboxes, radio groups, groups with headings, separators and submenus. */
  items: MenuEntry[];
  /** The trigger — one element, usually a Button. It is handed the ARIA and the handlers. */
  children: ReactElement;
  side?: "top" | "bottom";
  align?: Align;
  /** Controlled open state, if you need it. */
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** The menu's name, when the trigger's own text does not say it. */
  "aria-label"?: string;
};

/**
 * A menu of commands behind a button, grown out of it with DropMenu's
 * squash-and-stretch and drawn on the menu glass.
 *
 * The rows share one sliding tile rather than lighting their own backgrounds:
 * the pointer and the keys move the same thing, so the eye follows one object
 * down the list instead of watching rows blink. Shortcuts are drawn as keys,
 * and a row that opens a submenu points at where it opens.
 *
 * Opened by a click, nothing is current until you point or press a key;
 * opened from the keyboard (Enter, Space, Down), the first row is; Up opens on
 * the last. Escape, Tab or a press outside closes it and focus goes back to
 * the trigger.
 */
export function DropdownMenu({ items, children, side = "bottom", align = "start", open: controlled, onOpenChange, "aria-label": ariaLabel }: DropdownMenuProps) {
  const [inner, setInner] = useState(false);
  const open = controlled ?? inner;
  const [focus, setFocus] = useState<"first" | "last" | "none">("none");
  const trigger = useRef<HTMLElement | null>(null);
  const uid = useId();
  const tid = triggerId(children, `${uid}-trigger`);
  const menuId = `${uid}-menu`;

  const set = (v: boolean) => {
    setInner(v);
    onOpenChange?.(v);
  };
  const show = (f: "first" | "last" | "none") => {
    setFocus(f);
    set(true);
  };
  const close = (refocus: boolean) => {
    set(false);
    if (refocus) focusQuietly(trigger.current);
  };

  const triggerEl = withTrigger(
    children,
    {
      id: tid,
      "aria-haspopup": "menu",
      "aria-expanded": open,
      "aria-controls": open ? menuId : undefined,
      // A keyboard press of a button is a click with no detail.
      onClick: (e: { detail: number }) => (open ? close(false) : show(e.detail === 0 ? "first" : "none")),
      onKeyDown: (e: { key: string; preventDefault(): void; stopPropagation(): void }) => {
        if (e.key === "ArrowDown" || e.key === "ArrowUp") {
          e.preventDefault();
          e.stopPropagation();
          show(e.key === "ArrowDown" ? "first" : "last");
        }
      },
    },
    (el) => {
      trigger.current = el;
    }
  );

  return (
    <>
      {triggerEl}
      <Floating
        open={open}
        anchor={() => trigger.current?.getBoundingClientRect()}
        side={side}
        align={align}
        inkFrom={() => trigger.current}
        inside={() => [trigger.current]}
        onOutside={() => close(false)}
      >
        <MenuPanel id={menuId} entries={items} labelledBy={ariaLabel ? undefined : tid} aria-label={ariaLabel} focus={focus} onCloseAll={close} inkFrom={() => trigger.current} />
      </Floating>
    </>
  );
}
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);
}
packages/ui/src/components/DropMenu.tsx105 lines
"use client";

import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import type { CSSProperties, ReactNode } from "react";

/* Two springs of deliberately different character — the effect comes from letting
   them disagree. The grow axis is light and underdamped, so it sails past its
   final size; the cross axis is stiffer, heavier-damped and slower, so it arrives
   late. Through the overshoot the panel is briefly taller and narrower than it
   ends up, then it settles: squash and stretch out of physics rather than out of
   a hand-authored timeline. */
const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;

/* Closing is quick and flat. A bounce on the way out reads as indecision — the
   panel looks like it is not sure it wants to go. Open and close are not mirror
   images of each other, and should not be. */
const CLOSE = { duration: 0.13, ease: "easeIn" } as const;

export type DropPlacement = "up" | "down";

export type DropMenuProps = {
  open: boolean;
  /** Which way it grows. `up` for a bottom dock, `down` for a header. */
  placement?: DropPlacement;
  /** `transform-origin` utility. Defaults to the edge the menu grows from. */
  origin?: string;
  id?: string;
  className?: string;
  style?: CSSProperties;
  onMouseEnter?: () => void;
  onMouseLeave?: () => void;
  children: ReactNode;
};

/**
 * The open/close motion for a floating menu that grows out of a trigger sitting
 * against one edge — a drop-up over a dock, a sheet over a tab bar, a popover
 * under a header.
 *
 * It animates a wrapper and nothing else, so what you put inside is yours: give
 * the child the glass, the border and the padding. That split is deliberate — a
 * `backdrop-filter` on an element that is itself being transformed is weakened
 * or dropped entirely by most engines, so the blur must live on a child that is
 * holding still.
 *
 * The panel mounts only while open, which keeps a closed menu out of the tab
 * order and off the paint. The wrapper is always present, so a trigger's
 * `aria-controls` still resolves to something. Under reduced motion the elastic
 * scaling drops out and a plain fade remains.
 *
 * You own `open` — this is not a popover with its own state. Escape handling,
 * click-outside and hover intent belong to the trigger, which knows what it is.
 */
export function DropMenu({
  open,
  placement = "up",
  origin,
  id,
  className = "",
  style,
  onMouseEnter,
  onMouseLeave,
  children,
}: DropMenuProps) {
  const reduce = useReducedMotion();
  // Start nudged back toward the trigger, so it reads as coming out of it.
  const away = placement === "up" ? 4 : -4;
  const anchor = origin ?? (placement === "up" ? "origin-bottom" : "origin-top");

  return (
    <div
      id={id}
      style={style}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
      // Empty and inert when closed, so it can never sit over the page as a trap.
      className={`${className} ${open ? "" : "pointer-events-none"}`}
    >
      <LazyMotion features={domAnimation} strict>
        <AnimatePresence>
          {open && (
            <m.div
              className={anchor}
              initial={reduce ? { opacity: 0 } : { opacity: 0, scaleY: 0.82, scaleX: 0.94, y: away }}
              animate={reduce ? { opacity: 1 } : { opacity: 1, scaleY: 1, scaleX: 1, y: 0 }}
              exit={
                reduce
                  ? { opacity: 0, transition: CLOSE }
                  : { opacity: 0, scaleY: 0.9, scaleX: 0.97, y: away, transition: CLOSE }
              }
              transition={
                reduce
                  ? { duration: 0.15 }
                  : { scaleY: GROW, y: GROW, scaleX: CROSS, opacity: { duration: 0.14, ease: "easeOut" } }
              }
            >
              {children}
            </m.div>
          )}
        </AnimatePresence>
      </LazyMotion>
    </div>
  );
}