Popover — accessibility

Keyboard

  • The menu follows the WAI-ARIA menu pattern (acrm !3560, 15 Sep 2026). Opening a panel with role="menu" or "listbox" moves focus to the first item, or to the panel itself when it has no items.
  • Up / Down move between rows, Home / End jump to the first and last (since ccd9350dc).
  • Rows are <button>, so Enter and Space activate them. Selecting a row closes the panel and returns focus to the trigger.
  • Escape closes the panel and returns focus to the trigger. Inside a modal Dialog the popover consumes Escape in the capture phase, so the dialog underneath stays open.
  • The panel also closes when the anchor's scroll container scrolls or the window resizes.

ARIA

  • Panel: role="menu" by default (overridable through role), named by ariaLabel.
  • Rows carry the role the screen gives them: menuitem, menuitemradio or menuitemcheckbox with aria-checked — state must be in the attribute, not only in the .chk glyph, which is hidden with visibility when off.
  • The trigger is expected to carry aria-haspopup="menu" and aria-expanded; the component does not set them on the caller's element.
  • The empty line .acrm-popover-empty is plain text and is not announced when it replaces the list.
  • The panel deliberately has no heading element — the accessible name replaces it.

Focus

  • Foundation ring from ds.css: outline: 2px solid var(--accent); outline-offset: 1px on :focus-visible — it covers button and [role='menuitem*'], so rows are visibly focused once focus reaches them.
  • With no items the panel itself takes focus, so a keyboard user always lands inside the menu.
  • Focus returns to the trigger after Escape or a selection — but only when it has fallen to <body>. A row whose action mounts its own autoFocus field (for example „Přejmenovat sloupec“ in the segment screen) keeps that focus; the check is the same one Dialog uses.
  • Focus on OPEN lands one frame later (acrm !3956, 27 Sep 2026): the panel is visibility: hidden until place() positions it, and focus() on a hidden element is a no-op — so the open-focus effect runs in the next animation frame (with cleanup). A search field inside the panel takes focus before the first item. jsdom ignores visibility, so only a real browser (Playwright) proves this.
  • The trigger that menus wrap in a non-focusable <span> anchor (PickerMenu, MenuButton, NavSelect) gets focus back through focusAnchor: the anchor itself, or its first focusable trigger (button/link/tabindex); an anchor holding a text field (combobox) is left alone, it manages its own focus (acrm !3932).
  • A click outside (on the scrim) closes the panel without returning focus: the user has pointed somewhere else, and focus stays where they clicked.

Contrast

Computed from packages/ui/src/tokens/tokens.css:

  • Row label .nm --ink-900 on --surface-100 = 17.59:1 light, 14.53:1 dark.
  • Icon slot .ic --fg-muted = 2.85:1 light, 3.63:1 dark — below the 3:1 non-text minimum in light theme.
  • Check .chk --accent = 4.44:1 light, 3.83:1 dark — passes 3:1.
  • Destructive row .danger uses --critical-ink = 5.37:1 light, 5.80:1 dark — passes AA.
  • Disabled row: the label is dimmed below AA by design; the row is also removed from the tab order.
  • Empty line --fg-muted = 2.85:1 light — below AA for text.
  • Panel border --border on --surface-100: hairline, below the 3:1 non-text minimum; the --shadow-pop elevation does the separating.

Known limits

  • No focus management and no arrow keys: the menu is effectively unreachable by keyboard unless it contains a field that autofocuses.
  • The trigger's aria-haspopup / aria-expanded depend on the caller; the component cannot enforce them.
  • The empty and loading line is silent for assistive technology.
  • Muted icons and the empty line sit below the contrast minimums in light theme.

Source: docs/popover/popover.a11y.md · GitLab