Crea un menu a discesa

Crea un menu a discesa

Questa guida riguarda tutti i menu popup: a discesa, contestuali, extra e le barre dei menu che li aprono. L'esempio pratico è un menu di comandi dell'applicazione, in cui le voci modificano l'interfaccia (crea, salva, preferenze).

Due scelte indipendenti. Tutto il resto si applica a entrambi.

  • Elemento, per articolo. Un comando è un <button type="button">; una destinazione è un <a href> che si apre comunque in una nuova scheda, viene copiato come URL e funziona senza script. Un menu a discesa può combinarli liberamente: due link e sei comandi o un comando e sei link, non cambia nulla di seguito.
  • Modello di interazione, come da menu a discesa. I tasti Freccia, Home/End e Esc lo rendono un menu: aggiungi il gruppo di selezione, lascia che deduca menuitem su ogni elemento, che si tratti di un pulsante o di un link, e rendilo simile a un menu (una barra continua o un trigger con un indicatore di menu a discesa; vedi "Barra dei menu o pulsanti di menu separati" di seguito). Un breve elenco di link che gli utenti Tab è la navigazione: nessun gruppo di selezione, nessun role="menu", role="menuitem" o aria-haspopup="menu"; mantieni il popover, Esc e il ripristino dello stato attivo e consulta la guida alla barra di navigazione.

I contenuti non decidono mai i ruoli. Un menu a discesa "Prodotti" di link può essere un menu; un menu a discesa "Account" di comandi non può essere un menu di navigazione.

Utilizza il pattern del menu solo per un widget composito con un punto di tabulazione e la navigazione con i tasti Freccia. Utilizza un <div focusgroup="menu nomemory">, non l'elemento HTML <menu>. Non aggiungere role="menu" o role="menuitem" nel markup condiviso: il focusgroup nativo deduce questi ruoli e i ruoli espliciti bloccano questa inferenza. Aggiungili solo nel fallback per i browser non supportati di seguito. nomemory fa sì che la riapertura inizi dal primo elemento. Focusgroup sposta lo stato attivo; lo script gestisce l'anteprima, l'attivazione e il ripristino dello stato attivo.

Apri e posiziona il menu

Fai in modo che ogni attivatore sia un attivatore di comandi con commandfor e command="toggle-popover". Il browser apre e chiude il popover con un clic, Invio e Spazio e mantiene l'attivatore all'interno del gruppo di chiusura leggera del popover, quindi se fai clic su un attivatore aperto, questo si chiude. Non utilizzare popovertarget o popovertargetaction; questo pattern utilizza invece commandfor.

Posiziona ogni popover immediatamente dopo il relativo trigger. I browser aggiungono una relazione aria-details implicita solo quando il popover non è l'elemento di pari livello successivo, quindi questo posizionamento la evita senza attributi aggiuntivi.

<!-- 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>

Assegna un nome a ogni menu con aria-labelledby che punta al relativo trigger. Mantieni gli indicatori decorativi dei sottomenu in CSS o aggiungi aria-hidden="true". Assegna agli attivatori con aria-expanded="true" uno stile attivo visibile in modo che gli utenti possano capire quale menu è aperto o visualizzato in anteprima.

Scegli un modulo per gli attivatori di primo livello. Il ruolo, il modello di tastiera e lo stile devono corrispondere: i pulsanti separati visivamente indicano una fermata Tab ciascuno.

  • Barra dei menu. role="menubar" sul container (mai su un popover), con lo stile di una barra continua senza spazi tra gli attivatori. Un Tab per interrompere; ArrowLeft/ArrowRight per spostarti tra gli attivatori; passa il mouse sopra gli interruttori per aprire i menu. Da utilizzare per una barra dei comandi dell'applicazione.
  • Pulsanti di menu separati. Ogni attivatore è un punto di interruzione Tab e ha l'aspetto di un pulsante autonomo. Nessun role="menubar", nessun tasto Freccia tra gli attivatori, nessuna anteprima quando l'elemento è attivo, nessun passaggio al passaggio del mouse. Inserisci il tag <nav> se l'utente naviga, altrimenti non assegnare alcun ruolo. Da utilizzare per barre degli strumenti e intestazioni di pagina.

Il resto di questa guida descrive il modulo della barra dei menu. Per i pulsanti separati, mantieni tutto all'interno di ogni menu ed elimina il comportamento a livello di barra dei menu.

Posiziona il menu con il posizionamento dell'anchor CSS

Utilizza il posizionamento degli ancoraggi CSS, non le coordinate JavaScript. Sia command="toggle-popover" che showPopover({ source: trigger }) impostano un ancoraggio implicito, quindi non aggiungere regole anchor-name o position-anchor per menu. Il comportamento del menu non deve dipendere dal posizionamento dell'ancoraggio.

/* 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);
}

Mantieni --below per ultimo in modo che venga applicato solo quando non è possibile utilizzare nessuno dei due lati. Si apre direttamente sotto l'attivatore, quindi su uno schermo stretto copre gli elementi di pari livello sotto l'attivatore; questo è il compromesso accettato per rimanere accanto all'attivatore. Inoltre, consenti al sottomenu di ridursi all'area (min-inline-size: 0, max-inline-size meno il rientro). Un fallback che non si adatta viene ignorato e, se nessuno si adatta, il browser mantiene il posizionamento di base, quindi un sottomenu troppo largo di qualche pixel copre silenziosamente il suo trigger.

Per scoprire come funzionano i fallback di inversione su un popover semplice, consulta Menu a discesa sensibili alla visualizzazione. Questa guida posiziona un gruppo di pulsanti, non un menu; per i menu, il posizionamento del sottomenu e il fallback --below sopra hanno la precedenza.

Animazione di apertura e chiusura

Facoltativo. Sotto i 150 ms, solo opacity e un piccolo translate, lo stesso vale per i sottomenu, all'interno di @media (prefers-reduced-motion: no-preference). Lo stato attivo si sposta su toggle, quindi la transizione non ritarda mai gli utenti che utilizzano la tastiera.

@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 non è una ripetizione di [popover]: un popover chiuso è display: none e le transizioni non vengono eseguite da uno stato non visualizzato, quindi fornisce i valori precedenti per l'apertura. Senza questa proprietà, l'apertura avviene di scatto e solo la chiusura è animata. display e overlay con allow-discrete mantengono il popover visualizzato e nel livello superiore fino al termine della transizione di chiusura.

Tastiera e selezione

Non aggiungere un secondo sistema roving-tabindex all'interno di un focusgroup. Lo script copre solo ciò che non viene coperto dagli invoker e dal focusgroup:

  • Un punto di tabulazione per la barra dei menu. Tab entra nel primo trigger di primo livello. ArrowLeft e ArrowRight consentono di spostarsi tra gli attivatori di primo livello e di andare a capo alle due estremità. Home e End su un attivatore entrano nel relativo menu in corrispondenza del primo o dell'ultimo elemento.
    • Nota: l'attivatore a cui Tab torna. L'APG consente il primo trigger o l'ultimo utilizzato. La guida specifica il primo, ma la demo sposta il punto di tabulazione con ArrowLeft e ArrowRight, quindi dopo la navigazione da tastiera Shift+Tab torna all'ultimo trigger raggiunto. L'utilizzo del puntatore non lo sposta, quindi la stessa sequenza può attivare un trigger diverso. Entrambi i comportamenti sono accettabili; scegline uno e mantienilo coerente.
  • Anteprima. Se l'elemento attivo è un trigger di primo livello, viene visualizzato il relativo menu e viene impostato aria-expanded="true". Lo stato attivo rimane sull'attivatore e nessun elemento è attivo. La sfocatura dell'attivatore chiude un menu di sola anteprima.
  • Invio e Barra spaziatrice su un'anteprima. Chiudono l'anteprima e mantengono lo stato attivo sull'attivatore. L'azione toggle-popover nativa gestisce questa operazione, quindi lo script non deve intercettare questi tasti.
  • Freccia giù su un'anteprima. Lo converte in un menu aperto e attiva il primo elemento.
  • Clic del puntatore su un trigger aperto o visualizzato in anteprima. Chiude l'app in modo nativo, senza accedere al menu.
    • Differenza nota sugli attivatori dei sottomenu. Un clic del puntatore su un trigger di sottomenu attiva anche la funzionalità nativa, quindi può chiudere un sottomenu già visualizzato in anteprima con il passaggio del mouse o aprirne uno che non è ancora apparso. I menu nativi lo aprono, ma non lo chiudono mai. Questo è innocuo, quindi la guida lo accetta.
  • Clic del puntatore su un trigger chiuso. Lo apre in modo nativo. Lo stato attivo rimane sull'attivatore; nessun elemento è attivo. Solo le aperture della tastiera entrano nel menu.
  • Posiziona il puntatore su un elemento. Lo attiva senza un anello, quindi l'elemento attivo e quello su cui passi il mouse sono lo stesso.
  • Puntatore su un altro trigger di primo livello mentre un menu è aperto. Chiude la finestra e visualizza l'anteprima di quella su cui passi il mouse, attivando il relativo trigger senza un anello. Nessun elemento aperto: il passaggio del mouse non ha alcun effetto.
  • Puntatore su un attivatore di sottomenu. Visualizza l'anteprima del sottomenu dopo un breve ritardo (lo stato attivo del puntatore esegue la stessa anteprima con stato attivo). Un elemento di pari livello lo chiude.
  • All'interno di un menu. ArrowUp e ArrowDown per spostarti tra gli elementi attivati. Home e End consentono di passare al primo e all'ultimo elemento attivato, che vengono gestiti dallo script.
  • Freccia sinistra o Freccia destra in un menu di primo livello aperto. Chiude il menu e visualizza l'anteprima del menu adiacente con lo stato attivo sul relativo trigger, con ritorno a capo alle due estremità. ArrowDown e poi lo inserisce. Le frecce Sinistra e Destra non portano mai all'interno di un menu.
  • Freccia destra su un attivatore di sottomenu. Apre il sottomenu e imposta lo stato attivo sul primo elemento.
  • Freccia destra su una voce di sottomenu senza un sottomenu secondario. Chiude la gerarchia e visualizza l'anteprima del menu di livello superiore successivo, mentre lo stato attivo rimane sul relativo trigger. ArrowDown e poi lo inserisce.
  • Freccia sinistra o Esc in un sottomenu. Chiude solo il sottomenu e ripristina lo stato attivo sul relativo trigger. Il menu per i genitori rimane aperto.
  • Invio o Barra spaziatrice su un comando. Attiva l'elemento e chiude tutti i menu aperti.
  • Tab. Esce dal sistema di menu. Non intrappolare lo stato attivo o scorrere ogni comando. La chiusura non deve lasciare lo stato attivo all'interno dei contenuti nascosti.

L'evento command viene attivato prima che toggle-popover apra il menu, quindi lo stato attivo non può spostarsi nel menu. Sposta invece lo stato attivo nell'evento toggle. L'HTML ripristina lo stato attivo solo per il primo popover="auto" in uno stack, pertanto i livelli nidificati devono ripristinare lo stato attivo al relativo trigger in modo esplicito.

Implementazione dell'interazione

Implementa i comportamenti descritti sopra per il tuo markup. La gestione delle frecce, di Home/End, del ritorno a capo e di Tab per uscire è un codice keydown ordinario. Alcune parti sono facili da sbagliare e sono raggruppate di seguito per tipo di input.

Gestione della tastiera e dello stato attivo

  1. Un percorso aperto. L'apertura tramite tastiera chiama trigger.click(), quindi l'elemento toggle-popover dell'invoker è l'unico modo per aprire un menu. Contrassegna il menu in modo che il gestore toggle lo inserisca, la tastiera si apre solo. Quando si apre un puntatore, toggle attiva l'attivatore senza un anello, perché pointerdown ha impedito l'attivazione e i tasti Freccia devono comunque funzionare.
  2. Anteprima attiva. L'attivazione di un trigger chiama showPopover({ source: trigger }) in modo che il menu si agganci al trigger. Ignora lo stato attivo che torna dall'interno del menu dell'attivatore o da un'altra finestra (flag sulla finestra blur, cancellato un segno di spunta dopo la finestra focus o il cambio di app riapre l'ultimo menu). Nascondi un menu visualizzato in anteprima su blur, a meno che lo stato attivo non venga spostato al suo interno.
  3. Sposta lo stato attivo in toggle, non in command. command viene attivato prima dell'apertura del menu.
  4. Ripristina lo stato attivo sull'attivatore. Seleziona il trigger prima del giorno hidePopover(). Dopo l'attivazione di un puntatore, ripristina lo stato attivo senza un anello (focus({ focusVisible: false })) o l'attivatore mostra il contorno della selezione della tastiera dopo un clic del mouse; le attivazioni della tastiera mantengono l'anello. Un clic con event.detail === 0 proveniva dalla tastiera.
  5. Chiudi un livello alla volta. Chiama stopPropagation() su Escape e ArrowLeft in un sottomenu, altrimenti anche il listener dell'elemento principale lo chiude.

Gestione del puntatore

  1. Attivazione/disattivazione dei clic del puntatore. Chiama preventDefault() su pointerdown in modo che il clic non sposti lo stato attivo e non avvii un'anteprima a metà clic. Ricorda quale grilletto è stato premuto. Se fai clic su un trigger chiuso o visualizzato in anteprima, questo si apre o si chiude in modo nativo e non entra mai nel menu; solo le aperture da tastiera entrano nel menu. Non intercettare il clic.
  2. La funzionalità di passaggio del mouse segue lo stato attivo. Definisci lo stile di :hover e :focus-visible separatamente. Su pointermove sopra un elemento, chiama focus({ focusVisible: false }); i browser che ignorano l'opzione non mostrano comunque alcun anello per lo stato attivo del puntatore.
  3. I sensori di passaggio del mouse aprono i menu. Su pointerenter di un trigger di primo livello mentre è aperto un altro menu di primo livello: nascondilo, attiva il trigger su cui è posizionato il puntatore senza un anello e visualizza l'anteprima al momento dell'attivazione (vedi sopra). Nessuna apertura: non fare nulla.
  4. Triangolo di sicurezza. Un bridge CSS fisso è troppo stretto: funziona solo quando il puntatore inizia vicino al bordo dell'elemento principale. Consenti al CSS di disegnare il bridge e utilizza lo script solo per segnalare il puntatore:
    • Esegui questa operazione solo quando matchMedia('(hover: hover)').matches; non è pertinente per il tocco e la tastiera.
    • Inizia quando il puntatore lascia un trigger di sottomenu il cui sottomenu è aperto. Salta un sottomenu --below, che non ha un bordo laterale.
    • Imposta --bridge-x e --bridge-y (il puntatore, in px rispetto al sottomenu), --bridge-edge (la x del bordo vicino del sottomenu: 0 quando il puntatore si trova alla sua sinistra, la sua larghezza quando viene capovolto) e --bridge-height sul sottomenu e data-bridge per attivare il bridge.
    • Interrompi dopo circa 300 ms, quando il puntatore raggiunge un elemento nel sottomenu o quando il sottomenu si chiude. Rimuovi il listener e l'attributo. Non viene eseguito nient'altro.
    • Il CSS assegna al sottomenu un ::before ritagliato in un triangolo dal puntatore agli angoli del bordo vicino. Si trova sopra gli elementi di pari livello nel percorso del puntatore, quindi non ricevono mai l'evento di passaggio del mouse che chiuderebbe il sottomenu. clip-path limita anche l'hit test, in modo che il resto del menu principale rimanga interattivo.
    • Il sottomenu richiede overflow: visible (un popover viene ritagliato per impostazione predefinita) e lo pseudo-elemento si estende oltre, quindi compensa i punti della forma di questa estensione.
    • Follow-up: un percorso curvo (comandi di curva shape()) segue gli archi reali del puntatore meglio di un triangolo dritto.
[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 });
}

Hover follows focus (elemento 2) rimane un semplice gestore pointermove: non ha bisogno della logica del triangolo.

Inoltre:

  • Fuori fuoco. Chiudi tutti i menu aperti quando lo stato attivo si sposta all'esterno del sistema di menu (un listener document focusin).
  • Attivazione dei comandi. Annuncialo, poi nascondi il menu principale e ripristina lo stato attivo sul relativo trigger, senza un anello se l'attivazione è avvenuta da un puntatore. Per gli elementi selezionabili (menuitemcheckbox, menuitemradio), attendi circa 300 ms prima di chiudere, in modo che il segno di spunta modificato sia visibile. Stile di un trigger chiuso come attivo solo su :focus-visible; in caso contrario, lo stato attivo ripristinato (senza anello) lo lascia evidenziato dopo una selezione del puntatore.
  • aria-expanded. Impostalo solo nel listener toggle. Copre gli stati di anteprima e di apertura e l'elemento aria-expanded dell'attivatore rimane "true" mentre il menu è in anteprima o aperto.
  • Helper. I fallback di seguito presuppongono gli helper definiti per il markup: triggers (tutti i pulsanti [commandfor]), menuFor(trigger), isOpen(menu), closeAndRestore(menu, { fromPointer }) (focalizza l'attivatore, senza un anello quando fromPointer, poi menu.hidePopover()), enterOnOpen (un WeakSet di menu da inserire una volta aperti) e pointerTrigger (l'attivatore premuto per ultimo da un puntatore).
  • Utilizza popover="auto" per i sottomenu, posizionati dopo l'attivatore del sottomenu all'interno del focusgroup principale. Mai popover="manual".
  • La nidificazione consente al browser di mantenere aperto l'elemento principale, chiudere i sottomenu di pari livello, chiudere i sottomenu secondari quando il menu principale si chiude e gestire la chiusura con un clic esterno.
  • L'attivatore del sottomenu mantiene aria-haspopup="menu" e aria-expanded; il listener toggle mantiene aria-expanded sincronizzato.
  • Lo script della tastiera gestisce i tag Escape e ArrowLeft nidificati come descritto sopra.
  • Un attivatore di sottomenu non esegue mai anche un comando: un controllo non può sia attivare che aprire e Enter non può significare entrambe le cose. Inserisci l'azione predefinita come prima voce del sottomenu o aggiungi una voce di comando separata accanto all'attivatore. Se un controllo combinato è forzato, documentalo come un compromesso di accessibilità.

Separatori ed elementi selezionabili

  • Separa i gruppi di articoli con <hr> all'interno del focusgroup: un separator nativo, non selezionabile, ignorato dal focusgroup e dal fallback. Mai un <div> con stile.
  • Un'opzione booleana è un <button type="button"> con aria-checked e un role="menuitemcheckbox" esplicito. Focusgroup non deduce questi ruoli, quindi questo è l'unico punto in cui il markup condiviso imposta un ruolo dell'elemento.
  • Uno di un insieme è un <button type="button"> con role="menuitemradio" e aria-checked, all'interno di un role="group" con un aria-label, nello stesso gruppo di selezione.
  • Attivazione/disattivazione degli script aria-checked.
  • Enter su una casella di controllo attiva/disattiva e chiude il menu; Space attiva/disattiva e rimane aperto (APG). Le radio si chiudono con entrambi i tasti.
  • Esegui il rendering del segno di spunta da [aria-checked="true"]::before, non da un elemento icona separato (vedi il CSS di seguito).

Non inclusi qui

  • Feedback sull'attivazione. I menu nativi fanno lampeggiare l'elemento scelto prima di chiudersi. Se vuoi, mostra una conferma visiva di circa 100 ms prima di hidePopover(); non ritardare il ripristino dello stato attivo o l'annuncio.
  • Digitazione anticipata. Focusgroup non ne ha. Se necessario, su keydown stampabile, trova le etichette degli elementi attivati del livello corrente e attiva l'elemento successivo dopo quello corrente, con ritorno a capo. Non intercettare Space.

Verifica prima di terminare

Un'istruzione non può garantire il comportamento, quindi testa il risultato. Apri la pagina e controlla ciascuno di questi elementi con la tastiera e il mouse reali; correggi e testa nuovamente quelli che non funzionano:

  • Tastiera. Tab si posiziona una volta sul primo trigger; ArrowDown entra; Escape chiude un livello e riporta lo stato attivo sul relativo trigger; Tab chiude ogni menu.
  • Puntatore aperto. Se fai clic su un attivatore, si apre il relativo menu con lo stato attivo sull'attivatore e senza anello; se fai di nuovo clic, il menu si chiude.
  • Comando Pointer. Se fai clic su un comando, tutti i menu vengono chiusi e non viene visualizzato alcun anello di selezione sull'attivatore.
  • Sottomenu. Spostandosi in diagonale dal centro di un attivatore di sottomenu a un elemento del sottomenu, questo rimane aperto; spostandosi su un elemento di pari livello non correlato, il sottomenu si chiude.
  • Schermo stretto. A 375 px, nessun sottomenu copre il proprio trigger.
  • Segni di spunta. Gli elementi selezionati mostrano un segno di spunta.
  • Nessun avanzo. Nessun errore della console e nessun popovertarget, popovertargetaction o aria-details sugli attivatori.

Allinea i contenuti delle voci di menu

Quando una riga ha un'icona, un'etichetta e un indicatore di scorciatoia o sottomenu, riserva uno slot iniziale di dimensioni fisse per l'icona o il segno di spunta e spingi i contenuti finali verso la fine della riga. Le icone hanno una dimensione nota, quindi uno spazio fisso mantiene le etichette allineate indipendentemente dal fatto che un elemento abbia o meno un'icona. Non utilizzare subgrid per le righe: qualsiasi elemento secondario in linea aggiuntivo (ad esempio un badge) diventa un elemento della griglia a sé stante e interrompe il layout. Nascondi il testo della scorciatoia alle tecnologie per la disabilità quando ripete solo un tasto che aziona già l'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;
}

Inserisci un indicatore finale o una scorciatoia in un <span class="hint" aria-hidden="true"> all'interno del pulsante; l'attivatore del sottomenu annuncia già il popup.

Potenziamento progressivo e fallback

Opzioni di riserva e supporto del browser per focusgroup

Mantieni focusgroup="menu nomemory" nel markup. Se 'focusGroup' in HTMLElement.prototype è false, installa Su, Giù, Home e Fine, imposta role="menu" su ogni contenitore di menu e role="menuitem" sui pulsanti senza ruolo; lascia invariati gli elementi selezionabili, <hr> e role="group". Non installarlo mai quando è presente il focusgroup nativo. Sinistra, Destra, Anteprima, Apri e Chiudi sono script dell'autore in entrambi i casi.

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);
  });
}

Alternative e supporto del browser per Popover

L'API Popover è per lo più un potenziamento progressivo, ma i suoi comportamenti distintivi (promozione al livello superiore, chiusura con un clic e attivazione di popovertarget) non hanno un equivalente solo CSS. I browser meno recenti richiedono un polyfill o un fallback manuale se preferisci non fornirne uno.

Polyfill: per supportare l'attributo popover nei browser meno recenti, carica @oddbird/popover-polyfill in modo condizionale. OBBLIGATORIO: esegui il rilevamento delle funzionalità controllando la proprietà popover su HTMLElement.prototype e carica il polyfill solo quando manca il supporto nativo; NON caricarlo incondizionatamente.

Con un bundler o una mappa di importazione:

// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
  import("@oddbird/popover-polyfill");
}

Senza un bundler, importa da una CDN all'interno di 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>

Avvertenza sullo stile: il polyfill non può definire la pseudo-classe :popover-open reale, quindi applica una classe .\:popover-open. OBBLIGATORIO: combina i due elementi con :is() o :where(), altrimenti i browser che non dispongono di :popover-open ignorano l'intera regola:

[popover]:is(:popover-open, .\:popover-open) {
  display: block;
}

In alternativa, per un fallback legacy senza un polyfill, utilizza position: fixed e calcola manualmente le coordinate tramite getBoundingClientRect() o affidati al posizionamento predefinito con inset: auto se è accettabile per il caso d'uso.

Alternative e supporto del browser per il posizionamento dell'ancoraggio

Senza il posizionamento ancorato CSS, un popover torna al posizionamento centrato nell'area visibile dello user agent. Non utilizzare mai questa opzione per un menu: scollega il menu dal relativo trigger e un sottomenu centrato copre il menu principale. Controlla le regole ancorate dietro @supports (position-area: block-end) e posiziona il menu dallo script quando non riesce:

  • Posiziona il menu sotto l'attivatore (livello superiore) o accanto (sottomenu), spostandolo sull'altro lato e bloccandolo nell'area visibile quando si verifica un overflow.
  • Eseguilo nel gestore toggle quando si apre il menu e applica il risultato come left e top nel menu. Utilizza position: fixed con inset: auto e margin: 0 in modo che le coordinate siano relative alla visualizzazione.
  • Mantieni questi override all'interno di @supports not (position-area: block-end), in modo che i browser con posizionamento di ancoraggio non vengano interessati.

Se il targeting riguarda implementazioni precedenti in cui showPopover({ source }) non stabilisce un ancoraggio implicito (nessun source su ToggleEvent.prototype), assegna a ogni attivatore un anchor-name univoco e a ogni menu un position-anchor corrispondente dallo script, anziché scrivere regole CSS per menu.

Per i browser che non supportano affatto il posizionamento degli ancoraggi, consulta Menu a discesa sensibili alla visualizzazione per il percorso del polyfill.

Fallback e supporto del browser per i comandi Invoker

Senza attivatori, command="toggle-popover" non fa nulla, quindi lo script deve attivare/disattivare il menu.

  • Rileva le funzionalità dell'interfaccia, non di un'istanza: 'commandForElement' in HTMLButtonElement.prototype.
  • Per ogni attivatore click, chiama preventDefault() in modo che un browser con attivatori non possa attivare/disattivare due volte.
  • Se il menu è aperto, chiudilo con closeAndRestore(menu). In caso contrario, chiama menu.showPopover({ source: trigger }).
  • Contrassegna il menu in enterOnOpen, a meno che il clic non provenga dall'attivatore premuto (pointerTrigger), in modo che solo le aperture da tastiera entrino nel menu.
  • Enter e Space funzionano ancora perché generano un clic sui pulsanti.