Un dropdown mínimo usando un listener mousedown a nivel de documento para detectar clics fuera. El menú se posiciona con absolute + top-full relativo al wrapper div. Cada DropdownItem es un botón de ancho completo para objetivos de clic consistentes.
"use client";import { useState, useRef, useEffect } from "react";interface DropdownProps { label: string; children: React.ReactNode;}export function Dropdown({ label, children
El chevron rota 180 grados cuando el dropdown está abierto usando rotate-180 y transition-transform de Tailwind. Esto proporciona un indicador visual claro del estado abierto.
Los iconos se colocan en un contenedor de tamaño fijo con shrink-0 para que permanezcan alineados incluso cuando las etiquetas varían en longitud. El text-gray-400 mantiene los iconos visualmente secundarios al texto de la etiqueta.
"use client";import { useState, useRef, useEffect } from "react";export function DropdownDivider() { return <div className="my-1 h-px bg-gray-200" role="separator" />;}export function DropdownLabel({ children }
DropdownDivider es una línea horizontal delgada con role="separator" para accesibilidad. DropdownLabel proporciona un encabezado de sección no interactivo. Juntos agrupan acciones relacionadas visual y semánticamente.
"use client";import { useState, useRef, useEffect } from "react";interface SubmenuItemProps { label: string; children: React.ReactNode;}export function SubmenuItem({ label, children
El submenú se abre en mouseEnter y se posiciona con left-full top-0 para aparecer a la derecha del elemento padre. Un chevron apuntando a la derecha señala que el elemento tiene un submenú. El gap ml-1 evita que el submenú toque el menú padre.
"use client";import { useState, useRef, useEffect, useCallback, KeyboardEvent } from "react";interface DropdownProps { trigger: React.ReactNode; children: React.ReactNode;}export function Dropdown({ trigger
Implementa el patrón de menú WAI-ARIA. Las teclas de flecha recorren elementos, Home/End saltan al primero/último, y Escape cierra el menú. Los elementos usan role="menuitem" y tabIndex={-1} para que solo un elemento sea enfocable a la vez. El botón de trigger usa aria-haspopup y aria-expanded para comunicar el estado a tecnología asistiva.
Cuando el trigger está cerca del borde derecho del viewport, un menú alineado a la izquierda se desborda fuera de pantalla. Establecer align="right" fija el menú a right-0 para que se expanda hacia la izquierda. Esto es común para menús de avatar de usuario y botones de acción en filas de tabla.
Patrón de componente compuesto -- Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, DropdownDivider, y DropdownLabel comparten estado a través de contexto. Esto mantiene la API componible mientras encapsula el comportamiento.
Renderizado a través de portal -- el menú se renderiza a través de createPortal en document.body para escapar de contenedores overflow:hidden y contextos de apilamiento. La posición se calcula desde getBoundingClientRect del trigger.
Navegación completa por teclado -- implementa el patrón de menú WAI-ARIA con ciclo ArrowDown/ArrowUp, saltos Home/End, Escape para cerrar (devuelve foco al trigger), y Tab para cerrar y mover foco naturalmente.
Gestión de foco -- al cerrar, el foco vuelve al botón de trigger a través de triggerRef.current?.focus(). Al abrir a través del teclado, el foco se mueve al primer elemento del menú usando requestAnimationFrame.
Elementos destructivos -- la prop destructive renderiza el elemento en rojo con un fondo hover rojo, advirtiendo visualmente al usuario. Esto es independiente de la prop disabled.
Indicadores de atajo de teclado -- la prop shortcut opcional renderiza un elemento <kbd> alineado a la derecha en el elemento, coincidiendo con la convención de menú del SO.
Elementos deshabilitados -- los elementos deshabilitados reciben opacity-40, cursor-not-allowed, y se saltan por el selector de navegación por teclado ([role=menuitem]:not(:disabled)).
Click-outside no funcionando con portales -- si el menú está portalado a document.body pero el listener click-outside verifica ref.contains() en el wrapper, siempre detecta el clic del menú como "afuera." Verifica ambas referencias del trigger y del menú en el manejador de clic afuera.
Menú recortado por padre overflow:hidden -- si el trigger está dentro de un contenedor con overflow-hidden, el menú posicionado absolutamente se corta. Usa un portal o cambia a position: fixed con coordenadas calculadas.
Guerras de z-index con otros elementos flotantes -- dropdowns, tooltips, modales, y toasts todos compiten por z-index. Establece una escala de z-index consistente (por ejemplo, dropdown=50, modal=60, toast=70) y documéntala.
Olvidar aria-haspopup y aria-expanded -- sin estos atributos, los lectores de pantalla no pueden comunicar que el botón abre un menú o si el menú está actualmente abierto.
Cerrar al hacer clic en elemento pero no actualizar estado -- si onClick dispara una acción asincrónica y el dropdown se cierra antes de completarse, asegúrate de que la acción no dependa del dropdown siendo montado (por ejemplo, evita referencias a elementos internos del dropdown en el callback).
Tiempo de hover de submenú -- en menús anidados, un movimiento rápido del ratón del padre al hijo puede dejar brevemente ambos elementos, haciendo que el submenú se cierre. Añade un pequeño retraso (150-200ms) en mouseLeave antes de cerrar.
Conflictos de focus trap con modales -- si un dropdown se usa dentro de un modal con un focus trap, abrir el menú portalado mueve el foco fuera de la trampa. O renderiza el dropdown dentro del DOM del modal (sin portal) o ajusta el focus trap para incluir el menú.