Accessibility

TreeListView targets WCAG 2.2 Level AA and follows the ARIA Authoring Practices Guide patterns for grid and treegrid.


What the component provides

Area
Roles treegrid in tree mode, grid in list mode; row, columnheader, gridcell, rowgroup
Position aria-level, aria-posinset, aria-setsize on every tree row
Counts aria-rowcount, aria-colcount on the grid; aria-rowindex, aria-colindex on rows and cells
State aria-expanded, aria-selected, aria-sort, aria-disabled, aria-busy, aria-multiselectable
Focus Roving tabindex — the grid is one tab stop, arrows move within it
Announcements A polite live region reports selection, sorting, filtering and editing
Labels Every control has an accessible name; all are configurable properties
Motion Honours prefers-reduced-motion
Contrast Honours forced-colors: active (Windows High Contrast)

aria-rowindex matters more than it looks: without it a virtualised grid tells a screen reader it has 30 rows when it has 30,000, and "row 812 of 30,000" cannot be announced.


Keyboard reference

Key Action
Tab Move into, and out of, the grid
Previous / next row
Home / End First / last row
Page Up / Page Down One viewport, keeping a row of context
Ctrl+Home / End First / last row
printable character Type-ahead: jump to the next row starting with it

Tree

Key Action
Expand a closed parent, or step into the subtree
Collapse an open parent, or step out to the parent row
+ / - Expand / collapse
* Expand every sibling of the focused row
Enter Toggle a parent, or activate a leaf (tlvRowDoubleClick)

Cells (0.6)

Only with cellNavigation. The roving tab stop moves from the row to the focused cell.

Key Action
Move one cell — clamping at the edges
Tab / Shift+Tab Next / previous cell, wrapping at the row end
Home / End Ends of the row
Ctrl/Cmd+Home / End Corners of the grid
Shift+arrows Extend the cell rectangle

Expansion moves to the tree column per the APG treegrid pattern: opens a closed row and closes an open one when the focused cell is the tree column's, and moves between cells everywhere else. + and - expand and collapse from any column, so expansion is never out of reach.

Cells in a selected range carry aria-selected, and the range's size is announced politely as it changes.

Column header (0.3)

The header has a roving tab stop of its own: one Tab reaches it, the arrows walk it.

Key Action
Previous / next header cell (mirrored in RTL)
Home / End First / last column
Enter / Space Sort — Shift adds a tie-breaker
Ctrl/Cmd+Shift+/ Move the column
Ctrl/Cmd+Shift+G Group / ungroup by the column

Every action is announced politely, and so is every refusal: a column with reorderable: false announces that it cannot be moved rather than silently ignoring the key.

Selection

Key Action
Space Toggle the focused row
Shift+/ Extend the selection
Ctrl/Cmd+A Select every visible row
Escape Clear the selection

Editing

Key Action
F2 or Enter Open the editor on an editable cell
Enter / Tab Commit and move on
Escape Discard

Other

Key Action
Ctrl/Cmd+C Copy the selection as TSV (or the whole view if nothing is selected)
Ctrl/Cmd+V Paste a clipboard block, when allowPaste is on (0.5)
Ctrl/Cmd+Z Undo, when allowUndo is on (0.5)
Ctrl/Cmd+Shift+Z or Ctrl+Y Redo (0.5)
Ctrl/Cmd+F Focus the filter box, when show-filter is on
on a resize handle Resize the column (Shift for a coarse step)

Keys the grid does not claim — Tab, F5, Ctrl+P — pass straight through to the browser.


Configuring the accessible names

<bm-treelistview
  accessible-label="Project portfolio"
  select-all-label="Select all visible projects"
  row-checkbox-label="Select project"
  footer-visible-label="Projects"
  footer-selected-label="Chosen"
  empty-text="No projects match those filters."
  loading-text="Loading projects…">
</bm-treelistview>

accessible-label becomes the grid's aria-label. Set it to something meaningful — "Tree list view" tells a screen-reader user nothing about what they are looking at.


Design decisions worth knowing

Selection following focus

selection-follows-focus defaults to true, so arrow keys change the selection as well as the focus. That is right for a master/detail layout, where moving the focus is the point.

It is wrong for multi-select: a user needs to move past rows without selecting them. Set it to false whenever selection-mode is multiple or checkbox and the user builds a set:

<bm-treelistview selection-mode="multiple" selection-follows-focus="false"></bm-treelistview>

Row action buttons and the tab order

Action buttons are outside the tab order by default. Fifty rows with three buttons each would put 150 tab stops between the grid and whatever follows it, which makes the page unusable by keyboard.

Users reach the actions by focusing the row. If your grid has few rows and the buttons are the primary interaction, opt in:

<bm-treelistview row-actions-tabbable="true"></bm-treelistview>

Keyboard column resizing

Resize handles are focusable and respond to /. WCAG 2.1 SC 2.1.1 requires a keyboard equivalent for every pointer operation, and a truncated column is otherwise unreadable for a keyboard-only user.

The same reasoning drove the 0.3 header shortcuts: reordering and grouping were drag-only, so both failed SC 2.1.1 outright. autoFitColumn() gives a keyboard user the equivalent of double-clicking a column edge.

Localised announcements (0.7)

Every announcement resolves through the messages catalogue, so a grid in a non-English application speaks that application's language. Counted messages select CLDR plural categories with Intl.PluralRules rather than testing count === 1, which is right for English and wrong for most other languages.

Skeleton rows (0.7)

Placeholder rows are aria-hidden. The grid's aria-busy already announces the loading state, and a screen reader reading out a dozen empty rows on top of that is worse than silence. The shimmer honours prefers-reduced-motion.

Printing (0.7)

With printAllRows on, a virtualised grid renders every row for the duration of the print job. A printout silently missing most of its rows is a serious accessibility and correctness problem for anyone who works from paper or from a PDF.

Pagination (0.8)

The pager is a role="navigation" landmark. The current page carries aria-current="page" rather than relying on its colour, and previous/next are properly disabled at the ends instead of silently doing nothing.

Column virtualisation (0.8)

Cell navigation spans every visible column, not just the rendered window, so arrowing right reaches columns that have not been painted yet — the window follows focus. Pinned and frozen columns are never windowed out.

Truncation tooltips

A cell is given a title only when its content is genuinely clipped. Setting one unconditionally is actively harmful for screen-reader users: the tooltip is announced in addition to the cell's own text, so every cell would be read twice.

Pinned rows

Rows pinned to the top or bottom sit outside the scrolling body but inside the same grid. Their aria-rowindex continues the grid's numbering rather than restarting, and they are counted in aria-rowcount, so assistive technology reads one grid rather than three.

Async validation and focus

While an asynchronous validator runs, the editor stays open and is disabled, and a role="status" note appears beside it. Both matter for assistive technology: the disabled input stops focus moving on to a field whose value is about to be refused, and the status note means the wait is announced rather than being a silent pause. The refusal that follows is a role="alert", so it interrupts.

Live region announcements

Selection, sort, filter and edit changes are announced politely. Turn them off if your application makes its own announcements and the two would collide:

<bm-treelistview announce-changes="false"></bm-treelistview>

Detail panels are rows, not cells

A master/detail panel is rendered as its own role="row" immediately below the row it belongs to, with a single gridcell spanning the full width. It is deliberately not nested inside the data row's cell: a gridcell containing arbitrary interactive content — buttons, links, another table — breaks the grid's accessibility tree, and screen readers stop reporting the cell positions correctly.

Because the panel is a row, every row below it moves down by one. aria-rowindex and aria-rowcount both account for open panels, so "row 7 of 40" stays true as panels open and close. Two rows claiming the same index, or a count that does not match, is exactly what makes a grid read as broken.

Content inside a panel is yours to make accessible. The grid gives it a landmark-free container and nothing else. If your panel is a form, label its fields; if it is a chart, give it a text alternative. Keep its heading level consistent with the surrounding page.


Your responsibilities

The component cannot do these for you.

Give it a name. Set accessible-label, or point aria-labelledby at a visible heading.

Keep contrast. If you override --tlv-* colours, keep body text at 4.5:1 and large text at 3:1. Do not dilute --tlv-focus-ring.

Do not rely on colour alone. A red "Overdue" badge needs its text too, not just its background.

Label your custom renderers. A renderer that returns a bare icon needs a text alternative:

grid.cellRenderers = {
  status: ({ value }) => {
    const span = document.createElement('span');
    span.innerHTML = `<span aria-hidden="true">●</span><span class="sr-only">${value}</span>`;
    return span;
  },
};

Give the grid a height. A grid squeezed to a few pixels is unusable with a screen magnifier.

Test with a real screen reader. NVDA + Firefox and VoiceOver + Safari are the two combinations most likely to reveal a problem.


Known limitations


Automated testing

Component behaviour is covered by the browser test suite (npm run test.browser), including roles, aria-level/posinset/setsize, aria-rowindex/colindex, aria-sort, the roving tabindex, and the keyboard grid.

To add axe-core assertions to your own application tests:

import { injectAxe, checkA11y } from 'axe-playwright';

await injectAxe(page);
await checkA11y(page, 'bm-treelistview', {
  detailedReport: true,
  axeOptions: { rules: { 'color-contrast': { enabled: true } } },
});

Automated tools catch perhaps a third of real accessibility problems. They are a floor, not a ceiling.