Crea un menú desplegable
En esta guía, se abordan todos los menús emergentes: desplegables, contextuales, ampliados y las barras de menú que los abren. El ejemplo práctico es un menú de comandos de la aplicación, en el que los elementos cambian la interfaz (crear, guardar, preferencias).
Comandos, vínculos y qué compone un menú
Dos opciones independientes. Todo lo demás que se menciona aquí se aplica a ambos.
- Elemento, por artículo. Un comando es un
<button type="button">; un destino es un<a href>que se sigue abriendo en una pestaña nueva, se copia como una URL y funciona sin secuencia de comandos. Un menú desplegable puede mezclarlos libremente: dos vínculos y seis comandos, o un comando y seis vínculos, no cambia nada de lo que se indica a continuación. - Modelo de interacción, por menú desplegable. Las teclas de flecha,
Home/EndyEsclo convierten en un menú: agrega el grupo de enfoque, deja que infieramenuitemen cada elemento, ya sea un botón o un vínculo, y haz que se vea como uno (una barra continua o un activador con un indicador de menú desplegable; consulta "Barra de menú o botones de menú independientes" a continuación). Una lista corta de vínculos por los que los usuariosTabes la navegación: sin focusgroup, sinrole="menu",role="menuitem"niaria-haspopup="menu"; conserva el popover,Escy el restablecimiento del enfoque, y consulta la guía de la barra de navegación.
El contenido nunca decide los roles. Un menú desplegable de vínculos "Productos" puede ser un menú; un menú desplegable de comandos "Cuenta" no puede ser de navegación.
Usa el patrón de menú solo para un widget compuesto con una parada de tabulación y navegación con teclas de flecha. Usa un <div focusgroup="menu nomemory">, no el elemento HTML <menu>. No agregues role="menu" ni role="menuitem" en el lenguaje de marcado compartido: el focusgroup nativo infiere esos roles, y los roles explícitos bloquean esa inferencia. Agrégalas solo en el resguardo para navegadores no compatibles que se muestra a continuación. nomemory hace que la reapertura comience en el primer elemento. Focusgroup mueve el enfoque; la secuencia de comandos controla la vista previa, la activación y el restablecimiento del enfoque.
Abre y posiciona el menú
Haz que cada activador sea un invocador de comandos con commandfor y command="toggle-popover". El navegador abre y cierra el cuadro emergente cuando se hace clic, se presiona Intro y se presiona la barra espaciadora, y mantiene el activador dentro del grupo de cierre rápido de su cuadro emergente, por lo que, si se hace clic en un activador abierto, se cierra. No uses popovertarget ni popovertargetaction; este patrón usa commandfor en su lugar.
Coloca cada cuadro emergente inmediatamente después de su activador. Los navegadores agregan una relación aria-details implícita solo cuando el cuadro emergente no es el siguiente elemento hermano, por lo que esta ubicación lo evita sin atributos adicionales.
<!-- Command invoker: the browser toggles the popover on click, Enter, and Space -->
<button id="file-trigger" class="trigger" type="button" aria-haspopup="menu" aria-expanded="false"
commandfor="file-menu" command="toggle-popover">
File
</button>
<!-- Immediately after its trigger, so browsers add no implicit aria-details -->
<div id="file-menu" popover="auto">
<!-- Nested, not on the popover: Chromium otherwise drops focusgroup keyboard behavior -->
<div focusgroup="menu nomemory" aria-labelledby="file-trigger">
<button type="button">New file</button>
<button type="button">Save</button>
<button id="export-trigger" type="button" aria-haspopup="menu" aria-expanded="false"
commandfor="export-menu" command="toggle-popover">
Export <span aria-hidden="true">›</span>
</button>
<!-- Submenu: popover="auto" directly after its trigger, inside the parent focusgroup -->
<div id="export-menu" popover="auto">
<div focusgroup="menu nomemory" aria-labelledby="export-trigger">
<button type="button">PDF</button>
<button type="button">Plain Text</button>
</div>
</div>
</div>
</div>
Asigna un nombre a cada menú con aria-labelledby que apunte a su activador. Mantén los indicadores decorativos de submenú en CSS o agrega aria-hidden="true". Asigna un estilo activo visible a los activadores con aria-expanded="true" para que los usuarios puedan saber qué menú está abierto o en vista previa.
Barra de menú o botones de menú independientes
Elige un formulario para los activadores de nivel superior. El rol, el modelo de teclado y el diseño deben coincidir: los botones separados visualmente indican una detención de Tab cada uno.
- Menubar.
role="menubar"en el contenedor (nunca en un cuadro emergente), con el estilo de una barra continua sin espacios entre los activadores. Un paso deTab;ArrowLeft/ArrowRightpara moverse entre activadores; coloca el cursor sobre los interruptores para abrir los menús. Se usa para una barra de comandos de la aplicación. - Botones de menú independientes Cada activador es su propio punto de tabulación
Taby se ve como un botón independiente. No hayrole="menubar", no hay teclas de flecha entre los activadores, no hay vista previa en el enfoque, no hay cambio al colocar el cursor sobre un elemento. Encierra en<nav>si navegan; de lo contrario, no asignes ningún rol. Se usa para barras de herramientas y encabezados de página.
En el resto de esta guía, se describe el formato de barra de menú. Para los botones independientes, mantén todo dentro de cada menú y descarta el comportamiento a nivel de la barra de menú.
Posiciona el menú con el posicionamiento de anclaje de CSS
Usa el posicionamiento de anclaje de CSS, no las coordenadas de JavaScript. Tanto command="toggle-popover" como showPopover({ source: trigger }) establecen un ancla implícita, por lo que no debes agregar reglas anchor-name ni position-anchor por menú. El comportamiento del menú no debe depender del posicionamiento de anclaje.
/* Top-level menus sit below their trigger. The implicit anchor comes from the invoker or source. */
[popover] {
position-area: block-end span-inline-end;
inset: auto;
}
/* A popover nested in another popover is a submenu: beside its parent item, flipping when it would overflow. */
[popover] [popover] {
position-area: inline-end span-block-end;
position-try-fallbacks: flip-inline, flip-block, --below;
}
/* Last resort for narrow viewports: directly below the trigger, indented, so the submenu never
covers its trigger. */
@position-try --below {
position-area: block-end span-inline-end;
margin-inline-start: 1rem;
/* Fit the area: the submenu's minimum width must not stop this fallback from fitting. */
min-inline-size: 0;
max-inline-size: calc(100% - 1rem);
}
Mantén --below al final para que se aplique solo cuando no se ajuste a ningún lado. Se abre directamente debajo del activador, por lo que, en una pantalla estrecha, cubre los elementos hermanos que se encuentran debajo del activador. Esa es la compensación aceptada para permanecer junto al activador. También permite que el submenú se reduzca al área (min-inline-size: 0, max-inline-size menos la sangría). Se omite un resguardo que no se ajusta y, si ninguno se ajusta, el navegador mantiene la posición base, por lo que un submenú que es unos píxeles demasiado ancho cubre su activador de forma silenciosa.
Para ver cómo funcionan las opciones de resguardo de inversión en un cuadro emergente simple, consulta Menús desplegables que se adaptan al viewport. Esa guía posiciona un grupo de botones, no un menú. Para los menús, tienen prioridad la ubicación del submenú y el resguardo de --below que se mencionó anteriormente.
Cómo animar la apertura y el cierre
Opcional. Menos de 150 ms, solo opacity y un pequeño translate, lo mismo para los submenús, dentro de @media (prefers-reduced-motion: no-preference). El enfoque se mueve en toggle, por lo que la transición nunca retrasa a los usuarios que usan el teclado.
@media (prefers-reduced-motion: no-preference) {
/* Closing: this rule is in effect once :popover-open is removed. */
[popover] {
opacity: 0;
translate: 0 -.25rem;
transition: opacity .15s, translate .15s, display .15s allow-discrete, overlay .15s allow-discrete;
}
/* Opening: faster. */
[popover]:popover-open {
opacity: 1;
translate: 0 0;
transition-duration: .1s;
@starting-style { opacity: 0; translate: 0 -.25rem; }
}
/* Switching between open menubar menus is instant, as in native menubars. */
[role="menubar"]:has(> [popover]:popover-open) > [popover] {
transition: none;
}
}
@starting-style no es una repetición de [popover]: un cuadro emergente cerrado es display: none, y las transiciones no se ejecutan desde un estado no renderizado, por lo que proporciona los valores anteriores para la apertura. Sin ella, la apertura se ajusta y solo se anima el cierre. display y overlay con allow-discrete mantienen el cuadro emergente renderizado y en la capa superior hasta que finaliza la transición de cierre.
Teclado y enfoque
No agregues un segundo sistema de índice de tabulación móvil dentro de un grupo de enfoque. Script solo abarca lo que no hacen invokers ni focusgroup:
- Una tabulación para la barra de menú.
Tabingresa en el primer activador de nivel superior.ArrowLeftyArrowRightse mueven entre los activadores de nivel superior y se ajustan en cualquier extremo.HomeyEnden un activador ingresan a su menú en el primer o último elemento.- Nota: El activador al que vuelve
Tab. El APG permite el primer activador o el último que se usó. La guía especifica la primera, pero la demostración mueve la tabulación conArrowLeftyArrowRight, por lo que, después de la navegación con el teclado,Shift+Tabvuelve al último activador alcanzado. El uso del puntero no lo mueve, por lo que la misma secuencia puede llegar a un activador diferente. Cualquiera de los dos comportamientos es aceptable; elige uno y mantenlo de forma coherente.
- Nota: El activador al que vuelve
- Vista previa. Cuando se enfoca un activador de nivel superior, se muestra su menú y se establece
aria-expanded="true". El enfoque permanece en el activador y no se enfoca ningún elemento. Cuando se desenfoca el activador, se cierra un menú de solo vista previa. - Presiona Intro y la barra espaciadora en una vista previa. Cierran la vista previa y mantienen el enfoque en el activador. La acción nativa
toggle-popoverse encarga de esto, por lo que la secuencia de comandos no debe interceptar estas claves. - Presiona la flecha hacia abajo en una vista previa. Lo convierte en un menú abierto y enfoca el primer elemento.
- Haz clic con el puntero en un activador abierto o con vista previa. Cierra la app de forma nativa, sin ingresar al menú.
- Diferencia conocida en los activadores de submenús. Un clic del puntero en un activador de submenú también activa o desactiva de forma nativa, por lo que puede cerrar un submenú que ya se haya previsualizado al colocar el cursor sobre él o abrir uno que aún no haya aparecido. Los menús nativos solo lo abren y nunca lo cierran. Esto es inofensivo, por lo que la guía lo acepta.
- Se hace clic con el puntero en un activador cerrado. Se abre de forma nativa. El enfoque permanece en el activador; no se enfoca ningún elemento. Solo las aperturas del teclado ingresan al menú.
- Puntero sobre un elemento. Enfoca el elemento sin un anillo, por lo que el desplazamiento y el enfoque son un solo elemento.
- Puntero sobre otro activador de nivel superior mientras un menú está abierto. Cierra el menú y muestra una vista previa del elemento sobre el que se coloca el cursor, enfocando su activador sin un anillo. No hay nada abierto: colocar el cursor sobre un elemento no hace nada.
- Puntero sobre un activador de submenú. Muestra una vista previa de su submenú después de un breve retraso (el enfoque del puntero ejecuta la misma vista previa en el enfoque). Un elemento hermano lo cierra.
- En un menú
ArrowUpyArrowDownse mueven entre los elementos habilitados.HomeyEndse mueven al primer y último elemento habilitado, que controla la secuencia de comandos. - Flecha hacia la izquierda o Flecha hacia la derecha en un menú de nivel superior abierto. Cierra el menú y muestra una vista previa del menú adyacente con el enfoque en su activador, que se ajusta en cualquiera de los extremos. Luego, el
ArrowDownlo ingresa. Las teclas de flecha izquierda y derecha nunca se detienen dentro de un menú. - ArrowRight en un activador de submenú. Abre el submenú y enfoca su primer elemento.
- ArrowRight en un elemento de submenú sin un submenú secundario. Cierra la jerarquía y muestra una vista previa del siguiente menú de nivel superior, mientras que el enfoque permanece en su activador. Luego, el
ArrowDownlo ingresa. - Flecha hacia la izquierda o Escape en un submenú. Cierra solo ese submenú y restablece el enfoque en su activador. El menú superior permanece abierto.
- Presiona Intro o la barra espaciadora en un comando. La activa y cierra todos los menús abiertos.
- Tabulador. Sale del sistema de menús. No bloquees el enfoque ni recorras cada comando. El cierre no debe dejar el foco dentro del contenido oculto.
El evento command se activa antes de que toggle-popover abra el menú, por lo que el enfoque no se puede mover al menú allí. En su lugar, mueve el enfoque en el evento toggle. HTML restablece el enfoque solo para el primer popover="auto" de una pila, por lo que los niveles anidados deben restablecer el enfoque a su activador de forma explícita.
Implementa la interacción
Implementa los comportamientos anteriores para tu propio lenguaje de marcado. El manejo de las flechas, Home/End, el ajuste de línea y Tab para salir es código keydown común. A continuación, se agrupan por tipo de entrada algunas partes en las que es fácil equivocarse.
Control del teclado y el enfoque
- Un camino abierto. La apertura con el teclado llama a
trigger.click(), por lo que eltoggle-popoverdel invocador es la única forma de abrir un menú. Marca el menú para que el controladortogglelo ingrese, solo se abre el teclado. Cuando se abre un puntero,toggleenfoca el activador sin un anillo, ya quepointerdownimpidió el enfoque y las teclas de flecha deben seguir funcionando. - Vista previa enfocada. Cuando se enfoca un activador, se llama a
showPopover({ source: trigger })para que el menú se fije en él. Ignora el enfoque que regresa desde el interior del propio menú del activador o desde otra ventana (marca en la ventanablur, se borra una marca después de la ventanafocuso el cambio de app vuelve a abrir el último menú). Oculta un menú de vista previa enblur, a menos que el enfoque se haya movido a él. - Mueve el enfoque en
toggle, no encommand.commandse activa antes de que se abra el menú. - Restablece el enfoque al activador por tu cuenta. Enfoca el activador antes de
hidePopover(). Después de la activación de un puntero, restablece el enfoque sin un anillo (focus({ focusVisible: false })) o el activador muestra el contorno del enfoque del teclado después de un clic del mouse. Las activaciones del teclado mantienen el anillo. Un clic conevent.detail === 0provino del teclado. - Cierra un nivel a la vez. Llama a
stopPropagation()enEscapeyArrowLeften un submenú, o el objeto de escucha del elemento superior también lo cerrará.
Control de punteros
- Activa o desactiva los clics del puntero. Llama a
preventDefault()enpointerdownpara que el clic no mueva el enfoque ni inicie una vista previa a mitad del clic. Recuerda qué activador se presionó. Si haces clic en un activador cerrado o con vista previa, se abre o se cierra de forma nativa y nunca se ingresa al menú. Solo se ingresa cuando se abre con el teclado. No interceptes el clic. - El desplazamiento sigue el enfoque. Aplica un estilo diferente a
:hovery:focus-visible. Enpointermovesobre un elemento, llama afocus({ focusVisible: false }). De todos modos, los navegadores que ignoran la opción no pintan ningún anillo para el enfoque del puntero. - Los interruptores de desplazamiento abren menús. En
pointerenterde un activador de nivel superior mientras otro menú de nivel superior está abierto: lo oculta, enfoca el activador sobre el que se coloca el cursor sin un anillo y la vista previa en el enfoque (ver arriba) lo muestra. No hay nada abierto: no hacer nada. - Triángulo seguro. Un puente de CSS fijo es demasiado estrecho: solo funciona cuando el puntero comienza cerca del borde del elemento superior. Deja que CSS dibuje el puente y usa la secuencia de comandos solo para informar el puntero:
- Ejecuta esto solo cuando
matchMedia('(hover: hover)').matches; no es relevante para la pantalla táctil ni el teclado. - Se inicia cuando el puntero sale de un activador de submenú cuyo submenú está abierto. Omite un submenú
--below, que no tiene borde lateral. - Establece
--bridge-xy--bridge-y(el puntero, en px en relación con el submenú),--bridge-edge(el eje x del borde cercano del submenú:0cuando el puntero está a su izquierda, su ancho cuando se volteó) y--bridge-heighten el submenú, ydata-bridgepara activar el puente. - Detente después de unos 300 ms, cuando el puntero llegue a un elemento del submenú o cuando se cierre el submenú. Quita el objeto de escucha y el atributo. No se ejecuta nada después de eso.
- CSS le da al submenú un
::beforerecortado en un triángulo desde el puntero hasta las esquinas cercanas al borde. Se ubica sobre los elementos hermanos en la ruta del puntero, por lo que nunca reciben el evento de colocar el cursor sobre ellos que cerraría el submenú.clip-pathtambién limita las pruebas de posicionamiento, por lo que el resto del menú parental sigue siendo interactivo. - El submenú necesita
overflow: visible(un cuadro emergente se recorta de forma predeterminada) y el pseudoelemento se extiende más allá de él, por lo que se deben desplazar los puntos de la forma en esa medida. - Seguimiento: Una ruta curva (comandos de curva
shape()) sigue los arcos reales del puntero mejor que un triángulo recto.
- Ejecuta esto solo cuando
[popover] [popover] {
overflow: visible;
&[data-bridge]::before {
content: "";
position: absolute;
inset: -100vh -100vw; /* Room for the triangle, which can extend far outside the submenu. */
clip-path: shape(
from calc(100vw + var(--bridge-x)) calc(100vh + var(--bridge-y)),
line to calc(100vw + var(--bridge-edge)) 100vh,
line to calc(100vw + var(--bridge-edge)) calc(100vh + var(--bridge-height)),
close
);
}
}
const canHover = matchMedia('(hover: hover)');
// Call on pointerleave of a submenu trigger, with that trigger's submenu.
function startBridge(submenu, event) {
if (!canHover.matches || !submenu.matches(':popover-open')) return;
const rect = submenu.getBoundingClientRect();
if (event.clientX >= rect.left && event.clientX <= rect.right) return; // --below: no side edge.
const style = submenu.style;
style.setProperty('--bridge-edge', `${event.clientX < rect.left ? 0 : rect.width}px`);
style.setProperty('--bridge-height', `${rect.height}px`);
const update = ({ clientX, clientY, target }) => {
if (target !== submenu && submenu.contains(target)) return stop(); // Reached an item.
style.setProperty('--bridge-x', `${clientX - rect.left}px`);
style.setProperty('--bridge-y', `${clientY - rect.top}px`);
};
const timer = setTimeout(() => stop(), 300);
const stop = () => {
clearTimeout(timer);
document.removeEventListener('pointermove', update);
submenu.removeEventListener('toggle', stop);
submenu.removeAttribute('data-bridge');
};
update(event);
submenu.dataset.bridge = '';
document.addEventListener('pointermove', update);
submenu.addEventListener('toggle', stop, { once: true });
}
El desplazamiento sigue al enfoque (elemento 2) y sigue siendo un controlador pointermove simple: no necesita lógica de triángulo.
También:
- Fuera de foco. Cierra todos los menús abiertos cuando el enfoque se desplace fuera del sistema de menús (un objeto de escucha
documentfocusin). - Activación de comandos. Anúncialo, luego oculta el menú raíz y restablece el enfoque en su activador, sin un anillo si la activación provino de un puntero. En el caso de los elementos que se pueden marcar (
menuitemcheckbox,menuitemradio), espera unos 300 ms antes de cerrar para que se vea la marca de verificación modificada. Asigna un estilo a un activador cerrado como activo solo en:focus-visible; de lo contrario, el enfoque restablecido (sin anillo) lo deja destacado después de una selección con el puntero. aria-expanded. Configúralo solo en el objeto de escuchatoggle. Abarca los estados de vista previa y abierto, y elaria-expandeddel activador permanece"true"mientras se obtiene una vista previa de su menú o se abre.- Ayudantes Los resguardos que se muestran a continuación suponen que defines asistentes para tu lenguaje de marcado:
triggers(todos los botones[commandfor]),menuFor(trigger),isOpen(menu),closeAndRestore(menu, { fromPointer })(enfoca el activador, sin un anillo cuandofromPointer, luegomenu.hidePopover()),enterOnOpen(unWeakSetde menús para ingresar una vez abiertos) ypointerTrigger(el activador que se presionó por última vez con un puntero).
Submenús
- Usa
popover="auto"para los submenús, que se colocan después del activador del submenú dentro delfocusgroupdel elemento superior. Nuncapopover="manual". - El anidamiento permite que el navegador mantenga abierto el elemento superior, cierre los submenús hermanos, cierre los submenús secundarios cuando se cierra el menú raíz y controle el cierre ligero con un clic fuera del menú.
- El activador del submenú mantiene
aria-haspopup="menu"yaria-expanded; el objeto de escuchatogglemantienearia-expandedsincronizado. - La secuencia de comandos del teclado controla los elementos
EscapeyArrowLeftanidados como se describió anteriormente. - Un activador de submenú nunca ejecuta un comando: un control no puede activar y abrir, y
Enterno puede significar ambas cosas. Coloca la acción predeterminada como el primer elemento del submenú o agrega un elemento de comando independiente junto al activador. Si se fuerza un control combinado, documéntalo como una compensación de accesibilidad.
Separadores y elementos marcables
- Separa los grupos de artículos con
<hr>dentro del grupo de enfoque: unseparatornativo, no enfocable, omitido por el grupo de enfoque y el resguardo. Nunca un<div>con diseño. - Una opción booleana es un
<button type="button">conaria-checkedy unrole="menuitemcheckbox"explícito. Focusgroup no infiere estos roles, por lo que este es el único lugar donde el lenguaje de marcado compartido establece un rol de elemento. - Uno de los conjuntos es un
<button type="button">conrole="menuitemradio"yaria-checked, dentro de unrole="group"con unaria-label, en el mismo grupo de enfoque. - Se activan o desactivan las secuencias de comandos
aria-checked. Enteren una casilla de verificación activa o desactiva la opción y cierra el menú;Spaceactiva o desactiva la opción y mantiene el menú abierto (APG). Las radios se cierran con cualquiera de las dos claves.- Renderiza la marca de verificación de
[aria-checked="true"]::before, no un elemento de ícono independiente (consulta el CSS que aparece a continuación).
No se aborda aquí
- Comentarios sobre la activación. Los menús nativos muestran el elemento elegido antes de cerrarse. Si lo deseas, muestra una confirmación visual de ~100 ms antes de
hidePopover(); no retrases el restablecimiento del enfoque ni el anuncio. - Typeahead. Focusgroup no tiene ninguno. Si es necesario, en el
keydownimprimible, haz coincidir las etiquetas de los elementos habilitados del nivel actual y enfoca la siguiente coincidencia después del elemento actual, ajustando el texto. No interceptesSpace.
Verifica antes de finalizar
Una instrucción no puede garantizar el comportamiento, por lo que debes probar el resultado. Abre la página y verifica cada uno de estos elementos con el teclado y el mouse reales. Corrige y vuelve a probar los que fallen:
- Teclado
Tabllega al primer activador una vez;ArrowDowningresa;Escapecierra un nivel y devuelve el enfoque a su activador;Tabcierra todos los menús. - Puntero abierto. Si se hace clic en un activador, se abre su menú con el foco en el activador y sin anillo. Si se vuelve a hacer clic, se cierra.
- Comando de puntero. Cuando se hace clic en un comando, se cierran todos los menús y no se muestra ningún anillo de enfoque en el activador.
- Submenú. Si te mueves en diagonal desde el centro de un activador de submenú hasta un elemento de submenú, este se mantiene abierto; si te mueves a un elemento hermano no relacionado, se cierra.
- Pantalla estrecha. A 375 px, ningún submenú cubre su propio activador.
- Marcas de verificación. Los elementos marcados muestran una marca de verificación.
- No dejes sobras. No hay errores de consola ni
popovertarget,popovertargetactionoaria-detailsen los activadores.
Alinea el contenido de los elementos de menú
Cuando una fila tenga un ícono, una etiqueta y un indicador de acceso directo o submenú, reserva un espacio inicial de tamaño fijo para el ícono o la marca de verificación, y envía el contenido final al final de la línea. Los íconos tienen un tamaño conocido, por lo que un espacio fijo mantiene las etiquetas alineadas, independientemente de si un elemento tiene un ícono o no. No uses subgrid para las filas: cualquier elemento secundario intercalado adicional (una insignia, por ejemplo) se convierte en su propio elemento de cuadrícula y rompe el diseño. Oculta el texto de la combinación de teclas de la tecnología de accesibilidad cuando solo repite una tecla que ya opera el elemento.
[focusgroup~="menu"] {
--menu-icon-size: 1.25rem;
}
/* Target items only. A closed submenu popover inside the focusgroup must keep display: none. */
[focusgroup~="menu"] > button,
[focusgroup~="menu"] > [role="group"] > button {
display: flex;
align-items: center;
gap: .5rem;
}
/* The leading slot holds the icon or check mark. */
[focusgroup~="menu"] button::before {
content: "";
flex: none;
inline-size: var(--menu-icon-size);
}
/* Check mark for checked items. This selector must be at least as specific as the base
::before rule above, or the empty content wins and the check mark never shows. */
[focusgroup~="menu"] button[aria-checked="true"]::before {
content: "✓";
}
/* Shortcut or submenu indicator at the inline end. */
[focusgroup~="menu"] .hint {
margin-inline-start: auto;
}
Coloca un indicador final o un acceso directo en un <span class="hint" aria-hidden="true"> dentro del botón; el activador del submenú ya anuncia su ventana emergente.
Mejora progresiva y resguardos
Resguardos y compatibilidad de navegadores para focusgroup
Mantén focusgroup="menu nomemory" en el lenguaje de marcado. Si 'focusGroup' in HTMLElement.prototype es falso, instala las teclas de flecha hacia arriba, flecha hacia abajo, Inicio y Fin, establece role="menu" en cada contenedor de menú y role="menuitem" en los botones sin rol; deja los elementos marcables, <hr> y role="group" solos. Nunca lo instales cuando haya un focusgroup nativo. De cualquier manera, Left, Right, preview, open y close son guiones del autor.
if (!('focusGroup' in HTMLElement.prototype)) {
document.querySelectorAll('[focusgroup~="menu"]').forEach(group => {
group.setAttribute('role', 'menu');
group.querySelectorAll('button:not([role])').forEach(item => {
item.setAttribute('role', 'menuitem');
});
group.addEventListener('keydown', event => {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
// Own items only: a nested submenu has its own focusgroup, a role="group" wrapper does not.
const items = [...group.querySelectorAll('button')]
.filter(item => item.closest('[focusgroup]') === group)
.filter(item => !item.disabled && item.getAttribute('aria-disabled') !== 'true');
const current = items.indexOf(document.activeElement);
if (current < 0) return;
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 :
(current + (event.key === 'ArrowUp' ? -1 : 1) + items.length) % items.length;
event.preventDefault();
event.stopImmediatePropagation();
items[next].focus({ preventScroll: true });
}, true);
});
}
Resguardos y compatibilidad de navegadores para Popover
La API de Popover es, en su mayor parte, una mejora progresiva, pero sus comportamientos definitorios (promoción de la capa superior, cierre rápido y la invocación de popovertarget) no tienen un equivalente solo de CSS. Los navegadores más antiguos necesitan un polyfill o un resguardo manual si prefieres no enviar uno.
Polyfill: Para admitir el atributo popover en navegadores más antiguos, carga @oddbird/popover-polyfill de forma condicional. OBLIGATORIO: Detecta la función verificando la propiedad popover en HTMLElement.prototype y carga el polyfill solo cuando no haya compatibilidad nativa. NO lo cargues de forma incondicional.
Con un empaquetador o un mapa de importación:
// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
import("@oddbird/popover-polyfill");
}
Sin un empaquetador, importa desde una CDN dentro de un <script type="module">:
<script type="module">
if (!("popover" in HTMLElement.prototype)) {
import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
}
</script>
Advertencia de diseño: El polyfill no puede definir la pseudoclase :popover-open real, por lo que aplica una clase .\:popover-open en su lugar. OBLIGATORIO: Combina las dos con :is() o :where(); de lo contrario, los navegadores que no tengan :popover-open descartarán toda la regla:
[popover]:is(:popover-open, .\:popover-open) {
display: block;
}
Como alternativa, para un resguardo heredado sin un polyfill, usa position: fixed y calcula manualmente las coordenadas a través de getBoundingClientRect() o confía en el posicionamiento predeterminado con inset: auto si eso es aceptable para el caso de uso.
Resguardos y compatibilidad con navegadores para el posicionamiento de anclas
Sin el posicionamiento de anclaje de CSS, un cuadro emergente recurre a la ubicación centrada del viewport del UA. Nunca envíes eso para un menú: separa el menú de su activador y un submenú centrado cubre su elemento superior. Controla las reglas fijadas detrás de @supports (position-area: block-end) y posiciona el menú desde la secuencia de comandos cuando falle:
- Coloca el menú debajo de su activador (nivel superior) o junto a él (submenú), volteándolo hacia el otro lado y fijándolo al viewport cuando se desborde.
- Ejecútala en el controlador
togglecuando se abra el menú y aplica el resultado comoleftytopen el menú. Usaposition: fixedconinset: autoymargin: 0para que las coordenadas sean relativas al viewport. - Mantén estas anulaciones dentro de
@supports not (position-area: block-end)para que los navegadores con posicionamiento de anclaje no se vean afectados.
Si se orienta a implementaciones anteriores en las que showPopover({ source }) no establece un ancla implícita (no hay source en ToggleEvent.prototype), asigna a cada activador un anchor-name único y a cada menú un position-anchor coincidente desde la secuencia de comandos, en lugar de escribir reglas de CSS por menú.
En el caso de los navegadores que no tienen posicionamiento de fijación, consulta Menús desplegables que se adaptan al viewport para conocer la ruta de polyfill.
Resguardos y compatibilidad de navegadores para los comandos de invocador
Sin invocadores, command="toggle-popover" no hace nada, por lo que la secuencia de comandos debe activar o desactivar el menú.
- Detecta la interfaz, no una instancia:
'commandForElement' in HTMLButtonElement.prototype. - En cada activador
click, llama apreventDefault()para que un navegador con invocadores no pueda activar o desactivar dos veces. - Si el menú está abierto, ciérralo con
closeAndRestore(menu). De lo contrario, llama almenu.showPopover({ source: trigger }). - Marca el menú en
enterOnOpen, a menos que el clic provenga del activador presionado (pointerTrigger), de modo que solo se ingrese al menú cuando se abra con el teclado. EnterySpacesiguen funcionando porque generan un clic en los botones.