"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
/* ----------------------------------------------------------------------------
* Menu model
* ------------------------------------------------------------------------- */
type Icon = React.ComponentType<{ className?: string }>;
type Base = { id: string; label: string; icon?: Icon; shortcut?: string; disabled?: boolean };
export type MenuEntry =
| (Base & { type: "item"; destructive?: boolean; onSelect?: () => void })
| (Base & { type: "checkbox"; checked: boolean; onCheckedChange: (checked: boolean) => void })
| (Base & { type: "radio"; group: string; checked: boolean; onSelect: () => void })
| (Base & { type: "sub"; items: MenuEntry[] })
| { type: "separator"; id: string }
| { type: "label"; id: string; label: string };
type Actionable = Exclude<MenuEntry, { type: "separator" } | { type: "label" }>;
const isActionable = (e: MenuEntry): e is Actionable => e.type !== "separator" && e.type !== "label";
export interface ContextMenuProps {
items: MenuEntry[];
children: React.ReactNode;
/** Accessible name for the right-clickable area. */
label?: string;
className?: string;
onOpenChange?: (open: boolean) => void;
disabled?: boolean;
/** Open once on mount, anchored inside the area (useful for demos and onboarding hints). */
defaultOpen?: boolean;
}
type OpenState = { x: number; y: number; viaKeyboard: boolean } | null;
/* ----------------------------------------------------------------------------
* Root
* ------------------------------------------------------------------------- */
export function ContextMenu({ items, children, label = "Context area", className, onOpenChange, disabled, defaultOpen = false }: ContextMenuProps) {
const uid = React.useId();
const [open, setOpenState] = React.useState<OpenState>(null);
const targetRef = React.useRef<HTMLDivElement>(null);
const press = React.useRef<number | undefined>(undefined);
const setOpen = React.useCallback(
(v: OpenState) => {
setOpenState(v);
onOpenChange?.(!!v);
},
[onOpenChange],
);
const close = React.useCallback(
(restore: boolean) => {
setOpen(null);
if (restore) targetRef.current?.focus({ preventScroll: true });
},
[setOpen],
);
const anchor = React.useMemo<Anchor | null>(() => (open ? { kind: "point", x: open.x, y: open.y } : null), [open]);
const openAtElement = () => {
const r = targetRef.current?.getBoundingClientRect();
if (!r) return;
setOpen({ x: r.left + Math.min(40, r.width / 2), y: r.top + Math.min(40, r.height / 2), viaKeyboard: true });
};
React.useEffect(() => {
if (!defaultOpen) return;
const r = targetRef.current?.getBoundingClientRect();
if (r) setOpenState({ x: r.left + r.width * 0.42, y: r.top + r.height * 0.1, viaKeyboard: false });
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount only
}, []);
// dismiss on outside press / resize / blur
React.useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!(e.target as Element).closest?.(`[data-menu-root="${uid}"]`)) close(false);
};
const onBlur = () => close(false);
document.addEventListener("pointerdown", onDown, true);
window.addEventListener("resize", onBlur);
window.addEventListener("blur", onBlur);
return () => {
document.removeEventListener("pointerdown", onDown, true);
window.removeEventListener("resize", onBlur);
window.removeEventListener("blur", onBlur);
};
}, [open, close, uid]);
return (
<>
<div
ref={targetRef}
tabIndex={disabled ? -1 : 0}
aria-label={label}
aria-haspopup="menu"
aria-expanded={!!open}
aria-describedby={`${uid}-hint`}
onContextMenu={(e) => {
if (disabled) return;
e.preventDefault();
setOpen({ x: e.clientX, y: e.clientY, viaKeyboard: false });
}}
onKeyDown={(e) => {
if (disabled) return;
if ((e.shiftKey && e.key === "F10") || e.key === "ContextMenu") {
e.preventDefault();
openAtElement();
}
}}
onPointerDown={(e) => {
if (e.pointerType !== "touch" || disabled) return;
const { clientX, clientY } = e;
press.current = window.setTimeout(() => setOpen({ x: clientX, y: clientY, viaKeyboard: false }), 480);
}}
onPointerUp={() => window.clearTimeout(press.current)}
onPointerCancel={() => window.clearTimeout(press.current)}
onPointerMove={(e) => e.pointerType === "touch" && window.clearTimeout(press.current)}
className={cn("outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background", className)}
>
{children}
</div>
<span id={`${uid}-hint`} className="sr-only">
Right-click, long-press, or press Shift+F10 to open the menu.
</span>
<AnimatePresence>
{open && anchor && (
<MenuList
key={`${open.x}:${open.y}`}
rootId={uid}
entries={items}
anchor={anchor}
autoFocusFirst={open.viaKeyboard}
onCloseAll={close}
level={0}
/>
)}
</AnimatePresence>
</>
);
}
/* ----------------------------------------------------------------------------
* A menu surface (root or submenu)
* ------------------------------------------------------------------------- */
type Anchor = { kind: "point"; x: number; y: number } | { kind: "item"; rect: DOMRect };
function MenuList({
rootId,
entries,
anchor,
autoFocusFirst,
onCloseAll,
onCloseSelf,
level,
labelledBy,
}: {
rootId: string;
entries: MenuEntry[];
anchor: Anchor;
autoFocusFirst: boolean;
onCloseAll: (restore: boolean) => void;
onCloseSelf?: () => void;
level: number;
labelledBy?: string;
}) {
const uid = React.useId();
const reduce = useReducedMotion();
const ref = React.useRef<HTMLDivElement>(null);
const [active, setActive] = React.useState(-1);
const [sub, setSubState] = React.useState<{ index: number; viaKeyboard: boolean; anchor: Anchor; n: number } | null>(null);
const subTimer = React.useRef<number | undefined>(undefined);
const typeahead = React.useRef({ text: "", t: 0 });
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const openSub = (index: number, viaKeyboard: boolean) => {
const el = itemRefs.current[index];
if (!el) return;
setSubState((prev) => ({ index, viaKeyboard, anchor: { kind: "item", rect: el.getBoundingClientRect() }, n: (prev?.n ?? 0) + 1 }));
};
const closeSub = () => setSubState(null);
const enabled = entries.map((e, i) => (isActionable(e) && !e.disabled ? i : -1)).filter((i) => i >= 0);
// viewport-aware placement (direct style writes, before paint)
React.useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const w = el.offsetWidth;
const h = el.offsetHeight;
let x: number;
let y: number;
let ox = "left";
let oy = "top";
if (anchor.kind === "point") {
x = anchor.x;
y = anchor.y;
if (x + w > vw - 8) {
x = Math.max(8, x - w);
ox = "right";
}
if (y + h > vh - 8) {
y = Math.max(8, y - h);
oy = "bottom";
}
} else {
x = anchor.rect.right - 4;
y = anchor.rect.top - 5;
if (x + w > vw - 8) {
x = Math.max(8, anchor.rect.left - w + 4);
ox = "right";
}
if (y + h > vh - 8) y = Math.max(8, vh - 8 - h);
}
el.style.left = `${x}px`;
el.style.top = `${y}px`;
el.style.transformOrigin = `${ox} ${oy}`;
}, [anchor]);
React.useEffect(() => {
if (autoFocusFirst && enabled.length) setActive(enabled[0]);
else ref.current?.focus({ preventScroll: true });
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount only
}, []);
React.useEffect(() => {
if (active >= 0) itemRefs.current[active]?.focus({ preventScroll: true });
}, [active]);
React.useEffect(() => () => window.clearTimeout(subTimer.current), []);
const move = (dir: 1 | -1) => {
if (!enabled.length) return;
const pos = enabled.indexOf(active);
const next = pos === -1 ? (dir === 1 ? 0 : enabled.length - 1) : (pos + dir + enabled.length) % enabled.length;
setActive(enabled[next]);
closeSub();
};
const activate = (i: number, viaKeyboard: boolean) => {
const e = entries[i];
if (!e || !isActionable(e) || e.disabled) return;
if (e.type === "sub") {
window.clearTimeout(subTimer.current);
openSub(i, viaKeyboard);
return;
}
if (e.type === "checkbox") e.onCheckedChange(!e.checked);
else if (e.type === "radio") e.onSelect();
else {
e.onSelect?.();
onCloseAll(true);
}
};
const onKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
move(1);
break;
case "ArrowUp":
e.preventDefault();
move(-1);
break;
case "Home":
e.preventDefault();
if (enabled.length) setActive(enabled[0]);
break;
case "End":
e.preventDefault();
if (enabled.length) setActive(enabled[enabled.length - 1]);
break;
case "ArrowRight":
if (active >= 0 && entries[active]?.type === "sub") {
e.preventDefault();
activate(active, true);
}
break;
case "ArrowLeft":
if (level > 0) {
e.preventDefault();
e.stopPropagation();
onCloseSelf?.();
}
break;
case "Enter":
case " ":
e.preventDefault();
if (active >= 0) activate(active, true);
break;
case "Escape":
e.preventDefault();
e.stopPropagation();
if (level > 0) onCloseSelf?.();
else onCloseAll(true);
break;
case "Tab":
e.preventDefault();
onCloseAll(true);
break;
default:
if (e.key.length === 1 && /\S/.test(e.key)) {
const now = performance.now();
const ta = typeahead.current;
ta.text = now - ta.t > 600 ? e.key.toLowerCase() : ta.text + e.key.toLowerCase();
ta.t = now;
const start = Math.max(0, enabled.indexOf(active));
const order = [...enabled.slice(start + (ta.text.length === 1 ? 1 : 0)), ...enabled.slice(0, start + 1)];
const hit = order.find((i) => {
const en = entries[i];
return isActionable(en) && en.label.toLowerCase().startsWith(ta.text);
});
if (hit !== undefined) setActive(hit);
}
}
};
const subEntry = sub ? entries[sub.index] : undefined;
return (
<>
<motion.div
ref={ref}
data-menu-root={rootId}
role="menu"
aria-orientation="vertical"
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : "Context menu"}
tabIndex={-1}
onKeyDown={onKeyDown}
onContextMenu={(e) => e.preventDefault()}
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, transition: { duration: 0.1 } }}
transition={{ type: "spring", stiffness: 700, damping: 38 }}
style={{ position: "fixed", left: 0, top: 0, zIndex: 50 + level }}
className="max-h-[calc(100dvh-1rem)] min-w-56 max-w-[calc(100vw-1rem)] overflow-y-auto rounded-xl border bg-popover p-1 text-sm text-popover-foreground shadow-xl shadow-black/10 outline-none backdrop-blur dark:shadow-black/50"
>
{entries.map((e, i) => {
if (e.type === "separator") return <div key={e.id} role="separator" className="-mx-1 my-1 h-px bg-border" />;
if (e.type === "label")
return (
<div key={e.id} role="presentation" className="px-2.5 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/80">
{e.label}
</div>
);
const isActive = active === i;
const role = e.type === "checkbox" ? "menuitemcheckbox" : e.type === "radio" ? "menuitemradio" : "menuitem";
const checked = e.type === "checkbox" || e.type === "radio" ? e.checked : undefined;
const Icon = e.icon;
return (
<div
key={e.id}
ref={(el) => {
itemRefs.current[i] = el;
}}
id={`${uid}-i-${i}`}
role={role}
tabIndex={-1}
aria-disabled={e.disabled || undefined}
aria-checked={checked}
aria-haspopup={e.type === "sub" ? "menu" : undefined}
aria-expanded={e.type === "sub" ? sub?.index === i : undefined}
onPointerMove={() => {
if (e.disabled) return;
if (active !== i) setActive(i);
window.clearTimeout(subTimer.current);
if (e.type === "sub") {
if (sub?.index !== i) subTimer.current = window.setTimeout(() => openSub(i, false), 110);
} else if (sub) subTimer.current = window.setTimeout(closeSub, 180);
}}
onPointerLeave={() => {
if (!sub && active === i) setActive(-1);
}}
onClick={() => activate(i, false)}
className={cn(
"relative flex h-8 cursor-default select-none items-center gap-2.5 rounded-lg px-2.5 outline-none",
e.disabled && "opacity-40",
e.type === "item" && e.destructive ? "text-destructive" : "",
)}
>
{(isActive || sub?.index === i) && (
<motion.span
layoutId={reduce ? undefined : `${uid}-hl`}
className={cn("absolute inset-0 rounded-lg", e.type === "item" && e.destructive ? "bg-destructive/10" : "bg-accent")}
transition={{ type: "spring", stiffness: 800, damping: 50 }}
/>
)}
<span className="relative grid size-4 shrink-0 place-items-center text-muted-foreground">
{e.type === "checkbox" ? (
<AnimatePresence initial={false}>
{e.checked && (
<motion.span initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} transition={{ type: "spring", stiffness: 700, damping: 24 }}>
<Check className="size-4 text-foreground" strokeWidth={2.5} />
</motion.span>
)}
</AnimatePresence>
) : e.type === "radio" ? (
<span className="grid size-3.5 place-items-center rounded-full border border-foreground/30">
{e.checked && <motion.span layoutId={reduce ? undefined : `${uid}-radio-${e.group}`} className="size-1.5 rounded-full bg-foreground" />}
</span>
) : Icon ? (
<Icon className={cn("size-4", e.type === "item" && e.destructive && "text-destructive")} />
) : null}
</span>
<span className="relative flex-1 truncate">{e.label}</span>
{e.shortcut && <span className="relative ml-4 text-xs tracking-wider text-muted-foreground">{e.shortcut}</span>}
{e.type === "sub" && <ChevronRight className="relative size-3.5 text-muted-foreground" aria-hidden />}
</div>
);
})}
</motion.div>
<AnimatePresence>
{sub && subEntry?.type === "sub" && (
<MenuList
key={`${subEntry.id}-${sub.viaKeyboard ? sub.n : 0}`}
rootId={rootId}
entries={subEntry.items}
anchor={sub.anchor}
autoFocusFirst={sub.viaKeyboard}
onCloseAll={onCloseAll}
onCloseSelf={() => {
const idx = sub.index;
closeSub();
setActive(idx);
itemRefs.current[idx]?.focus({ preventScroll: true });
}}
level={level + 1}
labelledBy={`${uid}-i-${sub.index}`}
/>
)}
</AnimatePresence>
</>
);
}