← Componentes

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.

Clic derecho sobre "brief-alba.pdf"
<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.

Clic derecho aquí
<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.

Clic derecho aquí
<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.

Clic derecho aquí
<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.

Clic derecho aquí
<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.

Clic derecho aquí
<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ú.

Clic derecho aquí

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.

Clic derecho aquí

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.

Clic derecho aquí
<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.

Probá clic derecho cerca de cada borde de este recuadro
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

ComponentePropTipoDefault
ContextMenuopen / onOpenChangeboolean / (open: boolean) => void—
ContextMenuTriggerdisabledbooleanfalse
ContextMenuContentboundaryRefObject<HTMLElement>—
ContextMenuItemdisabledbooleanfalse
ContextMenuItemdestructivebooleanfalse
ContextMenuItemonSelect() => void—
ContextMenuCheckboxItemchecked / onCheckedChangeboolean / (checked: boolean) => void—
ContextMenuRadioGroupvalue / onValueChangestring / (value: string) => void—
ContextMenuRadioItemvaluestring—
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