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
Navigation
| 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
- Row navigation is still the default. Cell navigation is opt-in via
cellNavigation, because changing the focus model under an existing application would be a breaking change in everything but name. Editing is reached by double-click, F2, Enter orbeginEdit()in both. - Banded headers are announced through
aria-colspanon the band cell. Support for spanning column headers varies between screen readers, so do not rely on the band alone to carry meaning that a user must have — keep each column's owntitleself-explanatory. - A detail panel is not in the keyboard grid. Opening a panel does not move focus into it, and the grid's arrow keys do not enter it — Tab does, because the panel's own content is in the natural tab order. This is deliberate: capturing arrow keys inside a panel would break the grid navigation the user was in the middle of. Give a panel that holds a lot of content its own visible heading so a screen-reader user landing there knows what they are in.
- Conditional formatting is visual only. A rule that colours a cell red conveys nothing to a
screen-reader user, and colour alone fails WCAG 1.4.1. Pair any rule that carries meaning with
something non-visual — a
badgecolumn, a status word, or anaria-labelthrough a custom renderer.
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.