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.
"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.
npx shadcn@latest add https://www.gassierconsulting.com/r/capa-menu-contextualEjemplo 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.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
items? | CapaMenuContextualItem[] | Opcional. |
onSelect? | (id: string) => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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".