Context Menu
El menú que aparece con clic derecho — se compone con piezas, igual que Command, pero cada ítem es foco real de teclado en vez de un estado activo virtual. Se posiciona en el punto exacto del click y se acomoda solo si no entra en la pantalla — el mismo criterio para el menú raíz y para cualquier submenú.
Completo
Todo junto: grupos, submenú, atajos, radio, checkbox y una acción destructiva — el ejemplo general.
<ContextMenu>
<ContextMenuTrigger>{/* cualquier bloque — una card, una fila, un lienzo */}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuGroup>
<ContextMenuItem onSelect={copiar}>
<CopyIcon className="h-4 w-4" />
Copiar
<ContextMenuShortcut>⌘C</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem disabled>Pegar</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>Compartir</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onSelect={compartirEmail}>Por email</ContextMenuItem>
<ContextMenuItem onSelect={copiarLink}>Copiar link</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuRadioGroup value={vista} onValueChange={setVista}>
<ContextMenuRadioItem value="list">Lista</ContextMenuRadioItem>
<ContextMenuRadioItem value="grid">Cuadrícula</ContextMenuRadioItem>
</ContextMenuRadioGroup>
<ContextMenuSeparator />
<ContextMenuItem destructive onSelect={eliminar}>
<TrashIcon className="h-4 w-4" />
Eliminar
<ContextMenuShortcut>⌘⌫</ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>Básico
Solo ítems — sin íconos, atajos ni agrupación. La pieza mínima.
<ContextMenu>
<ContextMenuTrigger>Clic derecho aquí</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={copiar}>Copiar</ContextMenuItem>
<ContextMenuItem onSelect={duplicar}>Duplicar</ContextMenuItem>
<ContextMenuItem onSelect={renombrar}>Renombrar</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>Submenú
ContextMenuSub anida un panel nuevo — se abre con hover (con una demora corta para tolerar el cruce en diagonal hacia el panel), con click, o con la flecha derecha desde el teclado. Escape o la flecha izquierda cierran solo el submenú, no el árbol completo.
<ContextMenuSub>
<ContextMenuSubTrigger>Compartir</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onSelect={compartirEmail}>Por email</ContextMenuItem>
<ContextMenuItem onSelect={copiarLink}>Copiar link</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
{/* abre con hover (con una demora corta, para que cruzar en diagonal
hacia el panel no lo cierre), con click o con flecha derecha */}Atajos
ContextMenuShortcut alinea una placa de teclado a la derecha del ítem — puramente visual, no registra el atajo.
<ContextMenuItem onSelect={copiar}>
Copiar
<ContextMenuShortcut>⌘C</ContextMenuShortcut>
</ContextMenuItem>
{/* ContextMenuShortcut es decorativo — no registra el atajo real, solo
lo comunica. El key binding real vive donde corresponda en tu app */}Grupos
ContextMenuGroup + ContextMenuLabel organizan ítems relacionados bajo un encabezado en versalitas — el mismo tratamiento tipográfico que los grupos de Command.
<ContextMenuGroup>
<ContextMenuLabel>Archivo</ContextMenuLabel>
<ContextMenuItem onSelect={abrir}>Abrir</ContextMenuItem>
<ContextMenuItem onSelect={descargar}>Descargar</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuLabel>Organizar</ContextMenuLabel>
<ContextMenuItem onSelect={mover}>Mover a…</ContextMenuItem>
</ContextMenuGroup>Íconos
Cualquier ítem acepta un ícono como primer hijo — un flex row simple, sin slot dedicado ni prop de ícono.
<ContextMenuItem onSelect={duplicar}>
<DuplicateIcon className="h-4 w-4 text-muted-foreground" />
Duplicar
</ContextMenuItem>
{/* el ícono es el primer child — un flex row normal, sin slot dedicado */}Checkboxes
ContextMenuCheckboxItem es controlado — a diferencia de ContextMenuItem, togglearlo no cierra el menú.
Reglas: on · Cuadrícula: off · Compacto: off
const [reglas, setReglas] = useState(true);
<ContextMenuCheckboxItem checked={reglas} onCheckedChange={setReglas}>
Reglas
</ContextMenuCheckboxItem>
{/* togglear no cierra el menú — como un checkbox real, podés marcar
varias opciones seguidas sin que el panel se cierre entre clicks */}Radio
ContextMenuRadioGroup coordina la selección única entre sus ContextMenuRadioItem — mismo patrón que un fieldset nativo. A diferencia del checkbox, elegir una opción sí cierra el menú: es una acción completa, no un toggle.
Vista activa: Lista
const [vista, setVista] = useState("list");
<ContextMenuRadioGroup value={vista} onValueChange={setVista}>
<ContextMenuRadioItem value="list">Lista</ContextMenuRadioItem>
<ContextMenuRadioItem value="grid">Cuadrícula</ContextMenuRadioItem>
<ContextMenuRadioItem value="gallery">Galería</ContextMenuRadioItem>
</ContextMenuRadioGroup>Destructive
destructive tiñe el ítem en danger (texto, ícono vía currentColor, y el filo de foco) — reservado para acciones irreversibles.
<ContextMenuItem destructive onSelect={eliminar}>
<TrashIcon className="h-4 w-4" />
Eliminar plantilla
<ContextMenuShortcut>⌘⌫</ContextMenuShortcut>
</ContextMenuItem>
{/* destructive tiñe texto e ícono en danger (currentColor) y cambia el
filo de foco a danger — reservado para acciones irreversibles */}Sides (colisión con los bordes)
El panel se posiciona en el punto del click y se invierte solo si no entra — probá cerca de cada borde del recuadro. boundary acota esa detección a un contenedor en vez de la ventana completa, útil para esta demo y para paneles acotados en producción.
const boundaryRef = useRef<HTMLDivElement>(null);
<div ref={boundaryRef} className="relative h-72 overflow-hidden rounded-md border">
<ContextMenu>
<ContextMenuTrigger className="flex h-full w-full items-center justify-center">
Clic derecho cerca de cualquier borde
</ContextMenuTrigger>
<ContextMenuContent boundary={boundaryRef}>
<ContextMenuItem>Abrir</ContextMenuItem>
<ContextMenuItem>Duplicar</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div>
{/* boundary acota la detección de colisión a este elemento en vez de la
ventana — sin él, ContextMenuContent siempre se clampea contra el
viewport real, que es lo que querés en producción */}Código
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import { cn } from "./lib/cn";
import { EASE } from "./lib/motion";
import { usePresence } from "./lib/use-presence";
const CONTENT_EXIT_MS = 150;
const SUB_OPEN_DELAY_MS = 120;
const SUB_CLOSE_DELAY_MS = 300;
const MENU_MARKER_SELECTOR = "[data-kenza-menu-root]";
function CheckIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
);
}
function ChevronRightIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M9 6l6 6-6 6" />
</svg>
);
}
// el mismo filo dorado que el ítem activo de Command, pero disparado por
// :focus real (los ítems de este menú son focuseables de verdad, no un
// activeValue virtual) — group-focus en vez de una clase condicional
function ActiveBar({ variant = "primary" }: { variant?: "primary" | "danger" }) {
return (
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-y-1.5 left-0 w-0.5 origin-center scale-y-0 transition-transform duration-150 group-focus:scale-y-100",
EASE,
variant === "danger" ? "bg-danger" : "bg-primary",
)}
/>
);
}
const menuContentClass =
"z-50 min-w-[13rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground focus:outline-none";
const menuItemClass =
"group relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-foreground outline-none transition-colors duration-100 focus:bg-muted";
const menuItemDisabledClass = "pointer-events-none opacity-40";
// --------------------------------------------------------------------
// navegación por teclado — compartida entre ContextMenuContent y
// ContextMenuSubContent. Cada ítem es foco real (tabIndex=-1, .focus()
// programático), así que no hace falta un activeValue virtual: mover el
// foco con las flechas ya dispara :focus / group-focus solo.
// --------------------------------------------------------------------
function focusMenuItem(container: HTMLElement, direction: "first" | "last" | 1 | -1) {
const items = Array.from(container.querySelectorAll<HTMLElement>('[role^="menuitem"]:not([aria-disabled="true"])'));
if (items.length === 0) return;
if (direction === "first") {
items[0].focus({ preventScroll: true });
return;
}
if (direction === "last") {
items[items.length - 1].focus({ preventScroll: true });
return;
}
const currentIndex = items.indexOf(document.activeElement as HTMLElement);
const nextIndex = (currentIndex + direction + items.length) % items.length;
items[nextIndex]?.focus({ preventScroll: true });
}
// bajo automatización (y, más raramente, en el propio navegador) el
// primer intento de foco puede caer en el mismo frame en que el estilo
// recién se está asentando y no "pega" — reintenta unos frames en vez de
// asumir que el primer .focus() funcionó
function focusFirstMenuItemWithRetry(container: HTMLElement | null, attempt = 0) {
if (!container) return;
const first = container.querySelector<HTMLElement>('[role^="menuitem"]:not([aria-disabled="true"])');
if (!first) return;
first.focus({ preventScroll: true });
if (document.activeElement === first || attempt >= 5) return;
requestAnimationFrame(() => focusFirstMenuItemWithRetry(container, attempt + 1));
}
function handleMenuKeyDown(e: React.KeyboardEvent, container: HTMLElement) {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
focusMenuItem(container, 1);
break;
case "ArrowUp":
e.preventDefault();
focusMenuItem(container, -1);
break;
case "Home":
e.preventDefault();
focusMenuItem(container, "first");
break;
case "End":
e.preventDefault();
focusMenuItem(container, "last");
break;
case "Enter":
case " ":
e.preventDefault();
(document.activeElement as HTMLElement | null)?.click();
break;
case "ArrowRight": {
const active = document.activeElement as HTMLElement | null;
if (active?.getAttribute("data-submenu-trigger") === "true") {
e.preventDefault();
active.click();
}
break;
}
default:
break;
}
}
// cierra el menú entero si clickeás afuera de CUALQUIER nivel — root o
// submenú, cada uno portalado por separado a document.body. Un
// ref.contains() normal no alcanza porque el submenú no es descendiente
// del DOM del root (es otro portal); por eso el chequeo es por atributo,
// no por contención de nodo.
function useMenuOutsideDismiss(open: boolean, onDismiss: () => void) {
React.useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Element | null;
if (!target?.closest(MENU_MARKER_SELECTOR)) onDismiss();
};
// si la página se mueve debajo, la posición fixed del menú queda
// desalineada del punto donde se abrió — igual que un menú nativo, se
// cierra en vez de quedar flotando en el lugar equivocado
const onScroll = () => onDismiss();
document.addEventListener("pointerdown", onPointerDown);
window.addEventListener("scroll", onScroll, true);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("scroll", onScroll, true);
};
}, [open, onDismiss]);
}
function mergeRefs<T>(...refs: Array<React.Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<T | null>).current = node;
}
};
}
// --------------------------------------------------------------------
// raíz — abre/cierra y guarda el punto donde se hizo click derecho
// --------------------------------------------------------------------
interface ContextMenuRootValue {
open: boolean;
setOpen: (open: boolean) => void;
position: { x: number; y: number };
setPosition: (position: { x: number; y: number }) => void;
}
const ContextMenuRootContext = React.createContext<ContextMenuRootValue | null>(null);
function useRootContext(component: string) {
const ctx = React.useContext(ContextMenuRootContext);
if (!ctx) throw new Error(`${component} debe usarse dentro de ContextMenu`);
return ctx;
}
export interface ContextMenuProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
}
export function ContextMenu({ open: controlledOpen, onOpenChange, children }: ContextMenuProps) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : uncontrolledOpen;
const [position, setPosition] = React.useState({ x: 0, y: 0 });
const setOpen = React.useCallback(
(next: boolean) => {
if (!isControlled) setUncontrolledOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange],
);
const value = React.useMemo(() => ({ open, setOpen, position, setPosition }), [open, setOpen, position]);
return <ContextMenuRootContext.Provider value={value}>{children}</ContextMenuRootContext.Provider>;
}
export interface ContextMenuTriggerProps extends React.HTMLAttributes<HTMLDivElement> {
disabled?: boolean;
}
export const ContextMenuTrigger = React.forwardRef<HTMLDivElement, ContextMenuTriggerProps>(
({ disabled = false, onContextMenu, onKeyDown, className, children, ...props }, ref) => {
const ctx = useRootContext("ContextMenuTrigger");
return (
<div
ref={ref}
onContextMenu={(e) => {
onContextMenu?.(e);
if (disabled || e.defaultPrevented) return;
e.preventDefault();
ctx.setPosition({ x: e.clientX, y: e.clientY });
ctx.setOpen(true);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (disabled) return;
// Shift+F10 / la tecla "Menú" — el atajo nativo del sistema para
// abrir un menú contextual sin mouse
if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
ctx.setPosition({ x: rect.left, y: rect.bottom });
ctx.setOpen(true);
}
}}
className={className}
{...props}
>
{children}
</div>
);
},
);
ContextMenuTrigger.displayName = "ContextMenuTrigger";
// --------------------------------------------------------------------
// contenido — portal posicionado en el punto de click, con detección de
// colisión contra el viewport (o un boundary acotado, ver la demo de
// "sides"). transformOrigin sigue a la esquina que se usó para no
// salirse, así el paneo de entrada nace del punto real del click.
// --------------------------------------------------------------------
export interface ContextMenuContentProps extends React.HTMLAttributes<HTMLDivElement> {
/** limita la colisión a este elemento en vez de la ventana — pensado para paneles acotados, ver la demo "Sides" */
boundary?: React.RefObject<HTMLElement>;
}
export const ContextMenuContent = React.forwardRef<HTMLDivElement, ContextMenuContentProps>(
({ boundary, className, children, ...props }, forwardedRef) => {
const ctx = useRootContext("ContextMenuContent");
const localRef = React.useRef<HTMLDivElement>(null);
const focusedRef = React.useRef(false);
const [portalReady, setPortalReady] = React.useState(false);
const [coords, setCoords] = React.useState<{ x: number; y: number; originX: "left" | "right"; originY: "top" | "bottom" } | null>(null);
const { rendered, entered } = usePresence(ctx.open, CONTENT_EXIT_MS);
React.useEffect(() => setPortalReady(true), []);
React.useLayoutEffect(() => {
if (!rendered) {
setCoords(null);
focusedRef.current = false;
return;
}
const el = localRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const bounds = boundary?.current?.getBoundingClientRect() ?? {
left: 8,
top: 8,
right: window.innerWidth - 8,
bottom: window.innerHeight - 8,
};
let x = ctx.position.x;
let y = ctx.position.y;
let originX: "left" | "right" = "left";
let originY: "top" | "bottom" = "top";
if (x + rect.width > bounds.right) {
x = Math.max(bounds.left, ctx.position.x - rect.width);
originX = "right";
}
if (y + rect.height > bounds.bottom) {
y = Math.max(bounds.top, ctx.position.y - rect.height);
originY = "bottom";
}
setCoords({ x, y, originX, originY });
// eslint-disable-next-line react-hooks/exhaustive-deps -- boundary es un ref, su .current no dispara re-render por sí solo
}, [rendered, ctx.position]);
// el foco inicial se ata a "coords ya calculado", no a "entered" (el
// estado de la animación) — entered llega vía requestAnimationFrame y
// puede ganarle a este layout effect. El helper reintenta unos frames
// por si el primer .focus() cae justo cuando el estilo todavía se
// está asentando (visto bajo automatización, no solo en teoría).
React.useEffect(() => {
if (coords && !focusedRef.current) {
focusedRef.current = true;
focusFirstMenuItemWithRetry(localRef.current);
}
}, [coords]);
const onDismiss = React.useCallback(() => ctx.setOpen(false), [ctx]);
useMenuOutsideDismiss(ctx.open, onDismiss);
if (!portalReady || !rendered) return null;
return createPortal(
<div
data-kenza-menu-root=""
ref={mergeRefs(localRef, forwardedRef)}
role="menu"
aria-orientation="vertical"
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === "Escape" || e.key === "Tab") {
if (e.key === "Escape") e.preventDefault();
ctx.setOpen(false);
return;
}
handleMenuKeyDown(e, e.currentTarget);
}}
style={{
position: "fixed",
top: coords?.y ?? ctx.position.y,
left: coords?.x ?? ctx.position.x,
visibility: coords ? "visible" : "hidden",
transformOrigin: coords ? `${coords.originX} ${coords.originY}` : undefined,
}}
className={cn(
menuContentClass,
EASE,
entered ? "opacity-100 scale-100 duration-150" : "opacity-0 scale-95 duration-100",
className,
)}
{...props}
>
{children}
</div>,
document.body,
);
},
);
ContextMenuContent.displayName = "ContextMenuContent";
// --------------------------------------------------------------------
// ítems
// --------------------------------------------------------------------
export interface ContextMenuItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
disabled?: boolean;
/** tiñe el ítem en danger — para acciones irreversibles (eliminar, etc.), ver la demo "Destructive" */
destructive?: boolean;
onSelect?: () => void;
}
export const ContextMenuItem = React.forwardRef<HTMLDivElement, ContextMenuItemProps>(
({ disabled = false, destructive = false, onSelect, onClick, className, children, ...props }, ref) => {
const ctx = useRootContext("ContextMenuItem");
return (
<div
ref={ref}
role="menuitem"
aria-disabled={disabled}
tabIndex={-1}
onClick={(e) => {
onClick?.(e);
if (disabled) return;
onSelect?.();
ctx.setOpen(false);
}}
className={cn(
menuItemClass,
destructive ? "text-danger focus:bg-danger/10" : undefined,
disabled && menuItemDisabledClass,
className,
)}
{...props}
>
<ActiveBar variant={destructive ? "danger" : "primary"} />
{children}
</div>
);
},
);
ContextMenuItem.displayName = "ContextMenuItem";
export interface ContextMenuCheckboxItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
checked?: boolean;
disabled?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
export const ContextMenuCheckboxItem = React.forwardRef<HTMLDivElement, ContextMenuCheckboxItemProps>(
({ checked = false, disabled = false, onCheckedChange, onClick, className, children, ...props }, ref) => (
<div
ref={ref}
role="menuitemcheckbox"
aria-checked={checked}
aria-disabled={disabled}
tabIndex={-1}
onClick={(e) => {
onClick?.(e);
if (disabled) return;
onCheckedChange?.(!checked);
// a diferencia de ContextMenuItem, togglear un checkbox no cierra
// el menú — igual que un checkbox real, podés marcar varios seguidos
}}
className={cn(menuItemClass, "pl-7", disabled && menuItemDisabledClass, className)}
{...props}
>
<ActiveBar />
<span className={cn("absolute left-2 flex h-3.5 w-3.5 items-center justify-center text-primary", !checked && "opacity-0")}>
<CheckIcon className="h-3.5 w-3.5" />
</span>
{children}
</div>
),
);
ContextMenuCheckboxItem.displayName = "ContextMenuCheckboxItem";
interface RadioGroupContextValue {
value?: string;
onValueChange?: (value: string) => void;
}
const RadioGroupContext = React.createContext<RadioGroupContextValue | null>(null);
export interface ContextMenuRadioGroupProps {
value?: string;
onValueChange?: (value: string) => void;
children?: React.ReactNode;
}
export function ContextMenuRadioGroup({ value, onValueChange, children }: ContextMenuRadioGroupProps) {
const ctxValue = React.useMemo(() => ({ value, onValueChange }), [value, onValueChange]);
return (
<RadioGroupContext.Provider value={ctxValue}>
<div role="group">{children}</div>
</RadioGroupContext.Provider>
);
}
export interface ContextMenuRadioItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
value: string;
disabled?: boolean;
}
export const ContextMenuRadioItem = React.forwardRef<HTMLDivElement, ContextMenuRadioItemProps>(
({ value, disabled = false, onClick, className, children, ...props }, ref) => {
const ctx = useRootContext("ContextMenuRadioItem");
const group = React.useContext(RadioGroupContext);
const checked = group?.value === value;
return (
<div
ref={ref}
role="menuitemradio"
aria-checked={checked}
aria-disabled={disabled}
tabIndex={-1}
onClick={(e) => {
onClick?.(e);
if (disabled) return;
group?.onValueChange?.(value);
// a diferencia del checkbox, elegir una opción de un grupo
// exclusivo es una acción completa — cierra el menú, igual que
// los menús nativos (Finder, Explorer)
ctx.setOpen(false);
}}
className={cn(menuItemClass, "pl-7", disabled && menuItemDisabledClass, className)}
{...props}
>
<ActiveBar />
<span className={cn("absolute left-2 flex h-3.5 w-3.5 items-center justify-center", !checked && "opacity-0")}>
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
</span>
{children}
</div>
);
},
);
ContextMenuRadioItem.displayName = "ContextMenuRadioItem";
export function ContextMenuLabel({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("px-2 pb-1.5 pt-2 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground/70", className)}
{...props}
/>
);
}
export function ContextMenuGroup({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div role="group" className={cn("py-1", className)} {...props} />;
}
export function ContextMenuSeparator({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div role="separator" className={cn("my-1 h-px bg-border", className)} {...props} />;
}
export const ContextMenuShortcut = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(
({ className, ...props }, ref) => (
<span
ref={ref}
className={cn(
"ml-auto flex h-5 items-center justify-center gap-0.5 rounded-[3px] border border-border bg-muted px-1.5 font-mono text-[10px] tracking-wide text-muted-foreground",
className,
)}
{...props}
/>
),
);
ContextMenuShortcut.displayName = "ContextMenuShortcut";
// --------------------------------------------------------------------
// submenú — abre con hover (con demora, como cualquier menú nativo, para
// que cruzar en diagonal hacia el panel no lo cierre de golpe), con click
// o con flecha derecha. Posición ancla al trigger, no al punto de click.
// --------------------------------------------------------------------
interface SubContextValue {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<HTMLDivElement>;
contentRef: React.MutableRefObject<HTMLDivElement | null>;
closeTimer: React.MutableRefObject<ReturnType<typeof setTimeout> | undefined>;
}
const SubContext = React.createContext<SubContextValue | null>(null);
function useSubContext(component: string) {
const ctx = React.useContext(SubContext);
if (!ctx) throw new Error(`${component} debe usarse dentro de ContextMenuSub`);
return ctx;
}
export function ContextMenuSub({ children }: { children?: React.ReactNode }) {
const [open, setOpen] = React.useState(false);
const triggerRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement | null>(null);
const closeTimer = React.useRef<ReturnType<typeof setTimeout>>();
const value = React.useMemo(() => ({ open, setOpen, triggerRef, contentRef, closeTimer }), [open]);
return <SubContext.Provider value={value}>{children}</SubContext.Provider>;
}
export interface ContextMenuSubTriggerProps extends React.HTMLAttributes<HTMLDivElement> {
disabled?: boolean;
}
export function ContextMenuSubTrigger({ disabled = false, onClick, className, children, ...props }: ContextMenuSubTriggerProps) {
const sub = useSubContext("ContextMenuSubTrigger");
const openSub = () => {
if (disabled) return;
if (sub.closeTimer.current) clearTimeout(sub.closeTimer.current);
sub.setOpen(true);
};
const scheduleClose = () => {
sub.closeTimer.current = setTimeout(() => sub.setOpen(false), SUB_CLOSE_DELAY_MS);
};
return (
<div
ref={sub.triggerRef}
role="menuitem"
data-submenu-trigger="true"
aria-haspopup="menu"
aria-expanded={sub.open}
aria-disabled={disabled}
tabIndex={-1}
onMouseEnter={() => {
if (disabled) return;
if (sub.closeTimer.current) clearTimeout(sub.closeTimer.current);
sub.closeTimer.current = setTimeout(openSub, SUB_OPEN_DELAY_MS);
}}
onMouseLeave={() => {
if (sub.closeTimer.current) clearTimeout(sub.closeTimer.current);
scheduleClose();
}}
onClick={(e) => {
onClick?.(e);
if (disabled) return;
// el foco del primer ítem lo pone ContextMenuSubContent solo, en
// cuanto calcula su posición — no hace falta duplicarlo acá
openSub();
}}
className={cn(menuItemClass, "justify-between", disabled && menuItemDisabledClass, className)}
{...props}
>
<ActiveBar />
<span className="flex flex-1 items-center gap-2">{children}</span>
<ChevronRightIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</div>
);
}
export const ContextMenuSubContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, forwardedRef) => {
const sub = useSubContext("ContextMenuSubContent");
const rootCtx = useRootContext("ContextMenuSubContent");
const localRef = React.useRef<HTMLDivElement>(null);
const focusedRef = React.useRef(false);
const [coords, setCoords] = React.useState<{ x: number; y: number } | null>(null);
const { rendered, entered } = usePresence(sub.open, CONTENT_EXIT_MS);
React.useLayoutEffect(() => {
if (!rendered) {
setCoords(null);
focusedRef.current = false;
return;
}
const triggerEl = sub.triggerRef.current;
const el = localRef.current;
if (!triggerEl || !el) return;
const triggerRect = triggerEl.getBoundingClientRect();
const rect = el.getBoundingClientRect();
const { innerWidth, innerHeight } = window;
// leve solape con el trigger, como los submenús nativos — sin esto
// queda un hueco de 1px donde el mouse "se cae" y cierra el submenú
let x = triggerRect.right - 4;
if (x + rect.width > innerWidth - 8) x = Math.max(8, triggerRect.left - rect.width + 4);
let y = triggerRect.top - 4;
if (y + rect.height > innerHeight - 8) y = Math.max(8, innerHeight - rect.height - 8);
setCoords({ x, y });
}, [rendered]);
React.useEffect(() => {
sub.contentRef.current = localRef.current;
});
// ver la nota en ContextMenuContent — atado a coords, no a entered, con reintento
React.useEffect(() => {
if (coords && !focusedRef.current) {
focusedRef.current = true;
focusFirstMenuItemWithRetry(localRef.current);
}
}, [coords]);
if (!rendered) return null;
return createPortal(
<div
data-kenza-menu-root=""
ref={mergeRefs(localRef, forwardedRef)}
role="menu"
aria-orientation="vertical"
tabIndex={-1}
onMouseEnter={() => {
if (sub.closeTimer.current) clearTimeout(sub.closeTimer.current);
}}
onMouseLeave={() => {
if (sub.closeTimer.current) clearTimeout(sub.closeTimer.current);
sub.closeTimer.current = setTimeout(() => sub.setOpen(false), SUB_CLOSE_DELAY_MS);
}}
onKeyDown={(e) => {
if (e.key === "Tab") {
// Tab sale de todo el árbol de menús, no solo del submenú
rootCtx.setOpen(false);
return;
}
if (e.key === "Escape" || e.key === "ArrowLeft") {
e.stopPropagation();
e.preventDefault();
sub.setOpen(false);
sub.triggerRef.current?.focus({ preventScroll: true });
return;
}
handleMenuKeyDown(e, e.currentTarget);
}}
style={{
position: "fixed",
top: coords?.y ?? 0,
left: coords?.x ?? 0,
visibility: coords ? "visible" : "hidden",
}}
className={cn(
menuContentClass,
EASE,
entered ? "opacity-100 scale-100 duration-150" : "opacity-0 scale-95 duration-100",
className,
)}
{...props}
>
{children}
</div>,
document.body,
);
},
);
ContextMenuSubContent.displayName = "ContextMenuSubContent";Cada ítem es un div con tabIndex=-1 y foco real de teclado — las flechas mueven document.activeElement directamente, sin un índice virtual. Root y submenú se posicionan cada uno con su propia detección de colisión; cerrar por click afuera funciona incluso con un submenú abierto en otro portal, gracias a un atributo compartido en vez de ref.contains().
API
| Componente | Prop | Tipo | Default |
|---|---|---|---|
| ContextMenu | open / onOpenChange | boolean / (open: boolean) => void | — |
| ContextMenuTrigger | disabled | boolean | false |
| ContextMenuContent | boundary | RefObject<HTMLElement> | — |
| ContextMenuItem | disabled | boolean | false |
| ContextMenuItem | destructive | boolean | false |
| ContextMenuItem | onSelect | () => void | — |
| ContextMenuCheckboxItem | checked / onCheckedChange | boolean / (checked: boolean) => void | — |
| ContextMenuRadioGroup | value / onValueChange | string / (value: string) => void | — |
| ContextMenuRadioItem | value | string | — |
| ContextMenuLabel | — | encabezado de grupo — atributos nativos de div | |
| ContextMenuGroup | — | agrupador semántico — atributos nativos de div | |
| ContextMenuSeparator | — | hairline entre secciones | |
| ContextMenuShortcut | — | placa de atajo — hijo de ContextMenuItem | |
| ContextMenuSub / SubTrigger / SubContent | — | submenú anidado — misma API que Content | |