Accessibility
A div-based ARIA grid with one tab stop, full keyboard navigation, a live region that speaks the grid's own language, and a layout that mirrors under RTL.
Enter folds the group under the caret. Focus also survives a column disappearing, which used to leave a
grid impossible to tab into.One Tab Stop
Click a cell in the grid below, then walk it with the arrow keys. Press Tab and you leave the grid in one press rather than one per cell: the cells carry a roving tabindex, and every control inside a cell answers through the cell that holds it.
That is also why the selection checkbox is not tabbable, and why Space on the
cell toggles it instead.
// One tab stop. The cells carry a roving tabindex: the active one is 0,
// every other is -1, so Tab leaves the grid rather than walking it.
//
// A control inside a cell answers through the cell rather than taking a
// stop of its own. That is why the selection checkbox is not tabbable and
// Space on the cell toggles it instead: a hundred-row grid used to cost a
// hundred presses to step over.
// Where the focus is, and how to move it
grid.focus.active; // { row, col, section? }
grid.focus.focusCell({ row: 0, col: 2 });
grid.focus.moveBy(1, 0);
// A negative row is the header: the body starts at 0, so a focus sitting
// above it is the header row rather than a row of data.
grid.focus.active.row < 0;Navigation
| Keys | Moves |
|---|---|
Arrow keys | Move one cell, header row included |
Arrow down from a header | The filter row when one is drawn, then the first body row. It is a line of the grid rather than a strip above it, which is what makes it reachable at all |
Arrow up from a leaf header | Into the header groups above it, level by level. A column with no group above it has nowhere to go up to |
Arrow left or right on a group | Steps between the groups of that level rather than between columns |
Home / End | First or last cell of the row |
Ctrl + Home / End | First or last cell of the grid |
Page up / Page down | One viewport of rows at a time |
Tab | Leaves the grid, because the whole grid is one tab stop |
Actions
Every binding a registered feature contributes. A feature that is not registered contributes nothing, so its keys stay free.
| Keys | Does |
|---|---|
Space | Toggles selection on the focused row |
Enter or Space on a group | Folds or unfolds the header group under the caret, and the announcer says which it did |
Shift + Space | Extends the selection from the anchor row |
Ctrl or Cmd + A | Selects every selectable row |
Ctrl or Cmd + C | Copies the selection as TSV |
Ctrl or Cmd + V | Pastes tab-separated text from the focused cell. Bound to the paste event rather than to the keystroke, so a right-click paste works and no clipboard permission is asked for |
Enter or F2 | Opens the editor, or sorts when the header has focus |
A printable key | Opens a text, number or textarea editor seeded with the character that was typed |
Ctrl or Cmd + Enter | Commits an editor that claims Enter for itself |
Escape | Closes what the editor opened, a menu or a calendar, and then the editor itself |
Ctrl + Z, Ctrl + Shift + Z, Ctrl + Y | Undo and redo an edit; both redo forms are bound |
Shift + Enter | Adds the focused header column to the sort |
Shift + Arrow left or right | Resizes the focused column |
Alt + Arrow left or right | Moves the focused column |
Alt + Arrow up or down | Moves the focused row, or opens the column menu on a header |
Arrow right or left on a nested row | Expands the row, collapses it, and from a collapsed child steps to its parent. Enter toggles the same row |
The ARIA Shape
Positions are absolute rather than relative to the rendered window, which is what lets a virtualized grid tell the truth: row 1,400 of 2,000 says exactly that, even though only twenty rows exist in the DOM.
The grid below holds 2,000 rows and renders a dozen or so. Click a cell, sort a column, tick a row, then read the attributes off the DOM: the count never shrinks to the window, and the row index says where the row sits in the whole set. The count reads 2,001 because ARIA counts the header row too. The last badge is the exception that proves the point: it counts rows in the DOM, so sorting a selected row out of the window takes it back to zero while the grid still holds the selection.
<!-- A div-based ARIA grid, or treegrid once rows nest. Every row and cell
carries its position, so a screen reader can say where it is even
though only a window of rows exists in the DOM. -->
<div role="grid" aria-rowcount="2000" aria-colcount="5">
<div role="rowgroup">
<div role="row" aria-rowindex="1">
<div role="columnheader" aria-colindex="1" aria-sort="ascending" tabindex="0">Name</div>
</div>
</div>
<div role="rowgroup">
<div role="row" aria-rowindex="42" data-dg-row-id="41">
<div role="gridcell" aria-colindex="1" data-dg-cell="40:0" tabindex="-1">Ada</div>
</div>
</div>
</div>ARIA Attributes
| Attribute | Where and why |
|---|---|
role="grid" / "treegrid" | On the viewport. It reads grid.expansion.enabled, which an app sets when its rows nest: aria-level on the rows is not enough on its own, since a level can describe depth in a flat list |
aria-rowcount / aria-colcount | The whole set, not the rendered window. The rows above the body are counted too: the header levels, the leaf header row, and the filter row when one is drawn |
aria-rowindex / aria-colindex | Absolute position, so a virtualized row still says where it is |
aria-sort | On a sorted column header |
aria-selected | On a selected row. A feature can put it on a cell through its decoration |
aria-colspan / aria-rowspan | On a merged cell, which is the single tab stop for its block |
aria-expanded / aria-level | On rows that nest |
aria-invalid | On an editor whose value failed validation |
aria-live="polite" | The announcer region, outside the grid |
The Announcer
Sorting, filtering, paging, selection, column changes, row moves and a blocked edit all go through a polite live region. Every string belongs to the locale pack, so it speaks the language the grid is in, and any of them can be replaced.
The region is invisible, so the panel below mirrors it. Sort a column, type in the quick filter, tick a row or move a column, and read what a screen reader would have heard.
aria-live="polite", last five
Nothing said yet. Sort a column to start.
// A polite live region announces what a sighted user sees change.
// Every string is part of the locale pack, so it speaks the grid's
// language, and any of them can be replaced.
createDataGrid<Person>({
columns, data, getRowId,
announcer: {
sorted: (column, direction) => `${column}, ${direction}ending`,
filtered: (count) => `${count} rows left`,
selected: (count) => `${count} selected`,
rowMoved: (position) => `moved to ${position}`
}
});
// The full set also covers sortCleared, page, columnResized, columnMoved,
// columnPinned, columnVisibility, copied, rowExpanded, rowPinned and
// editInvalid.What It Says
Every string the announcer speaks. Each one belongs to the locale pack, so it is in the grid's language, and any of them can be replaced through announcer.
| String | Spoken when |
|---|---|
sorted(column, direction) | A column became sorted |
sortCleared() | The sort was cleared |
filtered(count) | The filter changed, with the rows it left |
page(page) | The page changed |
columnResized(column, width) | A column was resized |
columnMoved(column, position) | A column was moved |
columnPinned(column, side) | A column was pinned or unpinned |
columnVisibility(column, hidden) | A column was hidden or shown |
groupCollapsed(group, collapsed) | A header group was folded or unfolded |
selected(count) | The selection changed, with its size |
copied(count) | Rows were copied to the clipboard |
rowExpanded(expanded) | A nested row was expanded or collapsed |
rowPinned(side) | A row was pinned or unpinned |
rowMoved(position) | A row moved, with its new 1-based position |
editInvalid(message) | A commit was blocked by validation |
Right to Left
Layout uses logical properties throughout, so dir="rtl" mirrors the grid rather than restyling it. Flip the switch below: Name is pinned to the start,
so it moves to the right edge and its shadow turns with it, and the sort arrow and column menu
follow.
The columns are wider than the demo, so drag the scrollbar in both directions too.
Horizontal scrolling is normalized through scrollStart, because browsers disagree on whether scrollLeft is negative under RTL.
<!-- Layout uses logical properties throughout, so dir="rtl" mirrors the
grid, pinned columns included. Horizontal scroll is normalized through
scrollStart, since browsers disagree on the sign of scrollLeft under
RTL. -->
<div dir="rtl">
<DataGrid {grid} toolbar />
</div>