Crea un menú desplegable

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).

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/End y Esc lo convierten en un menú: agrega el grupo de enfoque, deja que infiera menuitem en 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 usuarios Tab es la navegación: sin focusgroup, sin role="menu", role="menuitem" ni aria-haspopup="menu"; conserva el popover, Esc y 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.

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 de Tab; ArrowLeft/ArrowRight para 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 Tab y se ve como un botón independiente. No hay role="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ú. Tab ingresa en el primer activador de nivel superior. ArrowLeft y ArrowRight se mueven entre los activadores de nivel superior y se ajustan en cualquier extremo. Home y End en 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 con ArrowLeft y ArrowRight, por lo que, después de la navegación con el teclado, Shift+Tab vuelve 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.
  • 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-popover se 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ú ArrowUp y ArrowDown se mueven entre los elementos habilitados. Home y End se 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 ArrowDown lo 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 ArrowDown lo 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

  1. Un camino abierto. La apertura con el teclado llama a trigger.click(), por lo que el toggle-popover del invocador es la única forma de abrir un menú. Marca el menú para que el controlador toggle lo ingrese, solo se abre el teclado. Cuando se abre un puntero, toggle enfoca el activador sin un anillo, ya que pointerdown impidió el enfoque y las teclas de flecha deben seguir funcionando.
  2. 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 ventana blur, se borra una marca después de la ventana focus o el cambio de app vuelve a abrir el último menú). Oculta un menú de vista previa en blur, a menos que el enfoque se haya movido a él.
  3. Mueve el enfoque en toggle, no en command. command se activa antes de que se abra el menú.
  4. 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 con event.detail === 0 provino del teclado.
  5. Cierra un nivel a la vez. Llama a stopPropagation() en Escape y ArrowLeft en un submenú, o el objeto de escucha del elemento superior también lo cerrará.

Control de punteros

  1. Activa o desactiva los clics del puntero. Llama a preventDefault() en pointerdown para 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.
  2. El desplazamiento sigue el enfoque. Aplica un estilo diferente a :hover y :focus-visible. En pointermove sobre un elemento, llama a focus({ focusVisible: false }). De todos modos, los navegadores que ignoran la opción no pintan ningún anillo para el enfoque del puntero.
  3. Los interruptores de desplazamiento abren menús. En pointerenter de 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.
  4. 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-x y --bridge-y (el puntero, en px en relación con el submenú), --bridge-edge (el eje x del borde cercano del submenú: 0 cuando el puntero está a su izquierda, su ancho cuando se volteó) y --bridge-height en el submenú, y data-bridge para 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 ::before recortado 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-path tambié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.
[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 document focusin).
  • 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 escucha toggle. Abarca los estados de vista previa y abierto, y el aria-expanded del 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 cuando fromPointer, luego menu.hidePopover()), enterOnOpen (un WeakSet de menús para ingresar una vez abiertos) y pointerTrigger (el activador que se presionó por última vez con un puntero).
  • Usa popover="auto" para los submenús, que se colocan después del activador del submenú dentro del focusgroup del elemento superior. Nunca popover="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" y aria-expanded; el objeto de escucha toggle mantiene aria-expanded sincronizado.
  • La secuencia de comandos del teclado controla los elementos Escape y ArrowLeft anidados como se describió anteriormente.
  • Un activador de submenú nunca ejecuta un comando: un control no puede activar y abrir, y Enter no 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: un separator nativo, 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"> con aria-checked y un role="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"> con role="menuitemradio" y aria-checked, dentro de un role="group" con un aria-label, en el mismo grupo de enfoque.
  • Se activan o desactivan las secuencias de comandos aria-checked.
  • Enter en una casilla de verificación activa o desactiva la opción y cierra el menú; Space activa 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 keydown imprimible, 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 interceptes Space.

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 Tab llega al primer activador una vez; ArrowDown ingresa; Escape cierra un nivel y devuelve el enfoque a su activador; Tab cierra 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, popovertargetaction o aria-details en 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 toggle cuando se abra el menú y aplica el resultado como left y top en el menú. Usa position: fixed con inset: auto y margin: 0 para 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 a preventDefault() 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 al menu.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.
  • Enter y Space siguen funcionando porque generan un clic en los botones.