"use client"; import { Check } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion, } from "motion/react"; import { cloneElement, createContext, isValidElement, type ReactElement, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, type PointerEvent as ReactPointerEvent, type Ref, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL } from "@/lib/ease"; import { cn } from "@/lib/utils"; type OpenModality = "pointer" | "keyboard" | "touch"; type MenuPoint = { x: number; y: number }; const VIEWPORT_PADDING = 8; const LONG_PRESS_DELAY = 520; const LONG_PRESS_TOLERANCE = 10; const MORPH_DURATION = 0.3; type TriggerElementProps = React.HTMLAttributes & { ref?: Ref; }; interface ContextMenuContextValue { open: boolean; setOpen: (open: boolean) => void; openAt: (point: MenuPoint, modality: OpenModality) => void; point: MenuPoint; modality: OpenModality; invocation: number; menuId: string; triggerRef: React.MutableRefObject; contentRef: React.MutableRefObject; activeId: string | null; setActiveId: (id: string | null) => void; reduce: boolean; } const ContextMenuContext = createContext(null); function useContextMenuContext(component: string) { const context = useContext(ContextMenuContext); if (!context) { throw new Error(`${component} must be used within `); } return context; } function assignRef(ref: Ref | undefined, value: T | null) { if (typeof ref === "function") { ref(value); } else if (ref) { ref.current = value; } } function getEnabledItems(container: HTMLElement | null) { if (!container) return []; return Array.from( container.querySelectorAll( '[data-context-menu-item="true"]:not([data-disabled="true"])', ), ); } function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } function collapsedClip( origin: MenuPoint, size: { width: number; height: number }, ) { const half = 8; const top = clamp(origin.y - half, 0, size.height); const right = clamp(size.width - origin.x - half, 0, size.width); const bottom = clamp(size.height - origin.y - half, 0, size.height); const left = clamp(origin.x - half, 0, size.width); return `inset(${top}px ${right}px ${bottom}px ${left}px round 10px)`; } export interface ContextMenuProps { children: ReactNode; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; className?: string; } export function ContextMenu({ children, open: controlledOpen, defaultOpen = false, onOpenChange, className, }: ContextMenuProps) { const [internalOpen, setInternalOpen] = useState(defaultOpen); const [point, setPoint] = useState({ x: 0, y: 0 }); const [modality, setModality] = useState("pointer"); const [invocation, setInvocation] = useState(0); const [activeId, setActiveId] = useState(null); const controlled = controlledOpen !== undefined; const open = controlled ? controlledOpen : internalOpen; const triggerRef = useRef(null); const contentRef = useRef(null); const menuId = useId(); const reduce = useReducedMotion() ?? false; const setOpen = useCallback( (next: boolean) => { if (!controlled) setInternalOpen(next); onOpenChange?.(next); if (!next) setActiveId(null); }, [controlled, onOpenChange], ); const openAt = useCallback( (nextPoint: MenuPoint, nextModality: OpenModality) => { setPoint(nextPoint); setModality(nextModality); setInvocation((current) => current + 1); setActiveId(null); setOpen(true); }, [setOpen], ); useEffect(() => { if (!open) return; const onPointerDown = (event: PointerEvent) => { if (!contentRef.current?.contains(event.target as Node)) setOpen(false); }; const onWindowChange = () => setOpen(false); window.addEventListener("pointerdown", onPointerDown); window.addEventListener("resize", onWindowChange); window.addEventListener("scroll", onWindowChange); return () => { window.removeEventListener("pointerdown", onPointerDown); window.removeEventListener("resize", onWindowChange); window.removeEventListener("scroll", onWindowChange); }; }, [open, setOpen]); const value = useMemo( () => ({ open, setOpen, openAt, point, modality, invocation, menuId, triggerRef, contentRef, activeId, setActiveId, reduce, }), [ open, setOpen, openAt, point, modality, invocation, menuId, activeId, reduce, ], ); return (
{children}
); } export interface ContextMenuTriggerProps { children: ReactElement; disabled?: boolean; className?: string; } export function ContextMenuTrigger({ children, disabled = false, className, }: ContextMenuTriggerProps) { const context = useContextMenuContext("ContextMenuTrigger"); const longPressTimer = useRef | null>(null); const touchOrigin = useRef(null); const cancelLongPress = useCallback(() => { if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; } touchOrigin.current = null; }, []); useEffect(() => cancelLongPress, [cancelLongPress]); if (!isValidElement(children)) { throw new Error(" requires a single React element"); } const childProps = children.props; const childRef = children.props.ref; const onPointerDown = (event: ReactPointerEvent) => { childProps.onPointerDown?.(event); if (event.defaultPrevented || disabled || event.pointerType !== "touch") return; const origin = { x: event.clientX, y: event.clientY }; touchOrigin.current = origin; longPressTimer.current = setTimeout(() => { context.openAt(origin, "touch"); longPressTimer.current = null; touchOrigin.current = null; }, LONG_PRESS_DELAY); }; const onPointerMove = (event: ReactPointerEvent) => { childProps.onPointerMove?.(event); const origin = touchOrigin.current; if ( origin && Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > LONG_PRESS_TOLERANCE ) { cancelLongPress(); } }; const onKeyDown = (event: ReactKeyboardEvent) => { childProps.onKeyDown?.(event); if (event.defaultPrevented || disabled) return; if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) return; event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); context.openAt( { x: rect.left + Math.min(24, rect.width / 2), y: rect.top + rect.height / 2 }, "keyboard", ); }; return cloneElement(children, { ref: (node: HTMLElement | null) => { context.triggerRef.current = node; assignRef(childRef, node); }, "aria-controls": context.open ? context.menuId : undefined, "aria-haspopup": "menu", "aria-expanded": context.open, className: cn(childProps.className, className), onContextMenu: (event: ReactMouseEvent) => { childProps.onContextMenu?.(event); if (event.defaultPrevented || disabled) return; event.preventDefault(); cancelLongPress(); context.openAt({ x: event.clientX, y: event.clientY }, "pointer"); }, onKeyDown, onPointerDown, onPointerMove, onPointerUp: (event: ReactPointerEvent) => { childProps.onPointerUp?.(event); cancelLongPress(); }, onPointerCancel: (event: ReactPointerEvent) => { childProps.onPointerCancel?.(event); cancelLongPress(); }, }); } export interface ContextMenuContentProps { children: ReactNode; className?: string; ariaLabel?: string; } export function ContextMenuContent({ children, className, ariaLabel = "Context menu", }: ContextMenuContentProps) { const context = useContextMenuContext("ContextMenuContent"); const [mounted, setMounted] = useState(false); const [position, setPosition] = useState(context.point); const [origin, setOrigin] = useState({ x: 0, y: 0 }); const [size, setSize] = useState({ width: 0, height: 0 }); const [morphReady, setMorphReady] = useState(false); const typeahead = useRef(""); const typeaheadTimer = useRef | null>(null); useEffect(() => setMounted(true), []); useLayoutEffect(() => { if (!context.open) { setMorphReady(false); return; } const content = context.contentRef.current; if (!content) return; content.dataset.invocation = String(context.invocation); const rect = content.getBoundingClientRect(); const left = Math.max( VIEWPORT_PADDING, Math.min( Math.max(context.point.x, VIEWPORT_PADDING), window.innerWidth - rect.width - VIEWPORT_PADDING, ), ); const top = Math.max( VIEWPORT_PADDING, Math.min( Math.max(context.point.y, VIEWPORT_PADDING), window.innerHeight - rect.height - VIEWPORT_PADDING, ), ); setPosition({ x: left, y: top }); setSize({ width: rect.width, height: rect.height }); setOrigin({ x: clamp(context.point.x - left, 12, Math.max(12, rect.width - 12)), y: clamp(context.point.y - top, 12, Math.max(12, rect.height - 12)), }); setMorphReady(false); if (context.reduce || context.modality === "keyboard") { setMorphReady(true); return; } // Let the measured collapsed clip paint once before expanding it. Without // this preparation frame, the first invocation can batch both states and // appear at full size without the morph. let openFrame = 0; const prepareFrame = requestAnimationFrame(() => { openFrame = requestAnimationFrame(() => setMorphReady(true)); }); return () => { cancelAnimationFrame(prepareFrame); cancelAnimationFrame(openFrame); }; }, [ context.open, context.point, context.contentRef, context.invocation, context.modality, context.reduce, ]); useEffect(() => { if (!context.open) return; const frame = requestAnimationFrame(() => { const first = getEnabledItems(context.contentRef.current)[0]; first?.focus({ preventScroll: true }); }); return () => cancelAnimationFrame(frame); }, [context.open, context.contentRef]); useEffect( () => () => { if (typeaheadTimer.current) clearTimeout(typeaheadTimer.current); }, [], ); const moveFocus = (direction: 1 | -1) => { const items = getEnabledItems(context.contentRef.current); if (items.length === 0) return; const current = items.indexOf(document.activeElement as HTMLElement); const next = current < 0 ? 0 : (current + direction + items.length) % items.length; items[next]?.focus(); }; const onKeyDown = (event: ReactKeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); context.setOpen(false); context.triggerRef.current?.focus(); return; } if (event.key === "Tab") { context.setOpen(false); return; } if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); moveFocus(event.key === "ArrowDown" ? 1 : -1); return; } if (event.key === "Home" || event.key === "End") { event.preventDefault(); const items = getEnabledItems(context.contentRef.current); items[event.key === "Home" ? 0 : items.length - 1]?.focus(); return; } if ( event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey ) { typeahead.current += event.key.toLocaleLowerCase(); if (typeaheadTimer.current) clearTimeout(typeaheadTimer.current); typeaheadTimer.current = setTimeout(() => { typeahead.current = ""; }, 500); const match = getEnabledItems(context.contentRef.current).find((item) => (item.dataset.label ?? item.textContent ?? "") .trim() .toLocaleLowerCase() .startsWith(typeahead.current), ); match?.focus(); } }; if (!mounted) return null; const visualOpen = context.open && morphReady; const clipHidden = collapsedClip(origin, size); const clipShown = "inset(0px 0px 0px 0px round 12px)"; return createPortal(
event.preventDefault()} className={cn( "min-w-56 overflow-hidden rounded-xl border border-border bg-card p-1.5 text-foreground outline-none", className, )} > {children}
, document.body, ); } type ContextMenuItemTone = "default" | "destructive"; export interface ContextMenuItemProps { children: ReactNode; onSelect?: () => void; disabled?: boolean; closeOnSelect?: boolean; tone?: ContextMenuItemTone; inset?: boolean; className?: string; textValue?: string; } function ContextMenuItemBase({ children, onSelect, disabled = false, closeOnSelect = true, tone = "default", inset = false, className, textValue, role = "menuitem", ariaChecked, }: ContextMenuItemProps & { role?: "menuitem" | "menuitemcheckbox" | "menuitemradio"; ariaChecked?: boolean; }) { const context = useContextMenuContext("ContextMenuItem"); const id = useId(); const active = context.activeId === id; const checkedProps = role === "menuitem" ? {} : { "aria-checked": ariaChecked }; return ( ); } export function ContextMenuItem(props: ContextMenuItemProps) { return ; } export interface ContextMenuCheckboxItemProps extends Omit { checked: boolean; onCheckedChange?: (checked: boolean) => void; } export function ContextMenuCheckboxItem({ checked, onCheckedChange, children, ...props }: ContextMenuCheckboxItemProps) { const context = useContextMenuContext("ContextMenuCheckboxItem"); return ( onCheckedChange?.(!checked)} > {checked ? ( ) : null} {children} ); } interface ContextMenuRadioGroupContextValue { value: string; onValueChange?: (value: string) => void; } const ContextMenuRadioGroupContext = createContext(null); export interface ContextMenuRadioGroupProps { value: string; onValueChange?: (value: string) => void; children: ReactNode; className?: string; } export function ContextMenuRadioGroup({ value, onValueChange, children, className, }: ContextMenuRadioGroupProps) { const context = useMemo( () => ({ value, onValueChange }), [value, onValueChange], ); return (
{children}
); } export interface ContextMenuRadioItemProps extends Omit { value: string; } export function ContextMenuRadioItem({ value, children, ...props }: ContextMenuRadioItemProps) { const group = useContext(ContextMenuRadioGroupContext); if (!group) { throw new Error( "ContextMenuRadioItem must be used within ", ); } const checked = group.value === value; return ( group.onValueChange?.(value)} > {children} ); } export interface ContextMenuLabelProps { children: ReactNode; inset?: boolean; className?: string; } export function ContextMenuLabel({ children, inset = false, className, }: ContextMenuLabelProps) { return (
{children}
); } export interface ContextMenuSeparatorProps { className?: string; } export function ContextMenuSeparator({ className, }: ContextMenuSeparatorProps) { return (
); } export interface ContextMenuShortcutProps { children: ReactNode; className?: string; } export function ContextMenuShortcut({ children, className, }: ContextMenuShortcutProps) { return ( ); }