Item list — accessibility

Keyboard

  • A row that opens something is a real <button> (or an <a> when it has an href), so Tab reaches it and Enter or Space opens it.
  • A static row is a <div> and is deliberately not focusable — nothing promises an interaction that does not exist.
  • Lists that carry their own keyboard cursor (mail, queues) mark the focused row with the cursor state, which is distinct from active (the row the screen is showing). Both can be visible at once and they mean different things.
  • There is no arrow-key handling inside the component; a host that wants a cursor implements it and paints it with il-cursor. On a role="listbox" container the component itself animates the highlight (.il-cursor-bar, sliding on a spring, acrm !3997) once the host has moved il-cursor to the new row — the host still owns which row is the cursor, the component only owns how the highlight travels there.
  • Reordering (onReorder): Alt+↑ / Alt+↓ moves the focused row by one place; focus stays on the moved row and an aria-live="polite" region (.acrm-sr) announces the new position („Krypto přesunuto na 3. místo z 6“). The grip is aria-hidden — the row itself is the control, so keyboard and screen-reader users never need it.

ARIA

  • The container is role="list" and every row sits in role="listitem" — the row itself is a button, and an element has only one role, hence the wrapper.
  • role="listbox" with option items exists as an opt-in for the ⌘K palette, where the list is part of a combobox and a screen reader must hear "list with N options". It is a lead's decision (D-M3-7), not the canon.
  • The lead slot is decorative when it is an icon; when it is a checkbox it is a real input with its own label.
  • locked rows are dimmed and lead nowhere; the state is visual, so the row must also say why in its content when that matters.
  • Nothing announces that the list grew after lazy loading.

Tags

  • The tags slot is plain text in pills: no role, not focusable, nothing to announce beyond the words. A tag that is clickable (TaskList chip) is a real <button> inside the pill.
  • Tone and the leading icon of a pill (Project label) are not announced — the project name must say it all. Do not use a bare icon pill.
  • On lg the tags are moved to a separate line by CSS only; the DOM order stays title · date · tags, which is the reading order a screen reader gets. Clipped pills (overflow: hidden when they run out of room on desktop) are still in the DOM and in the row’s title; on the phone nothing is clipped, the line wraps.
  • Hairlines and the group bar .tl-grp.is-embedded are decoration; the group header keeps its own heading semantics from the host.

Focus

  • Rows are buttons or links, so the foundation ring from ds.css applies (outline: 2px solid var(--accent); outline-offset: 1px); item-list.css does not switch it off.
  • The cursor state is a background, not a focus ring — a host that moves the cursor with arrow keys must also move real focus, or expose it with aria-activedescendant. In a role="list" container that background is painted on the row itself; in a role="listbox" container it is the shared .il-cursor-bar behind the rows, and the row's own background stays transparent so the bar shows through.

Contrast

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

  • Title .il-t in ink on the page surface = well above AA in both themes.
  • Byline and content .il-by / .il-c and the meta column use muted tones — --fg-muted is 2.85:1 in light theme, below AA for text. This is the deliberate cost of "hierarchy by colour alone".
  • read lowers the tone further, so an already-seen row is the quietest thing in the list.
  • locked dims the whole row.
  • Focus ring --accent = 4.44:1 light, 3.83:1 dark.

Known limits

  • Hierarchy is carried by colour alone: for a user who cannot tell the tones apart, title, byline and meta read as one line.
  • Secondary text sits at the muted contrast limit, and read / locked go below it on purpose.
  • Lazy loading is silent — no live region announces new rows.
  • The keyboard cursor is a host responsibility; the component only paints it.
  • prefers-reduced-motion: reduce is respected for the role="listbox" cursor: both the cursor bar's spring and the listbox's cross-fade (ilListIn) turn off, so the cursor and the list appear instantly instead of travelling or fading (acrm !3997). Nothing else in Item list carries motion to begin with.

Source: docs/item-list/item-list.a11y.md · GitLab