Ir al contenido
Evaluar mi proyecto
Componentes · Modales y capasDisponible

Menú contextual de clic derecho

Se abre con clic derecho, tecla de menú o Mayús+F10; flechas, Inicio, Fin y Escape, atajos visibles y posición dentro del área.

Haz clic derecho aquío enfoca y pulsa Mayús + F10

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

capa-menu-contextual.tsx103 líneas
Descargar
"use client";

import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent } from "react";

/** Menú contextual de clic derecho (o tecla de menú / Mayús+F10) con navegación por flechas, atajos visibles y cierre con Escape. Se posiciona dentro de su área sin desbordarla. React + TypeScript, sin dependencias. */
export type CapaMenuContextualItem = { id: string; label: string; shortcut?: string; danger?: boolean; separatorBefore?: boolean };

export type CapaMenuContextualProps = {
  items?: CapaMenuContextualItem[];
  onSelect?: (id: string) => void;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gccmc-root { box-sizing: border-box; position: relative; display: grid; place-items: center; width: 100%; max-width: 520px; height: 300px; border: 1.5px dashed #b9bdc9; border-radius: 16px; background: #fff; color: #18181b; font: 500 14px/1.3 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; overflow: hidden; outline: 0; }
.gccmc-root *, .gccmc-root *::before, .gccmc-root *::after { box-sizing: border-box; }
.gccmc-root:focus-visible { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, .25); }
@media (prefers-reduced-motion: reduce) { .gccmc-menu { animation: none !important; } }
.gccmc-hint { text-align: center; color: #71717a; pointer-events: none; }
.gccmc-hint b { display: block; margin-bottom: 4px; color: #18181b; font-size: 16px; }
.gccmc-last { position: absolute; left: 16px; bottom: 12px; font-size: 12px; color: #71717a; }
.gccmc-menu { position: absolute; z-index: 5; min-width: 220px; margin: 0; padding: 6px; list-style: none; border: 1px solid #e4e4e7; border-radius: 12px; background: rgba(255, 255, 255, .96); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); box-shadow: 0 16px 36px -12px rgba(0, 0, 0, .35); animation: gccmc-in .14s ease-out; transform-origin: top left; }
.gccmc-sep { height: 1px; margin: 5px 6px; background: #ececef; }
.gccmc-item { display: flex; align-items: center; justify-content: space-between; gap: 24px; width: 100%; min-height: 36px; padding: 0 10px; border: 0; border-radius: 7px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.gccmc-item:focus-visible, .gccmc-item:hover { outline: 0; background: #2563eb; color: #fff; }
.gccmc-item[data-d="true"] { color: #dc2626; }
.gccmc-item[data-d="true"]:focus-visible, .gccmc-item[data-d="true"]:hover { background: #dc2626; color: #fff; }
.gccmc-sc { font-size: 12px; opacity: .6; }
@keyframes gccmc-in { from { opacity: 0; transform: scale(.96); } }`;

const DEFAULT: CapaMenuContextualItem[] = [
  { id: "abrir", label: "Abrir", shortcut: "↵" },
  { id: "renombrar", label: "Renombrar", shortcut: "F2" },
  { id: "duplicar", label: "Duplicar", shortcut: "⌘D" },
  { id: "mover", label: "Mover a…", shortcut: "⌘M" },
  { id: "borrar", label: "Mover a la papelera", shortcut: "⌫", danger: true, separatorBefore: true },
];

export function CapaMenuContextual({ items = DEFAULT, onSelect, className = "", style }: CapaMenuContextualProps) {
  const root = useRef<HTMLDivElement>(null);
  const menu = useRef<HTMLUListElement>(null);
  const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
  const [last, setLast] = useState("");

  function openAt(x: number, y: number) {
    const box = root.current!.getBoundingClientRect();
    setPos({ x: Math.max(6, Math.min(x - box.left, box.width - 232)), y: Math.max(6, Math.min(y - box.top, box.height - items.length * 38 - 20)) });
  }
  function onContext(event: MouseEvent) {
    event.preventDefault();
    openAt(event.clientX, event.clientY);
  }
  function rootKey(event: KeyboardEvent) {
    if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
      event.preventDefault();
      const box = root.current!.getBoundingClientRect();
      openAt(box.left + box.width / 2, box.top + box.height / 2 - 60);
    }
  }
  useEffect(() => {
    if (!pos) return;
    menu.current?.querySelector<HTMLButtonElement>("button")?.focus();
    const away = (event: PointerEvent) => { if (!menu.current?.contains(event.target as Node)) setPos(null); };
    document.addEventListener("pointerdown", away);
    return () => document.removeEventListener("pointerdown", away);
  }, [pos]);

  function menuKey(event: KeyboardEvent) {
    const buttons = Array.from(menu.current?.querySelectorAll<HTMLButtonElement>("button") ?? []);
    const i = buttons.indexOf(document.activeElement as HTMLButtonElement);
    if (event.key === "ArrowDown") { event.preventDefault(); buttons[(i + 1) % buttons.length].focus(); }
    else if (event.key === "ArrowUp") { event.preventDefault(); buttons[(i - 1 + buttons.length) % buttons.length].focus(); }
    else if (event.key === "Home") { event.preventDefault(); buttons[0].focus(); }
    else if (event.key === "End") { event.preventDefault(); buttons[buttons.length - 1].focus(); }
    else if (event.key === "Escape" || event.key === "Tab") { event.preventDefault(); setPos(null); root.current?.focus(); }
  }

  return (
    <div ref={root} className={`gccmc-root ${className}`} style={style} tabIndex={0} onContextMenu={onContext} onKeyDown={rootKey} aria-label="Área con menú contextual. Pulsa la tecla de menú o Mayús más F10 para abrirlo.">
      <style>{CSS}</style>
      <p className="gccmc-hint">
        <b>Haz clic derecho aquí</b>
        o enfoca y pulsa Mayús + F10
      </p>
      {last ? <span className="gccmc-last" role="status">Última acción: {last}</span> : null}
      {pos ? (
        <ul ref={menu} className="gccmc-menu" role="menu" style={{ left: pos.x, top: pos.y }} onKeyDown={menuKey}>
          {items.map((item) => (
            <li key={item.id} role="none">
              {item.separatorBefore ? <div className="gccmc-sep" role="separator" /> : null}
              <button className="gccmc-item" type="button" role="menuitem" data-d={item.danger} onClick={() => { setLast(item.label); onSelect?.(item.id); setPos(null); root.current?.focus(); }}>
                {item.label}
                {item.shortcut ? <span className="gccmc-sc">{item.shortcut}</span> : null}
              </button>
            </li>
          ))}
        </ul>
      ) : null}
    </div>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/capa-menu-contextual

Ejemplo de uso de Menú contextual de clic derecho

Después de copiar capa-menu-contextual.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
import { CapaMenuContextual } from "./capa-menu-contextual";

export default function Ejemplo() {
  return <CapaMenuContextual />;
}

Propiedades de CapaMenuContextual

CapaMenuContextual acepta 4 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
items?CapaMenuContextualItem[]Opcional.
onSelect?(id: string) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 103 líneas (6 KB) y define 1 animación con @keyframes. Esto es lo que hace Menú contextual de clic derecho por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
  • Declara roles ARIA para lectores de pantalla: status, menu, none, separator, menuitem.
  • Responde al teclado, no solo al ratón.
  • Usa backdrop-filter para el efecto de vidrio; en navegadores sin soporte el panel se ve sin desenfoque.
  • Es un componente de cliente de Next.js: incluye la directiva "use client".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)