"use client"; import { createContext, forwardRef, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; import { ChevronRight } from "@/components/icons"; interface Position { x: number; y: number; } /** The element a menu opened from, in viewport coordinates. */ export interface ContextMenuAnchor { left: number; top: number; right: number; bottom: number; /** Line the menu up with the anchor's start (default) or end edge; flips in RTL. */ align?: "start" | "end"; } export interface ContextMenuPosition extends Position { /** Opened from a button or by keyboard: placed under (or over) this rect instead of at x/y. */ anchor?: ContextMenuAnchor; /** Gets the focus back when the menu closes. Default: what had the focus when it opened. */ trigger?: HTMLElement | null; } interface ContextMenuProps { isOpen: boolean; position: ContextMenuPosition; onClose: () => void; children: React.ReactNode; } const VIEWPORT_MARGIN = 10; const ANCHOR_GAP = 4; const HOVER_CLOSE_DELAY = 150; // ─── Which menu is open ──────────────────────────────────────── // Open menus, the one opened last at the end. Only that one answers keys. const openMenus: object[] = []; function syncOpenFlag() { if (typeof document === "undefined") return; if (openMenus.length > 0) document.documentElement.dataset.contextMenuOpen = ""; else delete document.documentElement.dataset.contextMenuOpen; } /** * True while a ContextMenu is open. App shortcut hooks check it (or the * `data-context-menu-open` attribute on ) and stand down: the menu * owns the keyboard until it closes. */ export function isContextMenuOpen(): boolean { return openMenus.length > 0; } // ─── Items of a menu level ───────────────────────────────────── const ITEM_SELECTOR = [ '[role="menuitem"]', '[role="menuitemradio"]', '[role="menuitemcheckbox"]', "button", "a[href]", 'input:not([type="hidden"])', "select", "textarea", '[tabindex]:not([tabindex="-1"])', ].join(","); function isUsable(level: HTMLElement, el: HTMLElement): boolean { if ((el as HTMLButtonElement).disabled || el.getAttribute("aria-disabled") === "true") return false; if (typeof window.getComputedStyle !== "function") return true; // Only display: a menu that just turned visible can still compute as // hidden for a frame (under reduced motion every property transitions). for (let node: HTMLElement | null = el; node && node !== level; node = node.parentElement) { if (window.getComputedStyle(node).display === "none") return false; } return true; } /** The controls of one menu level, in order: not those of a submenu inside it. */ export function menuLevelItems(level: HTMLElement): HTMLElement[] { const all = Array.from(level.querySelectorAll(ITEM_SELECTOR)).filter( (el) => el.closest('[role="menu"]') === level ); // A control inside a menu item (a plugin entry's button) is not a stop of its own. return all.filter((el) => !all.some((other) => other !== el && other.contains(el)) && isUsable(level, el)); } /** Single-line text fields keep ←/→/Home/End; ↑/↓ still move through the menu. */ function isSingleLineText(el: Element): boolean { return el instanceof HTMLInputElement && /^(text|search|email|url|tel|password|)$/.test(el.type); } /** Fields that need every key but Escape and Tab. */ function needsKeys(el: Element): boolean { if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) return true; if (el instanceof HTMLElement && el.isContentEditable) return true; return el instanceof HTMLInputElement && !isSingleLineText(el) && !/^(button|submit|reset|checkbox|radio|color|file|image)$/.test(el.type); } function focusEl(el: HTMLElement | undefined | null) { el?.focus({ preventScroll: true }); } /** * Focus the first item of a menu level once it can take the focus: entries * can render a moment after the menu (the mail menu waits for its data), and * a panel that just turned visible can count as hidden for a frame. Gives * up when the focus goes somewhere else meanwhile. Returns a cancel. */ function focusFirstItem(level: HTMLElement): () => void { const from = document.activeElement; let frame = 0; let tries = 0; let done = false; const attempt = () => { if (done) return; const active = document.activeElement; if ((active && level.contains(active)) || active !== from) { done = true; return; } const first = menuLevelItems(level)[0]; focusEl(first); if (first && document.activeElement === first) { done = true; return; } if (++tries < 30) frame = requestAnimationFrame(attempt); }; attempt(); return () => { done = true; cancelAnimationFrame(frame); }; } // Events the menu sends to a submenu's entry. const SUBMENU_OPEN = "contextmenu:submenu-open"; const SUBMENU_CLOSE = "contextmenu:submenu-close"; function submenuTriggerOf(el: Element | null): HTMLElement | null { return el instanceof HTMLElement && el.dataset.submenuTrigger !== undefined ? el : null; } /** Close the submenu whose panel this is and put the focus on its entry. */ function closeSubmenuPanel(panel: HTMLElement) { const trigger = panel.parentElement?.querySelector(":scope > [data-submenu-trigger]"); trigger?.dispatchEvent(new CustomEvent(SUBMENU_CLOSE)); } // ─── Hover and open state of one level ───────────────────────── interface MenuLevel { /** The submenu of this level that is open. */ openId: string | null; /** Open a submenu (or close them all with null), after `delay` ms unless asked again. */ request: (id: string | null, delay?: number) => void; /** A touch happened just now: the mouse events that follow are emulated. */ recentTouch: () => boolean; } const MenuLevelContext = createContext(null); const neverTouched = () => false; function useMenuLevel(recentTouch: () => boolean): MenuLevel { const [openId, setOpenId] = useState(null); const timer = useRef | null>(null); useEffect(() => () => clearTimeout(timer.current ?? undefined), []); const request = useCallback((id: string | null, delay = 0) => { clearTimeout(timer.current ?? undefined); timer.current = null; if (delay <= 0) { setOpenId(id); return; } timer.current = setTimeout(() => { // A field in the open submenu has the focus, e.g. a color input whose // system dialog is up: closing would drop its answer (#1366). const active = document.activeElement; if (active instanceof HTMLInputElement && active.closest("[data-submenu-panel]")) return; setOpenId(id); }, delay); }, []); return useMemo(() => ({ openId, request, recentTouch }), [openId, request, recentTouch]); } // ─── The menu ────────────────────────────────────────────────── /** * A menu in a portal: right-click menus, ⋮ menus, popover menus (R6). * * Keyboard: the focus moves to the first item on open; ↑/↓/Home/End move, * Enter/Space activate, → (← in RTL) or Enter opens a submenu, ← or Escape * closes it; Escape and Tab close the menu and give the focus back to the * element it opened from. While it is open it owns the keyboard: keys don't * reach the app's shortcut handlers. */ export const ContextMenu = forwardRef( ({ isOpen, position, onClose, children }, ref) => { const [mounted, setMounted] = useState(false); const [adjustedPosition, setAdjustedPosition] = useState(null); const localRef = useRef(null); const onCloseRef = useRef(onClose); useEffect(() => { onCloseRef.current = onClose; }); const lastTouchAt = useRef(0); const recentTouch = useCallback(() => Date.now() - lastTouchAt.current < 800, []); const level = useMenuLevel(recentTouch); const { request: requestLevel } = level; useEffect(() => { setMounted(true); }, []); const anchor = position.anchor; const anchorKey = anchor ? `${anchor.left},${anchor.top},${anchor.right},${anchor.bottom},${anchor.align ?? ""}` : ""; // Measure the rendered menu and clamp it inside the viewport before the // browser paints. We hide the element until this runs so the user never // sees the menu jump from an unclamped position to a clamped one. // `mounted` must be a dependency: on the very first open the menu isn't // in the DOM yet (mounted is still false), so this effect has to re-run // after the mount flip or the menu stays visibility:hidden. // // The menu can grow after it opened (a plugin frame in it reports its // height later), so it is clamped again whenever its size changes. useLayoutEffect(() => { if (!isOpen) { setAdjustedPosition(null); return; } const node = localRef.current; if (!node) return; const clamp = () => { const rect = node.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; let x = position.x; let y = position.y; if (anchor) { // Under the anchor, lined up with its start edge (end edge for // align="end"); above it when there is no room below. const rtl = getComputedStyle(node).direction === "rtl"; const alignEnd = (anchor.align === "end") !== rtl; x = alignEnd ? anchor.right - rect.width : anchor.left; y = anchor.bottom + ANCHOR_GAP; if (y + rect.height > vh - VIEWPORT_MARGIN && anchor.top - ANCHOR_GAP - rect.height >= VIEWPORT_MARGIN) { y = anchor.top - ANCHOR_GAP - rect.height; } } if (x + rect.width > vw - VIEWPORT_MARGIN) { x = vw - rect.width - VIEWPORT_MARGIN; } if (y + rect.height > vh - VIEWPORT_MARGIN) { y = vh - rect.height - VIEWPORT_MARGIN; } x = Math.max(VIEWPORT_MARGIN, x); y = Math.max(VIEWPORT_MARGIN, y); setAdjustedPosition((prev) => (prev && prev.x === x && prev.y === y ? prev : { x, y })); }; clamp(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(clamp); observer.observe(node); return () => observer.disconnect(); // anchorKey stands for anchor, which callers build anew on each render. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, mounted, position.x, position.y, anchorKey]); const isPositioned = adjustedPosition !== null; // Where the focus goes back to: the trigger the opener named, else what // had the focus when the menu opened. Read in a layout effect, before // the menu takes the focus. const returnFocusTo = useRef(null); const trigger = position.trigger; useLayoutEffect(() => { if (!isOpen) return; const active = document.activeElement; returnFocusTo.current = trigger ?? (active instanceof HTMLElement && active !== document.body ? active : null); }, [isOpen, trigger]); // Register as the open menu; give the focus back when it closes. useEffect(() => { if (!isOpen) return; const token = {}; openMenus.push(token); syncOpenFlag(); const menuNode = localRef.current; const onKeyDown = (e: KeyboardEvent) => { if (openMenus[openMenus.length - 1] !== token) return; const root = localRef.current; if (!root) return; const active = document.activeElement; const inside = active instanceof HTMLElement && root.contains(active); const current = ((inside ? active.closest('[role="menu"]') : null) as HTMLElement | null) ?? root; const consume = (preventDefault = true) => { e.stopImmediatePropagation(); if (preventDefault) e.preventDefault(); }; if (e.key === "Escape") { consume(); if (current !== root) closeSubmenuPanel(current); else onCloseRef.current(); return; } if (e.key === "Tab") { consume(); onCloseRef.current(); return; } if (e.isComposing) return; if (!inside) { // The menu opened without taking the focus (or lost it): the // arrows bring it in instead of moving the list behind. if (e.key === "ArrowDown" || e.key === "Home") { consume(); focusEl(menuLevelItems(root)[0]); } else if (e.key === "ArrowUp" || e.key === "End") { consume(); const items = menuLevelItems(root); focusEl(items[items.length - 1]); } return; } if (needsKeys(active)) return; const text = isSingleLineText(active); const rtl = getComputedStyle(root).direction === "rtl"; const forwardKey = rtl ? "ArrowLeft" : "ArrowRight"; const backKey = rtl ? "ArrowRight" : "ArrowLeft"; const submenuTrigger = submenuTriggerOf(active); switch (e.key) { case "ArrowDown": case "ArrowUp": { consume(); const items = menuLevelItems(current); if (items.length === 0) return; const index = items.indexOf(active); const delta = e.key === "ArrowDown" ? 1 : -1; const next = index === -1 ? (delta === 1 ? 0 : items.length - 1) : (index + delta + items.length) % items.length; focusEl(items[next]); return; } case "Home": case "End": { if (text) return; consume(); const items = menuLevelItems(current); focusEl(e.key === "Home" ? items[0] : items[items.length - 1]); return; } case forwardKey: if (text) return; consume(); submenuTrigger?.dispatchEvent(new CustomEvent(SUBMENU_OPEN)); return; case backKey: if (text) return; consume(); if (current !== root) closeSubmenuPanel(current); return; case "Enter": case " ": if (submenuTrigger) { consume(); submenuTrigger.dispatchEvent(new CustomEvent(SUBMENU_OPEN)); return; } // The item activates itself (the browser's default action); the // app must not also see the key (Enter opens the selected row). if (!text) consume(false); return; default: // Letters and the rest: the app's shortcuts are suspended. if (!text) consume(false); } }; window.addEventListener("keydown", onKeyDown, true); return () => { window.removeEventListener("keydown", onKeyDown, true); const index = openMenus.indexOf(token); if (index !== -1) openMenus.splice(index, 1); syncOpenFlag(); requestLevel(null); const target = returnFocusTo.current; returnFocusTo.current = null; const active = document.activeElement; const focusLeftWithMenu = !active || active === document.body || (menuNode?.contains(active) ?? false); if (target && target.isConnected && focusLeftWithMenu) focusEl(target); }; }, [isOpen, requestLevel]); // Once placed (it is invisible, and can't take the focus, before), the // first item gets the focus. useEffect(() => { if (!isOpen || !isPositioned) return; const root = localRef.current; if (!root || root.contains(document.activeElement)) return; return focusFirstItem(root); }, [isOpen, isPositioned]); const setRefs = (node: HTMLDivElement | null) => { localRef.current = node; if (typeof ref === "function") { ref(node); } else if (ref) { ref.current = node; } }; if (!mounted || !isOpen) return null; const renderPosition = adjustedPosition ?? position; return createPortal(
{ if (e.pointerType === "touch" || e.pointerType === "pen") lastTouchAt.current = Date.now(); }} > {/* Taller than the window: scroll inside the menu. */}
{children}
, document.body ); } ); ContextMenu.displayName = "ContextMenu"; // ─── Items ───────────────────────────────────────────────────── interface ContextMenuItemProps { icon?: React.ComponentType<{ className?: string }>; label: string; onClick: () => void; disabled?: boolean; destructive?: boolean; shortcut?: string; /** Stable hook for integration tests (not user-visible). */ testId?: string; /** Tooltip, e.g. the address behind a display name. */ title?: string; /** A second line under the label, e.g. why the item is disabled. */ hint?: string; } export function ContextMenuItem({ icon: Icon, label, onClick, disabled = false, destructive = false, shortcut, testId, title, hint, }: ContextMenuItemProps) { const level = useContext(MenuLevelContext); return ( ); } export function ContextMenuSeparator() { return
; } interface ContextMenuSubMenuProps { icon?: React.ComponentType<{ className?: string }>; label: string; children: React.ReactNode; /** Stable hook for integration tests (not user-visible). */ testId?: string; /** Shown but not openable. */ disabled?: boolean; /** Tooltip on the entry. */ title?: string; /** A second line under the label, e.g. why the entry is disabled. */ hint?: string; } /** * An entry with a submenu. It opens on hover (mouse), click or tap, Enter, * Space and → (← in RTL), and stays open until another entry of its menu is * hovered or chosen, ← or Escape, or the menu closes. */ export function ContextMenuSubMenu({ icon: Icon, label, children, testId, disabled = false, title, hint, }: ContextMenuSubMenuProps) { const parent = useContext(MenuLevelContext); const id = useId(); // Outside a ContextMenu (a host's own panel) it keeps its own state. const [localOpen, setLocalOpen] = useState(false); const isOpen = parent ? parent.openId === id : localOpen; const setOpen = useCallback( (open: boolean) => { if (parent) parent.request(open ? id : null); else setLocalOpen(open); }, [parent, id] ); const triggerRef = useRef(null); const panelRef = useRef(null); const focusFirstOnOpen = useRef(false); const ownLevel = useMenuLevel(parent?.recentTouch ?? neverTouched); // The menu's keyboard handler talks to the entry through DOM events. useEffect(() => { const trigger = triggerRef.current; if (!trigger) return; const onOpen = () => { if (disabled) return; setOpen(true); if (panelRef.current) focusFirstItem(panelRef.current); else focusFirstOnOpen.current = true; }; const onClose = () => { setOpen(false); focusEl(trigger); }; trigger.addEventListener(SUBMENU_OPEN, onOpen); trigger.addEventListener(SUBMENU_CLOSE, onClose); return () => { trigger.removeEventListener(SUBMENU_OPEN, onOpen); trigger.removeEventListener(SUBMENU_CLOSE, onClose); }; }, [disabled, setOpen]); const onPanelPlaced = useCallback(() => { if (!focusFirstOnOpen.current || !panelRef.current) return; focusFirstOnOpen.current = false; focusFirstItem(panelRef.current); }, []); return (
{ if (!disabled) setOpen(true); }} >
{ // A click or tap opens it. It doesn't toggle: the tap that also // fired an emulated mouseenter would close it again. e.stopPropagation(); if (!disabled) setOpen(true); }} > {Icon && } {hint ? ( {label} {hint} ) : ( {label} )}
{isOpen && !disabled && (
{children}
)}
); } interface SubMenuPanelProps extends React.HTMLAttributes { /** The side of the entry to open on when it fits; flips with the text direction. */ side?: "start" | "end"; /** Called once the panel is placed and visible. */ onPlaced?: () => void; "data-submenu-panel"?: string; } /** * A submenu's panel, placed beside the entry it is rendered in (its parent * element): on `side` when it fits in the window, else on the other side, * else clamped inside the window. It is `position: fixed`, so a pane that * clips its overflow can't cut it off. */ export const SubMenuPanel = forwardRef(function SubMenuPanel( { side = "end", onPlaced, className, style, ...rest }, ref ) { const [pos, setPos] = useState(null); const subMenuRef = useRef(null); useLayoutEffect(() => { const subEl = subMenuRef.current; const itemEl = subEl?.parentElement; if (!subEl || !itemEl) return; const itemRect = itemEl.getBoundingClientRect(); const subRect = subEl.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; // Where the panel's left edge goes on either side, if it fits there. const onRight = itemRect.right + subRect.width <= vw - VIEWPORT_MARGIN ? itemRect.right : null; const onLeft = itemRect.left - subRect.width >= VIEWPORT_MARGIN ? itemRect.left - subRect.width : null; const rightFirst = (side === "end") !== (getComputedStyle(itemEl).direction === "rtl"); const x = (rightFirst ? (onRight ?? onLeft) : (onLeft ?? onRight)) ?? Math.max(VIEWPORT_MARGIN, vw - subRect.width - VIEWPORT_MARGIN); let top = itemRect.top; if (top + subRect.height > vh - VIEWPORT_MARGIN) { top = vh - subRect.height - VIEWPORT_MARGIN; } top = Math.max(VIEWPORT_MARGIN, top); // The submenu is measured while it still sits at left/top 0, so its rect // is the origin `position: fixed` resolves against. That is the viewport, // unless a theme gives the parent menu a backdrop-filter, filter or // transform: then the parent is the containing block and viewport // coordinates land offset by the parent's own position, far from the // entry (#1149). setPos({ x: x - subRect.left, y: top - subRect.top }); }, [side]); const placed = pos !== null; const onPlacedRef = useRef(onPlaced); useEffect(() => { onPlacedRef.current = onPlaced; }); useEffect(() => { if (placed) onPlacedRef.current?.(); }, [placed]); const setRefs = (node: HTMLDivElement | null) => { subMenuRef.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }; return (
); }); interface ContextMenuHeaderProps { children: React.ReactNode; } export function ContextMenuHeader({ children }: ContextMenuHeaderProps) { return (
{children}
); }