Select — accessibility

Keyboard

  • Trigger (button): Tab focuses it; ArrowDown, ArrowUp, Enter or Space open the menu; disabled removes it from the tab order.
  • Open menu: focus moves to the selected option (else the first enabled one) after a 0ms timeout; ArrowDown/ArrowUp move with wrap and skip disabled options; Home/End jump; Enter or Space pick (native button click); Esc closes and returns focus to the trigger.
  • Picking an option closes the menu and refocuses the trigger.
  • No type-ahead (typing a letter does not jump to an option) and no Tab trapping — Tab leaves the menu while it stays open until the scrim is clicked or Esc.
  • Scrim closes on mousedown; window scroll (of a scroll parent of the anchor) and resize also close.

ARIA

  • Trigger: role="combobox", aria-haspopup="listbox", aria-expanded, aria-required from required; aria-label, aria-labelledby, aria-describedby are passed through — inside <Field> use the label id.
  • Menu: <Popover role="listbox" aria-label>; rows are button[role="option"] with aria-selected and native disabled.
  • The hidden native select is aria-hidden="true" and tabindex="-1"; it exists only for the form (constraint validation bubble, FormData).
  • No aria-activedescendant — focus really moves between option buttons.

Focus

  • Trigger :focus-visible gets the foundation ring from ds.css: outline: 2px solid var(--accent); outline-offset: 1px. The .is-open class adds the accent border + 3px halo color-mix(in srgb, var(--accent) 16%, transparent).
  • Option buttons inside the menu get the same foundation outline (they match button:focus-visible).
  • Focus returns to the trigger on Esc and on pick; it does not return when the menu closes through the scrim, scroll or resize (focus stays on the last focused option, which is removed from the DOM → body).

Contrast

Computed from packages/ui/src/tokens/tokens.css (WCAG 2.x relative luminance):

  • Trigger value --ink-900 #18191b on --surface-100 #ffffff = 17.59:1 (light); #eceded on #1b1c1f = 14.53:1 (dark).
  • Placeholder --fg-muted #98999c on white = 2.85:1 (light) — below AA 4.5:1; #717479 on #1b1c1f = 3.63:1 (dark).
  • Disabled trigger text --fg-muted on --surface-75 #fbfbfb = 2.75:1 (light), 3.82:1 (dark).
  • Trigger border --border-strong #dcdbdd on white = 1.38:1 (light), #3a3c42 on #1b1c1f = 1.55:1 (dark) — hairline, below the 3:1 boundary minimum.
  • Open/focus border --accent #0072f5 on white = 4.44:1; on #1b1c1f = 3.83:1 — passes 3:1 for a non-text indicator.
  • Error border --critical #e5484d on white = 3.91:1 (light), 4.35:1 (dark).
  • Row text --fg = --ink-900 on the menu surface = 17.59:1 (light), 14.53:1 (dark); on hover --hover-surface = 17.0:1 (light, #fbfbfb) / 13.09:1 (dark, #232529).
  • Accent check --accent on white = 4.44:1 (light), 3.83:1 (dark).
  • Disabled row opacity: 0.4 — --ink-900 blended on white ≈ #a3a4a5 ≈ 2.6:1.

Known limits

  • Placeholder and disabled text (--fg-muted) are below AA 4.5:1 in light theme.
  • No type-ahead; long lists rely on arrow keys — the reason the family caps Select at ~10 items.
  • Closing via scrim/scroll/resize drops focus to body.
  • Option buttons are not a single tab stop: Tab walks through them (each is a real button), which differs from the WAI-ARIA listbox pattern.
  • Native constraint-validation bubble anchors to the sr-only select (fixed, no top/left) — it appears at the element's static position, not always next to the trigger.
  • multiple: every option reports its own aria-selected; Enter / Space toggles the focused option without closing the menu, Escape closes it and returns focus to the trigger, which shows the summary („3 vybráno“). The listbox sets aria-multiselectable="true" (acrm !4501), so a screen reader announces that several options can be chosen; a single select does not set it.

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