Een dropdownmenu maken

Een dropdownmenu maken

In deze handleiding worden alle pop-upmenu's behandeld: dropdownmenu's, contextmenu's, overloopmenu's en de menubalken waarmee je ze opent. Het uitgewerkte voorbeeld is een opdrachtmenu van een app, waarin items de interface wijzigen (maken, opslaan, voorkeuren).

2 onafhankelijke keuzes. De rest van de informatie hier is van toepassing op beide.

  • Element, per item. Een opdracht is een <button type="button">. Een bestemming is een <a href> die nog steeds wordt geopend in een nieuw tabblad, wordt gekopieerd als een URL en werkt zonder script. In een dropdownmenu kunnen ze vrij worden gemengd: 2 links en 6 opdrachten of 1 opdracht en 6 links, verandert niets hieronder.
  • Interactiemodel, per dropdownmenu. Met de pijltoetsen, Home/End en Esc maak je er een menu van: voeg de focusgroep toe, laat deze menuitem afleiden voor elk item, of het nu een knop of een link is, en laat het eruitzien als een menu (een doorlopende balk of een trigger met een dropdownindicator, zie 'Menubalk of afzonderlijke menuknoppen' hieronder). Een korte lijst met links waar gebruikers doorheen Tab is navigatie: geen focusgroep, geen role="menu", role="menuitem" of aria-haspopup="menu". Behoud de popover, Esc en focusherstel en bekijk de gids voor de navigatiebalk.

Content bepaalt nooit de rollen. Een dropdownmenu Producten met links kan een menu zijn, maar een dropdownmenu Account met opdrachten kan geen navigatie zijn.

Gebruik het menupatroon alleen voor een samengestelde widget met één tabstop en navigatie met de pijltoetsen. Gebruik een <div focusgroup="menu nomemory">, niet het HTML-element <menu>. Voeg geen role="menu" of role="menuitem" toe aan de gedeelde opmaak: de native focusgroep leidt die rollen af en expliciete rollen blokkeren die afleiding. Voeg ze alleen toe aan de reserve-items voor niet-ondersteunde browsers hieronder. nomemory zorgt dat het opnieuw openen begint bij het eerste item. Focusgroup verplaatst de focus. Het script verwerkt het voorbeeld, de activering en het herstel van de focus.

Het menu openen en positioneren

Maak van elke trigger een opdrachtaanroeper met commandfor en command="toggle-popover". De browser opent en sluit de popover bij klikken, Enter en spatie, en houdt de trigger binnen de groep voor lichte sluiting van de popover, zodat klikken op een open trigger deze sluit. Gebruik niet popovertarget of popovertargetaction. Dit patroon gebruikt commandfor.

Plaats elke popover onmiddellijk na de trigger. Browsers voegen alleen een impliciete aria-details-relatie toe als de popover niet de volgende sibling is. Deze plaatsing voorkomt dit zonder extra kenmerken.

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

Geef elk menu een naam met aria-labelledby die naar de trigger wijst. Houd decoratieve submenu-indicatoren in CSS of voeg aria-hidden="true" toe. Geef triggers met aria-expanded="true" een zichtbare actieve stijl, zodat gebruikers weten welk menu is geopend of wordt weergegeven als voorbeeld.

Kies één formulier voor de triggers op het hoogste niveau. De rol, het toetsenbordmodel en de stijl moeten overeenkomen: visueel gescheiden knoppen geven elk één Tab-stop aan.

  • Menubalk. role="menubar" op de container (nooit op een pop-over), opgemaakt als één doorlopende balk zonder gaten tussen triggers. Eén Tab-stop; ArrowLeft/ArrowRight om tussen triggers te schakelen; plaats de muisaanwijzer op een menu om het te openen. Gebruik voor een opdrachtbalk van een app.
  • Afzonderlijke menuknoppen. Elke trigger is een eigen Tab-stop en ziet eruit als een losse knop. Geen role="menubar", geen pijltoetsen tussen triggers, geen voorbeeld bij focus, geen schakelen bij aanwijzen. Verpak in <nav> als ze navigeren, anders geen rol. Gebruik deze voor werkbalken en paginakopteksten.

In de rest van deze gids wordt het formulier voor de menubalk beschreven. Voor afzonderlijke knoppen houd je alles in elk menu en laat je het gedrag op menubalkniveau vallen.

Het menu positioneren met CSS-ankerpositionering

Gebruik CSS-ankerpositionering, geen JavaScript-coördinaten. Zowel command="toggle-popover" als showPopover({ source: trigger }) stellen een impliciet anker in, dus voeg geen anchor-name- of position-anchor-regels per menu toe. Menugedrag mag niet afhankelijk zijn van ankerpositionering.

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

Zet --below als laatste, zodat deze alleen wordt toegepast als geen van beide kanten past. Het menu wordt direct onder de trigger geopend, dus op een smal scherm bedekt het de items onder de trigger. Dit is de geaccepteerde afweging om naast de trigger te blijven. Laat het submenu ook krimpen tot het gebied (min-inline-size: 0, max-inline-size min de inspringing). Een fallback die niet past, wordt overgeslagen. Als geen enkele fallback past, behoudt de browser de basisplaatsing. Een submenu dat een paar pixels te breed is, bedekt dan de trigger zonder dat de gebruiker dit merkt.

Ga naar Viewport-Aware Dropdowns voor meer informatie over hoe de reserves voor omdraaien werken in een gewone popover. In die handleiding wordt een knoppengroep geplaatst, geen menu. Voor menu's hebben de plaatsing van het submenu en de --below-terugval hierboven voorrang.

Openen en sluiten animeren

Optioneel. Minder dan 150 ms, alleen opacity en een kleine translate, hetzelfde voor submenu's, binnen @media (prefers-reduced-motion: no-preference). De focus gaat verder op toggle, zodat de overgang toetsenbordgebruikers nooit vertraagt.

@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 is geen herhaling van [popover]: een gesloten popover is display: none en overgangen worden niet uitgevoerd vanuit een niet-gerenderde status, dus deze levert de voor-waarden voor de opening. Zonder deze functie wordt de open stand vastgezet en wordt alleen de sluiting geanimeerd. display en overlay met allow-discrete zorgen dat de popover wordt weergegeven en in de bovenste laag blijft totdat de sluitingstransitie is afgelopen.

Toetsenbord en focus

Voeg geen tweede roving-tabindex-systeem toe aan een focusgroep. Het script behandelt alleen wat de aanroepers en focusgroep niet doen:

  • Eén tabstop voor de menubalk. Tab komt binnen bij de eerste trigger op het hoogste niveau. ArrowLeft en ArrowRight bewegen tussen triggers op het hoogste niveau en gaan aan beide kanten terug naar het begin. Home en End op een trigger openen het menu bij het eerste of laatste item.
    • Opmerking: naar welke trigger Tab terugkeert. De APG staat de eerste trigger of de laatst gebruikte trigger toe. In de gids staat de eerste, maar in de demo wordt de tabstop verplaatst met ArrowLeft en ArrowRight, dus na toetsenbordnavigatie keert Shift+Tab terug naar de laatste trigger die is bereikt. Het gebruik van de aanwijzer verplaatst de aanwijzer niet, dus dezelfde reeks kan op een andere trigger terechtkomen. Beide gedragingen zijn acceptabel. Kies er een en houd deze consistent.
  • Voorbeeld. Als je de focus op een trigger op het hoogste niveau plaatst, worden het menu en de sets van de trigger getoondaria-expanded="true". De focus blijft op de trigger en er wordt geen item geselecteerd. Als je de trigger vervaagt, wordt een menu met alleen een voorbeeld gesloten.
  • Enter en spatie in een voorbeeld. Ze sluiten het voorbeeld en houden de focus op de trigger. De systeemeigen actie toggle-popover verwerkt dit, dus het script mag deze toetsen niet onderscheppen.
  • Pijl-omlaag op een voorbeeld. Hiermee wordt het menu geopend en wordt het eerste item geselecteerd.
  • Klik met de muisaanwijzer op een geopende of voorbeeldtrigger. Sluit het menu op de normale manier. Het menu wordt niet geopend.
    • Bekend verschil in submenu-triggers. Een muisklik op een submenu-trigger schakelt ook standaard, zodat een submenu dat al een voorbeeld heeft gegeven, kan worden gesloten of een submenu dat nog niet is verschenen, kan worden geopend. Systeemeigen menu's openen het alleen en sluiten het nooit. Dit is onschadelijk, dus de gids accepteert het.
  • Cursor klikt op een gesloten trigger. Opent het bestand in de oorspronkelijke app. De focus blijft op de trigger. Er is geen item geselecteerd. Alleen als je het toetsenbord opent, wordt het menu geopend.
  • Plaats de cursor op een item. Focust zonder ring, dus plaatsen en focussen zijn één item.
  • De cursor over een andere trigger op het hoogste niveau plaatsen terwijl een menu is geopend. Sluit het en toont een voorbeeld van het item waar de cursor op staat, waarbij de trigger zonder ring wordt gefocust. Niets geopend: de muisaanwijzer doet niets.
  • Cursor op een submenutrigger. Toont na een korte vertraging een voorbeeld van het submenu (aanwijzerfocus voert hetzelfde voorbeeld bij focus uit). Een item op hetzelfde niveau sluit het.
  • In een menu. ArrowUp en ArrowDown wisselen tussen aangezette items. Home en End gaan naar het eerste en laatste aangezette item, dat door het script wordt verwerkt.
  • Pijl-links of pijl-rechts in een geopend menu op het hoogste niveau. Sluit het menu en toont een voorbeeld van het aangrenzende menu met de focus op de trigger, waarbij aan beide kanten wordt teruggelopen. ArrowDown voert deze dan in. Links en Rechts komen nooit in een menu terecht.
  • ArrowRight op een submenutrigger. Opent het submenu en plaatst de focus op het eerste item.
  • ArrowRight op een submenu-item zonder onderliggend submenu. Sluit de hiërarchie en toont een voorbeeld van het volgende menu op het hoogste niveau terwijl de focus op de trigger blijft. ArrowDown voert deze dan in.
  • ArrowLeft of Escape in een submenu. Sluit alleen dat submenu en herstelt de focus op de trigger. Het menu voor ouders blijft open.
  • Enter of spatie op een opdracht. Hiermee activeer je de optie en sluit je alle geopende menu's.
  • Tabblad. Verlaat het menusysteem. Zorg dat de focus niet vast komt te zitten en dat je niet door elke opdracht hoeft te stappen. Na het sluiten mag de focus niet op verborgen content blijven staan.

De gebeurtenis command wordt geactiveerd voordat toggle-popover het menu opent, dus de focus kan daar niet naar het menu worden verplaatst. Verplaats de focus in plaats daarvan in de gebeurtenis toggle. HTML herstelt de focus alleen voor de eerste popover="auto" in een stapel, dus geneste niveaus moeten de focus expliciet herstellen naar hun trigger.

De interactie implementeren

Implementeer het bovenstaande gedrag voor je eigen opmaak. Pijl, Home/End, terugloop en Tab-to-exit-afhandeling is gewone keydown-code. Sommige onderdelen zijn makkelijk verkeerd te doen. Deze zijn hieronder gegroepeerd op invoertype.

Toetsenbord- en focusverwerking

  1. Eén open pad. Als je het menu opent met het toetsenbord, wordt trigger.click() aangeroepen. De toggle-popover van de aanroeper is dus de enige manier om een menu te openen. Markeer het menu zodat de toggle-handler het opent. Alleen het toetsenbord wordt geopend. Als een aanwijzer wordt geopend, richt toggle zich op de trigger zonder ring, omdat pointerdown focus heeft voorkomen en de pijltoetsen nog steeds moeten werken.
  2. Voorbeeld bij focus. Als je een trigger activeert, wordt showPopover({ source: trigger }) aangeroepen, zodat het menu eraan wordt verankerd. Focus negeren die terugkeert vanuit het eigen menu van de trigger of vanuit een ander venster (vlag op venster blur, vinkje verwijderd na venster focus of schakelen tussen apps opent het laatste menu opnieuw). Een voorbeeldmenu verbergen op blur, tenzij de focus ernaartoe is verplaatst.
  3. Verplaats de focus in toggle, niet in command. command wordt geactiveerd voordat het menu wordt geopend.
  4. Herstel de focus zelf naar de trigger. Focus de trigger vóór hidePopover(). Herstel na een aanwijzeractivering de focus zonder een ring (focus({ focusVisible: false })) of de trigger toont de toetsenbordfocusomtrek na een muisklik. Toetsenbordactiveringen behouden de ring. Een klik met event.detail === 0 kwam van het toetsenbord.
  5. Sluit één niveau tegelijk. Roep stopPropagation() aan op Escape en ArrowLeft in een submenu, anders sluit de listener van het bovenliggende element het ook.

Aanwijzerverwerking

  1. Schakelaars voor aanwijzerklikken. Roep preventDefault() aan op pointerdown zodat de klik de focus niet verplaatst en een voorbeeld start tijdens de klik. Onthoud welke trigger is ingedrukt. Als je op een gesloten of voorbeeldtrigger klikt, wordt deze standaard geopend of gesloten en wordt het menu nooit geopend. Alleen als je het toetsenbord opent, wordt het menu geopend. Onderschep de klik niet.
  2. De cursor volgt de focus. Stijl :hover en :focus-visible afzonderlijk. Als je de muisaanwijzer op pointermove plaatst, wordt focus({ focusVisible: false }) aangeroepen. Browsers die de optie negeren, tekenen geen ring voor de focus van de muisaanwijzer.
  3. Als je de muisaanwijzer op een menu plaatst, wordt het geopend. Bij pointerenter van een trigger op het hoogste niveau terwijl een ander menu op het hoogste niveau is geopend: verberg het, focus de trigger waar de muisaanwijzer op staat zonder een ring en een voorbeeld bij focus (zie hierboven) toont een voorbeeld. Niets open: doe niets.
  4. Veilige driehoek. Een vaste CSS-brug is te smal: deze werkt alleen als de cursor dicht bij de rand van het bovenliggende element begint. Laat CSS de brug tekenen en gebruik het script alleen om de aanwijzer te rapporteren:
    • Voer dit alleen uit als matchMedia('(hover: hover)').matches. Dit is niet relevant voor aanraken en toetsenbord.
    • Start wanneer de cursor een submenu-trigger verlaat waarvan het submenu is geopend. Sla een submenu van --below over, dat geen zijrand heeft.
    • Stel --bridge-x en --bridge-y (de aanwijzer, in px ten opzichte van het submenu), --bridge-edge (de x van de dichtstbijzijnde rand van het submenu: 0 als de aanwijzer zich links ervan bevindt, de breedte als deze is omgedraaid) en --bridge-height in op het submenu en data-bridge om de brug aan te zetten.
    • Stop na ongeveer 300 ms, wanneer de muisaanwijzer een item in het submenu bereikt of wanneer het submenu wordt gesloten. Verwijder de listener en het kenmerk. Daarna wordt er niets meer uitgevoerd.
    • CSS geeft het submenu een ::before die is bijgesneden tot een driehoek van de aanwijzer naar de hoeken aan de rand. Het element bevindt zich boven de onderliggende items op het pad van de cursor, zodat ze nooit de muisaanwijzer krijgen die het submenu zou sluiten. clip-path beperkt ook hit-tests, zodat de rest van het bovenliggende menu interactief blijft.
    • Het submenu heeft overflow: visible nodig (een popover wordt standaard afgekapt) en het pseudo-element steekt er voorbij, dus verschuif de punten van de vorm met die mate.
    • Vervolg: een gebogen pad (shape() curve-opdrachten) volgt echte aanwijzerbogen beter dan een rechte driehoek.
[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 (item 2) blijft een gewone pointermove-handler: er is geen driehoekslogica nodig.

En verder:

  • Buiten de focus. Sluit elk geopend menu als de focus buiten het menusysteem terechtkomt (een document focusin-listener).
  • Opdrachtactivering. Kondig dit aan, verberg het hoofdmenu en herstel de focus naar de trigger, zonder een ring als de activering afkomstig was van een aanwijzer. Wacht voor items die kunnen worden aangevinkt (menuitemcheckbox, menuitemradio) ongeveer 300 ms voordat je sluit, zodat het gewijzigde vinkje zichtbaar is. Stijl een gesloten trigger alleen als actief op :focus-visible. Anders blijft de herstelde focus (zonder ring) gemarkeerd na een aanwijzerselectie.
  • aria-expanded. Stel deze alleen in de toggle-listener in. Dit geldt voor de voorbeeld- en geopende status. De aria-expanded van de trigger blijft "true" terwijl het menu wordt getoond of geopend.
  • Helpers. De onderstaande fallbacks gaan uit van helpers die je definieert voor je opmaak: triggers (alle [commandfor]-knoppen), menuFor(trigger), isOpen(menu), closeAndRestore(menu, { fromPointer }) (focus op de trigger, zonder ring als fromPointer, dan menu.hidePopover()), enterOnOpen (een WeakSet van menu's die je kunt openen als ze eenmaal zijn geopend) en pointerTrigger (de trigger die het laatst is ingedrukt door een aanwijzer).
  • Gebruik popover="auto" voor submenu's, geplaatst na de submenu-trigger in de focusgroup van het bovenliggende element. Nooit popover="manual".
  • Met nesten kan de browser het bovenliggende menu openhouden, subsubmenu's sluiten, onderliggende submenu's sluiten als het hoofdmenu wordt gesloten en het sluiten van het menu afhandelen als er buiten het menu wordt geklikt.
  • De submenu-trigger behoudt aria-haspopup="menu" en aria-expanded. De toggle-listener houdt aria-expanded gesynchroniseerd.
  • Het toetsenbordscript verwerkt geneste Escape en ArrowLeft zoals hierboven beschreven.
  • Een submenu-trigger voert nooit ook een opdracht uit: één bedieningselement kan niet zowel activeren als openen, en Enter kan niet beide betekenen. Zet de standaardactie als het eerste submenu-item of voeg een afzonderlijk opdrachtitem toe naast de trigger. Als een gecombineerde bediening wordt afgedwongen, documenteer je dit als een toegankelijkheidscompromis.

Scheidingstekens en aanvinkbare items

  • Afzonderlijke productgroepen met <hr> in de focusgroep: een native separator, niet focusbaar, overgeslagen door focusgroep en de fallback. Nooit een gestileerde <div>.
  • Een booleaanse optie is een <button type="button"> met aria-checked en een expliciete role="menuitemcheckbox". Focusgroup leidt deze rollen niet af, dus dit is de enige plek waar gedeelde opmaak een itemrol instelt.
  • Een van de sets is een <button type="button"> met role="menuitemradio" en aria-checked, in een role="group" met een aria-label, in dezelfde focusgroep.
  • Scriptschakelaars aria-checked.
  • Enter op een selectievakje schakelt de optie in of uit en sluit het menu. Space schakelt de optie in of uit en houdt het menu open (APG). Radio's sluiten met beide toetsen.
  • Geef het vinkje weer vanuit [aria-checked="true"]::before, niet als een afzonderlijk icoonelement (zie de CSS hieronder).

Hier niet behandeld

  • Feedback over activering. Systeemeigen menu's laten het gekozen item knipperen voordat ze worden gesloten. Toon indien gewenst een visuele bevestiging van ongeveer 100 ms vóór hidePopover(). Vertraag het herstel van de focus of de aankondiging niet.
  • Typeahead. Focusgroep heeft geen. Pas indien nodig op afdrukbare keydown de aangezette itemlabels van het huidige niveau aan en focus de volgende overeenkomst na het huidige item, met terugloop. Onderschep Space niet.

Verifiëren voordat je afrondt

Een instructie kan geen gedrag garanderen, dus test het resultaat. Open de pagina en controleer elk van deze met het echte toetsenbord en de echte muis. Los eventuele problemen op en test opnieuw:

  • Toetsenbord. Tab landt één keer op de eerste trigger, ArrowDown opent, Escape sluit één niveau en zet de focus terug op de trigger, Tab sluit elk menu.
  • Cursor openen. Als je op een trigger klikt, wordt het menu geopend met de focus op de trigger en zonder ring. Als je nog een keer klikt, wordt het menu gesloten.
  • Opdracht voor aanwijzer. Als je op een opdracht klikt, worden alle menu's gesloten en wordt er geen focusring op de trigger getoond.
  • Submenu. Als je diagonaal van het midden van een submenutrigger naar een submenu-item gaat, blijft het submenu geopend. Als je naar een niet-gerelateerd item op hetzelfde niveau gaat, wordt het submenu gesloten.
  • Smal scherm. Bij 375 px bedekt geen enkel submenu de eigen trigger.
  • Vinkjes. Aangevinkte items hebben een vinkje.
  • Geen restjes. Geen consolefouten en geen popovertarget, popovertargetaction of aria-details voor triggers.

Content van menu-items uitlijnen

Als een rij een icoon, label en sneltoets of submenu-indicator heeft, reserveer je een voorloopruimte van vaste grootte voor het icoon of vinkje en verplaats je de volgcontent naar het inline einde. Iconen hebben een bekend formaat, dus een vaste ruimte zorgt dat labels uitgelijnd blijven, of een item nu een icoon heeft of niet. Gebruik subgrid niet voor rijen: een extra inline onderliggend item (bijvoorbeeld een badge) wordt een eigen rasteritem en verstoort de vormgeving. Verberg de tekst van de sneltoets voor ondersteunende technologie als deze alleen een toets herhaalt die het item al bedient.

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

Plaats een indicator of sneltoets aan het einde in een <span class="hint" aria-hidden="true"> in de knop. De submenu-trigger kondigt de pop-up al aan.

Progressive enhancement en fallbacks

Fallbacks en browserondersteuning voor focusgroep

Laat focusgroup="menu nomemory" in de opmaak staan. Als 'focusGroup' in HTMLElement.prototype onwaar is, installeer je Omhoog, Omlaag, Home en End, stel je role="menu" in voor elke menucontainer en role="menuitem" voor knoppen zonder rol. Laat items die kunnen worden aangevinkt, <hr> en role="group" met rust. Installeer deze nooit als er een systeemeigen focusgroep aanwezig is. Links, rechts, voorbeeld, openen en sluiten zijn in beide gevallen auteursrechten.

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

Fallbacks en browserondersteuning voor Popover

De Popover API is voornamelijk progressive enhancement, maar de bepalende gedragingen (promotie van de bovenste laag, licht sluiten en aanroepen van popovertarget) hebben geen equivalent dat alleen CSS gebruikt. Oudere browsers hebben een polyfill nodig, of een handmatige fallback als je er liever geen verzendt.

Polyfill: Als je het kenmerk popover wilt ondersteunen in oudere browsers, laad je @oddbird/popover-polyfill voorwaardelijk. VERPLICHT: Voer een functiedetectie uit door te controleren op de property popover op HTMLElement.prototype en laad de polyfill alleen als systeemeigen ondersteuning ontbreekt. Laad de polyfill NIET onvoorwaardelijk.

Met een bundler of importtoewijzing:

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

Zonder bundler importeer je vanuit een CDN in een <script type="module">:

<script type="module">
  if (!("popover" in HTMLElement.prototype)) {
    import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
  }
</script>

Opmerking over de stijl: De polyfill kan de echte pseudoklasse :popover-open niet definiëren, dus wordt in plaats daarvan de klasse .\:popover-open toegepast. VERPLICHT: Combineer de 2 met :is() of :where(), anders negeren browsers zonder :popover-open de hele regel:

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

Als alternatief kun je voor een verouderde fallback zonder polyfill position: fixed gebruiken en de coördinaten handmatig berekenen via getBoundingClientRect() of vertrouwen op de standaardpositionering met inset: auto als dat acceptabel is voor de use case.

Fallbacks en browserondersteuning voor ankerpositionering

Zonder CSS-ankerpositionering valt een popover terug naar de gecentreerde viewport-plaatsing van de UA. Gebruik dat nooit voor een menu: het menu wordt dan losgekoppeld van de trigger en een gecentreerd submenu bedekt het bovenliggende menu. Beperk de verankerde regels achter @supports (position-area: block-end) en positioneer het menu vanuit het script als dit mislukt:

  • Plaats het menu onder de trigger (hoogste niveau) of ernaast (submenu). Draai het menu naar de andere kant en klem het vast aan de viewport als het overloopt.
  • Voer deze uit in de toggle-handler als het menu wordt geopend en pas het resultaat toe als left en top in het menu. Gebruik position: fixed met inset: auto en margin: 0 zodat de coördinaten relatief zijn aan de viewport.
  • Houd deze overschrijvingen binnen @supports not (position-area: block-end), zodat browsers met ankerpositionering niet worden beïnvloed.

Als je eerdere implementaties target waarbij showPopover({ source }) geen impliciet anker tot stand brengt (geen source op ToggleEvent.prototype), geef je elke trigger een unieke anchor-name en elk menu een overeenkomende position-anchor vanuit het script, in plaats van css-regels per menu te schrijven.

Voor browsers zonder ankerpositionering ga je naar Viewport-Aware Dropdowns voor de polyfill-route.

Fallbacks en browserondersteuning voor Invoker-opdrachten

Zonder aanroepers doet command="toggle-popover" niets, dus het script moet het menu aan- of uitzetten.

  • Detecteer de functie van de interface, niet van een instantie: 'commandForElement' in HTMLButtonElement.prototype.
  • Roep bij elke trigger click preventDefault() aan, zodat een browser met aanroepers niet 2 keer kan schakelen.
  • Als het menu is geopend, sluit je het met closeAndRestore(menu). Bel anders menu.showPopover({ source: trigger }).
  • Markeer het menu in enterOnOpen, tenzij de klik afkomstig is van de ingedrukte trigger (pointerTrigger), zodat alleen toetsenbordopeningen het menu openen.
  • Enter en Space werken nog wel, omdat ze een klik op knoppen genereren.