"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
interface EntryBase {
id: string;
label: string;
/** Decorative leading icon. */
icon?: React.ReactNode;
/** Shortcut hint shown on the right, e.g. "⌘C". Display only — bind the keys yourself. */
shortcut?: string;
disabled?: boolean;
}
export interface ContextMenuAction extends EntryBase {
type?: "item";
/** Red styling for destructive actions. */
destructive?: boolean;
onSelect?: () => void;
}
export interface ContextMenuCheckbox extends EntryBase {
type: "checkbox";
checked: boolean;
onCheckedChange?: (checked: boolean) => void;
}
export interface ContextMenuRadioGroup {
type: "radio";
id: string;
/** Accessible name of the group (also shown as a small heading). */
label?: string;
value: string;
onValueChange?: (value: string) => void;
options: { value: string; label: string; shortcut?: string; disabled?: boolean }[];
}
export interface ContextMenuSeparator {
type: "separator";
id?: string;
}
export interface ContextMenuGroup {
type: "group";
id: string;
/** Group heading and accessible name. */
label: string;
items: ContextMenuEntry[];
}
export interface ContextMenuSubmenu extends Omit<EntryBase, "shortcut"> {
type: "submenu";
items: ContextMenuEntry[];
}
export type ContextMenuEntry = ContextMenuAction | ContextMenuCheckbox | ContextMenuRadioGroup | ContextMenuSeparator | ContextMenuGroup | ContextMenuSubmenu;
export interface ContextMenuProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
/** Menu structure: actions, checkboxes, radio groups, separators, labelled groups and submenus. */
items: ContextMenuEntry[];
/** The target area. Right-click, long-press, Shift+F10 or the ContextMenu key opens the menu. */
children: React.ReactNode;
/** Called with the id of any activated action, checkbox or radio option (`groupId:value`). */
onSelect?: (id: string) => void;
/** Minimum menu width in px. */
width?: number;
/** Touch long-press duration in ms. */
longPressDelay?: number;
/** Show shortcut hints. */
showShortcuts?: boolean;
/** Spring for the scale-in from the pointer. */
spring?: Spring;
/** Accent for the highlighted item tint and focus rings. */
accent?: string;
/** Accessible name of the menu. */
menuLabel?: string;
/** Disable the custom menu (the browser menu shows instead). */
disabled?: boolean;
}
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const EDGE = 8; // px kept from the viewport edge
const PANEL_PAD = 4; // panel padding, so submenus line up with their trigger
const HOVER_DELAY = 110; // ms before hover opens / closes submenus
const ITEM_SELECTOR = "[data-cm-item]";
interface MenuContext {
uid: string;
openPath: string[];
setOpenPath: (path: string[]) => void;
/** Ask the panel at `depth` to focus its first item when it mounts. */
requestFocus: (depth: number) => void;
/** True (once) when the panel at `depth` should focus its first item. */
takeFocusRequest: (depth: number) => boolean;
/** Runs `fn` after the hover delay, replacing any pending hover action. */
scheduleHover: (fn: () => void) => void;
cancelHover: () => void;
activate: (entry: ContextMenuAction | ContextMenuCheckbox) => void;
activateRadio: (group: ContextMenuRadioGroup, value: string) => void;
close: (returnFocus: boolean) => void;
showShortcuts: boolean;
reduceMotion: boolean;
spring: Spring;
width: number;
}
/** Enabled items that belong directly to this panel (not to a nested submenu). */
function panelItems(panel: HTMLElement) {
return Array.from(panel.querySelectorAll<HTMLElement>(ITEM_SELECTOR)).filter(
(el) => el.closest('[role="menu"]') === panel && el.getAttribute("aria-disabled") !== "true",
);
}
/**
* A context menu for a target area: right-click, touch long-press, Shift+F10 or the ContextMenu
* key opens it at the pointer (or the focused element). Supports actions, checkbox and radio items,
* labelled groups, separators, nested submenus (hover or ArrowRight), shortcut hints and typeahead,
* with full menu ARIA. It renders in the top layer, flips to stay in the viewport and scales in
* from the pointer on a spring. Escape, Tab or an outside click closes it and returns focus.
*/
export function ContextMenu({
items,
children,
onSelect,
width = 224,
longPressDelay = 500,
showShortcuts = true,
spring = { stiffness: 520, damping: 32, mass: 0.7 },
accent = "#FF7448",
menuLabel = "Context menu",
disabled = false,
className = "",
style,
onContextMenu,
onKeyDown,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
...props
}: ContextMenuProps) {
const uid = React.useId();
const reduceMotion = usePrefersReducedMotion();
const targetRef = React.useRef<HTMLDivElement>(null);
const rootPanelRef = React.useRef<HTMLDivElement | null>(null);
const pendingFocus = React.useRef<number | null>(null);
const hoverTimer = React.useRef(0);
const longPress = React.useRef({ timer: 0, x: 0, y: 0 });
const keyboardOpenedAt = React.useRef(-Infinity);
const [anchor, setAnchor] = React.useState<{ x: number; y: number } | null>(null);
const [openPath, setOpenPath] = React.useState<string[]>([]);
const open = (x: number, y: number, focusFirst: boolean) => {
window.clearTimeout(hoverTimer.current);
pendingFocus.current = focusFirst ? 0 : null;
setOpenPath([]);
setAnchor({ x, y });
};
const close = React.useCallback((returnFocus: boolean) => {
window.clearTimeout(hoverTimer.current);
setAnchor(null);
setOpenPath([]);
if (returnFocus) targetRef.current?.focus({ preventScroll: true });
}, []);
const activate = (entry: ContextMenuAction | ContextMenuCheckbox) => {
if (entry.disabled) return;
if (entry.type === "checkbox") {
// Toggles keep the menu open so several can be changed in a row.
entry.onCheckedChange?.(!entry.checked);
onSelect?.(entry.id);
return;
}
entry.onSelect?.();
onSelect?.(entry.id);
close(true);
};
const activateRadio = (group: ContextMenuRadioGroup, value: string) => {
group.onValueChange?.(value);
onSelect?.(`${group.id}:${value}`);
};
// While open: outside pointer, scroll, resize and window blur close the menu.
React.useEffect(() => {
if (!anchor) return;
const onPointer = (event: PointerEvent) => {
if (rootPanelRef.current?.contains(event.target as Node)) return;
close(false);
// Return focus to the target unless the click put it somewhere else (a link, an input…).
requestAnimationFrame(() => {
const active = document.activeElement;
if (!active || active === document.body) targetRef.current?.focus({ preventScroll: true });
});
};
const onScroll = (event: Event) => {
if (rootPanelRef.current?.contains(event.target as Node)) return;
close(false);
};
const onDismiss = () => close(false);
document.addEventListener("pointerdown", onPointer, true);
window.addEventListener("scroll", onScroll, { capture: true, passive: true });
window.addEventListener("resize", onDismiss);
window.addEventListener("blur", onDismiss);
return () => {
document.removeEventListener("pointerdown", onPointer, true);
window.removeEventListener("scroll", onScroll, { capture: true });
window.removeEventListener("resize", onDismiss);
window.removeEventListener("blur", onDismiss);
};
}, [anchor, close]);
React.useEffect(() => {
const press = longPress.current;
const hover = hoverTimer;
return () => {
window.clearTimeout(press.timer);
window.clearTimeout(hover.current);
};
}, []);
/** Places the root panel at the anchor, flipping left / up when it would leave the viewport. */
const mountRoot = React.useCallback(
(node: HTMLDivElement | null) => {
rootPanelRef.current = node;
if (!node || !anchor) return;
if (typeof node.showPopover === "function" && !node.matches(":popover-open")) {
try {
node.showPopover();
} catch {
// Unsupported or already open: position: fixed still applies.
}
}
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const w = node.offsetWidth;
const h = node.offsetHeight;
const flipX = anchor.x + w > vw - EDGE && anchor.x - w >= EDGE;
const flipY = anchor.y + h > vh - EDGE && anchor.y - h >= EDGE;
const left = Math.max(EDGE, Math.min(flipX ? anchor.x - w : anchor.x, vw - w - EDGE));
const top = Math.max(EDGE, Math.min(flipY ? anchor.y - h : anchor.y, vh - h - EDGE));
node.style.left = `${Math.round(left)}px`;
node.style.top = `${Math.round(top)}px`;
// Scale in from the pointer.
node.style.transformOrigin = `${Math.round(anchor.x - left)}px ${Math.round(anchor.y - top)}px`;
if (pendingFocus.current === 0) {
pendingFocus.current = null;
panelItems(node)[0]?.focus({ preventScroll: true });
} else {
node.focus({ preventScroll: true });
}
},
[anchor],
);
/* ------------------------------ target events ------------------------------ */
const cancelLongPress = () => window.clearTimeout(longPress.current.timer);
const ctx: MenuContext = {
uid,
openPath,
setOpenPath,
requestFocus: (depth) => {
pendingFocus.current = depth;
},
takeFocusRequest: (depth) => {
if (pendingFocus.current !== depth) return false;
pendingFocus.current = null;
return true;
},
scheduleHover: (fn) => {
window.clearTimeout(hoverTimer.current);
hoverTimer.current = window.setTimeout(fn, HOVER_DELAY);
},
cancelHover: () => window.clearTimeout(hoverTimer.current),
activate,
activateRadio,
close,
showShortcuts,
reduceMotion,
spring,
width,
};
return (
<>
<div
ref={targetRef}
tabIndex={0}
aria-haspopup={disabled ? undefined : "menu"}
aria-expanded={disabled ? undefined : anchor !== null}
aria-describedby={disabled ? undefined : `${uid}-hint`}
className={`relative select-none outline-hidden [-webkit-touch-callout:none] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cm-accent)] ${className}`}
style={{ ["--cm-accent" as string]: accent, ...style }}
onContextMenu={(event) => {
onContextMenu?.(event);
if (disabled || event.defaultPrevented) return;
event.preventDefault();
cancelLongPress();
// The keyboard path already opened the menu; some browsers follow up with a contextmenu event.
if (performance.now() - keyboardOpenedAt.current < 400) return;
open(event.clientX, event.clientY, false);
}}
onKeyDown={(event) => {
onKeyDown?.(event);
if (disabled || event.defaultPrevented) return;
if (event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) {
event.preventDefault();
keyboardOpenedAt.current = performance.now();
const focused = document.activeElement instanceof HTMLElement && event.currentTarget.contains(document.activeElement) ? document.activeElement : event.currentTarget;
const rect = focused.getBoundingClientRect();
open(rect.left + Math.min(24, rect.width / 2), rect.top + Math.min(24, rect.height / 2), true);
}
}}
onPointerDown={(event) => {
onPointerDown?.(event);
if (disabled || event.pointerType === "mouse") return;
cancelLongPress();
const { clientX, clientY } = event;
longPress.current = {
x: clientX,
y: clientY,
timer: window.setTimeout(() => open(clientX, clientY, false), longPressDelay),
};
}}
onPointerMove={(event) => {
onPointerMove?.(event);
if (event.pointerType === "mouse") return;
const { x, y } = longPress.current;
if (Math.hypot(event.clientX - x, event.clientY - y) > 10) cancelLongPress();
}}
onPointerUp={(event) => {
onPointerUp?.(event);
cancelLongPress();
}}
onPointerCancel={(event) => {
onPointerCancel?.(event);
cancelLongPress();
}}
{...props}
>
{children}
</div>
<span id={`${uid}-hint`} hidden>
Right-click, long-press, or press Shift+F10 for options.
</span>
<AnimatePresence>
{anchor ? (
<MenuPanel
key={`${anchor.x}:${anchor.y}`}
ref={mountRoot}
ctx={ctx}
entries={items}
depth={0}
label={menuLabel}
accent={accent}
/>
) : null}
</AnimatePresence>
</>
);
}
/* --------------------------------------------------------------------------- */
/* Panels and items */
/* --------------------------------------------------------------------------- */
interface MenuPanelProps {
ctx: MenuContext;
entries: ContextMenuEntry[];
depth: number;
/** Root panel: aria-label. */
label?: string;
/** Submenus: id of the trigger that labels them. */
labelledBy?: string;
id?: string;
accent?: string;
ref?: React.Ref<HTMLDivElement>;
}
function MenuPanel({ ctx, entries, depth, label, labelledBy, id, accent, ref }: MenuPanelProps) {
const typeahead = React.useRef({ buffer: "", timer: 0 });
const root = depth === 0;
React.useEffect(() => {
const t = typeahead.current;
return () => window.clearTimeout(t.timer);
}, []);
/** Submenus: line up with the trigger, flip to the left / shift up when they would leave the viewport. */
const mountSub = (node: HTMLDivElement | null) => {
if (!node || root) return;
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
node.style.left = "100%";
node.style.right = "auto";
node.style.top = `${-PANEL_PAD - 1}px`;
let rect = node.getBoundingClientRect();
let originX = "0%";
if (rect.right > vw - EDGE) {
node.style.left = "auto";
node.style.right = "100%";
originX = "100%";
rect = node.getBoundingClientRect();
}
if (rect.bottom > vh - EDGE) node.style.top = `${Math.round(-PANEL_PAD - 1 - (rect.bottom - (vh - EDGE)))}px`;
node.style.transformOrigin = `${originX} 0%`;
if (ctx.takeFocusRequest(depth)) panelItems(node)[0]?.focus({ preventScroll: true });
};
// Motion re-invokes callback refs on re-render: position and focus only once per mounted node.
const nodeRef = React.useRef<HTMLDivElement | null>(null);
const setRefs = (node: HTMLDivElement | null) => {
if (!node || node === nodeRef.current) return;
nodeRef.current = node;
mountSub(node);
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.RefObject<HTMLDivElement | null>).current = node;
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
// The innermost panel handles every key; parents never see it.
event.stopPropagation();
const panel = event.currentTarget;
const list = panelItems(panel);
const active = document.activeElement as HTMLElement | null;
const index = active ? list.indexOf(active) : -1;
const focusAt = (i: number) => list[(i + list.length) % list.length]?.focus({ preventScroll: true });
switch (event.key) {
case "ArrowDown":
event.preventDefault();
focusAt(index + 1);
return;
case "ArrowUp":
event.preventDefault();
focusAt(index < 0 ? -1 : index - 1);
return;
case "Home":
case "PageUp":
event.preventDefault();
focusAt(0);
return;
case "End":
case "PageDown":
event.preventDefault();
focusAt(-1);
return;
case "ArrowRight":
if (active?.getAttribute("aria-haspopup") === "menu") {
event.preventDefault();
ctx.requestFocus(depth + 1);
active.click();
}
return;
case "ArrowLeft":
if (!root) {
event.preventDefault();
ctx.setOpenPath(ctx.openPath.slice(0, depth - 1));
document.getElementById(labelledBy ?? "")?.focus({ preventScroll: true });
}
return;
case "Escape":
event.preventDefault();
ctx.close(true);
return;
case "Tab":
event.preventDefault();
ctx.close(true);
return;
case "Enter":
case " ":
if (active && index >= 0) {
event.preventDefault();
if (active.getAttribute("aria-haspopup") === "menu") ctx.requestFocus(depth + 1);
active.click();
}
return;
}
// Typeahead: jump to the next item whose label starts with the typed characters.
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
const t = typeahead.current;
window.clearTimeout(t.timer);
t.buffer += event.key.toLowerCase();
t.timer = window.setTimeout(() => (t.buffer = ""), 600);
const ordered = [...list.slice(index + 1), ...list.slice(0, index + 1)];
const single = t.buffer.split("").every((c) => c === t.buffer[0]);
const match =
ordered.find((el) => (el.dataset.label ?? "").startsWith(t.buffer)) ??
(single ? ordered.find((el) => (el.dataset.label ?? "").startsWith(t.buffer[0])) : undefined);
match?.focus({ preventScroll: true });
}
};
const motionProps = ctx.reduceMotion
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0.08 } }
: {
initial: { opacity: 0, scale: root ? 0.88 : 0.95 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: root ? 0.96 : 0.98, transition: { duration: 0.1 } },
transition: { type: "spring" as const, ...ctx.spring },
};
return (
<motion.div
ref={setRefs}
id={id}
role="menu"
aria-label={label}
aria-labelledby={labelledBy}
aria-orientation="vertical"
tabIndex={-1}
popover={root ? "manual" : undefined}
onKeyDown={onKeyDown}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
}}
{...motionProps}
className={`${root ? "fixed inset-auto m-0" : "absolute"} z-50 overflow-visible rounded-xl border border-zinc-900/10 bg-white p-1 text-[13px] text-zinc-800 shadow-[0_24px_48px_-20px_rgb(0_0_0/0.4),0_2px_6px_-2px_rgb(0_0_0/0.08)] outline-hidden dark:border-white/10 dark:bg-[#1a1d21] dark:text-zinc-200 dark:shadow-[0_24px_48px_-16px_rgb(0_0_0/0.75)]`}
style={{ minWidth: ctx.width, ["--cm-accent" as string]: accent }}
>
<Entries ctx={ctx} entries={entries} depth={depth} />
</motion.div>
);
}
function Entries({ ctx, entries, depth }: { ctx: MenuContext; entries: ContextMenuEntry[]; depth: number }) {
return (
<>
{entries.map((entry, i) => {
const key = entry.id ?? `sep-${i}`;
if (entry.type === "separator") {
return <div key={key} role="separator" className="-mx-1 my-1 h-px bg-zinc-900/[0.08] dark:bg-white/[0.08]" />;
}
if (entry.type === "group") {
const headingId = `${ctx.uid}-group-${entry.id}`;
return (
<div key={key} role="group" aria-labelledby={headingId}>
<GroupHeading id={headingId}>{entry.label}</GroupHeading>
<Entries ctx={ctx} entries={entry.items} depth={depth} />
</div>
);
}
if (entry.type === "radio") {
const headingId = `${ctx.uid}-radio-${entry.id}`;
return (
<div key={key} role="group" aria-label={entry.label ? undefined : entry.id} aria-labelledby={entry.label ? headingId : undefined}>
{entry.label ? <GroupHeading id={headingId}>{entry.label}</GroupHeading> : null}
{entry.options.map((option) => (
<Item
key={option.value}
role="menuitemradio"
ctx={ctx}
depth={depth}
label={option.label}
shortcut={option.shortcut}
disabled={option.disabled}
checked={option.value === entry.value}
indicator="radio"
onActivate={() => ctx.activateRadio(entry, option.value)}
/>
))}
</div>
);
}
if (entry.type === "submenu") return <Submenu key={key} ctx={ctx} entry={entry} depth={depth} />;
if (entry.type === "checkbox") {
return (
<Item
key={key}
role="menuitemcheckbox"
ctx={ctx}
depth={depth}
label={entry.label}
icon={entry.icon}
shortcut={entry.shortcut}
disabled={entry.disabled}
checked={entry.checked}
indicator="check"
onActivate={() => ctx.activate(entry)}
/>
);
}
return (
<Item
key={key}
role="menuitem"
ctx={ctx}
depth={depth}
label={entry.label}
icon={entry.icon}
shortcut={entry.shortcut}
disabled={entry.disabled}
destructive={entry.destructive}
onActivate={() => ctx.activate(entry)}
/>
);
})}
</>
);
}
function GroupHeading({ id, children }: { id: string; children: React.ReactNode }) {
return (
<div id={id} aria-hidden="true" className="px-2 pb-1 pt-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500">
{children}
</div>
);
}
const itemClass =
"group/item relative flex h-8 w-full cursor-default items-center gap-2 rounded-md px-2 text-left outline-hidden transition-colors duration-75 aria-disabled:pointer-events-none aria-disabled:opacity-40 [&_svg]:size-4";
/** Highlight of the focused (keyboard or hovered) item. */
const tint = "focus:bg-[color-mix(in_oklab,var(--cm-accent)_14%,transparent)] focus:text-zinc-950 dark:focus:text-white";
const destructiveTint = "text-red-600 focus:bg-red-500/10 focus:text-red-700 dark:text-red-400 dark:focus:text-red-300";
interface ItemProps {
ctx: MenuContext;
depth: number;
role: "menuitem" | "menuitemcheckbox" | "menuitemradio";
label: string;
icon?: React.ReactNode;
shortcut?: string;
disabled?: boolean;
destructive?: boolean;
checked?: boolean;
indicator?: "check" | "radio";
onActivate: () => void;
}
function Item({ ctx, depth, role, label, icon, shortcut, disabled, destructive, checked, indicator, onActivate }: ItemProps) {
return (
<div
role={role}
tabIndex={-1}
data-cm-item=""
data-label={label.toLowerCase()}
aria-disabled={disabled || undefined}
aria-checked={role === "menuitem" ? undefined : Boolean(checked)}
className={`${itemClass} ${destructive ? destructiveTint : tint}`}
onClick={() => {
if (!disabled) onActivate();
}}
onPointerMove={(event) => {
if (event.pointerType !== "mouse" || disabled) return;
if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true });
// Hovering a plain item closes deeper submenus after a short delay.
if (ctx.openPath.length > depth) ctx.scheduleHover(() => ctx.setOpenPath(ctx.openPath.slice(0, depth)));
}}
>
<span aria-hidden="true" className="flex w-4 shrink-0 items-center justify-center text-zinc-500 group-focus/item:text-current dark:text-zinc-400">
{indicator === "check" ? (
checked ? (
<svg viewBox="0 0 16 16" fill="none" className="text-[var(--cm-accent)]">
<path d="M3.5 8.5 6.5 11.5 12.5 4.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : null
) : indicator === "radio" ? (
checked ? <span className="size-1.5 rounded-full bg-[var(--cm-accent)] ring-[3px] ring-[color-mix(in_oklab,var(--cm-accent)_25%,transparent)]" /> : null
) : (
icon
)}
</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
{ctx.showShortcuts && shortcut ? (
<kbd aria-hidden="true" className="ml-4 shrink-0 font-mono text-[11px] tracking-tight text-zinc-400 dark:text-zinc-500">
{shortcut}
</kbd>
) : null}
</div>
);
}
function Submenu({ ctx, entry, depth }: { ctx: MenuContext; entry: ContextMenuSubmenu; depth: number }) {
const open = ctx.openPath[depth] === entry.id;
const triggerId = `${ctx.uid}-sub-${entry.id}`;
const panelId = `${triggerId}-menu`;
const openSub = () => ctx.setOpenPath([...ctx.openPath.slice(0, depth), entry.id]);
return (
<div role="none" className="relative">
<div
id={triggerId}
role="menuitem"
tabIndex={-1}
data-cm-item=""
data-label={entry.label.toLowerCase()}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? panelId : undefined}
aria-disabled={entry.disabled || undefined}
className={`${itemClass} ${tint} ${open ? "bg-zinc-900/[0.05] dark:bg-white/[0.06]" : ""}`}
onClick={() => {
if (!entry.disabled) openSub();
}}
onPointerMove={(event) => {
if (event.pointerType !== "mouse" || entry.disabled) return;
if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true });
if (open) ctx.cancelHover();
else ctx.scheduleHover(openSub);
}}
>
<span aria-hidden="true" className="flex w-4 shrink-0 items-center justify-center text-zinc-500 group-focus/item:text-current dark:text-zinc-400">
{entry.icon}
</span>
<span className="min-w-0 flex-1 truncate">{entry.label}</span>
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="ml-4 shrink-0 text-zinc-400 dark:text-zinc-500">
<path d="M6 3.5 10.5 8 6 12.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<AnimatePresence>
{open ? <MenuPanel key={entry.id} ctx={ctx} entries={entry.items} depth={depth + 1} id={panelId} labelledBy={triggerId} /> : null}
</AnimatePresence>
</div>
);
}