Data Grid
A virtualized data table for large datasets, more rows than a page can hold (thousands to hundreds of thousands): one scrolling box in which only the rows in view are rendered, by your code, when the grid asks for them. Arrow keys move between cells as in a spreadsheet, and the grid tells assistive technology how many rows there are and which ones it shows. Find in page, printing, and reading the rows in a screen reader's browse mode only reach the rendered rows; when people need those, use aihio-table with aihio-pagination instead.
Intents
tabular-data
Examples
Each one rendered live, over the markup that produces it.
A hundred thousand rows
The rows live in script. aihio-range asks for the ones in view and the script renders them into body; aihio-sort asks for them in a new order. Try the arrow keys, Page Down, and Control+End.
| Request | Method | Path | Status | Duration (ms) |
|---|
Show code · 48 lines
<aihio-data-grid id="requests-grid" row-count="100000" density="compact">
<table aria-label="Requests">
<colgroup>
<col style="width: 8rem">
<col style="width: 6rem">
<col>
<col style="width: 6rem">
<col style="width: 9rem">
</colgroup>
<thead>
<tr>
<th scope="col" data-sortable="id" aria-sort="ascending">Request</th>
<th scope="col" data-sortable="method">Method</th>
<th scope="col" data-sortable="path">Path</th>
<th scope="col" data-sortable="status" data-numeric>Status</th>
<th scope="col" data-sortable="duration" data-numeric>Duration (ms)</th>
</tr>
</thead>
<tbody></tbody>
</table>
</aihio-data-grid>
<script type="module">
const grid = document.getElementById('requests-grid');
const methods = ['GET', 'POST', 'PATCH', 'DELETE'];
const rows = Array.from({ length: 100000 }, (_, index) => ({
id: index + 1,
method: methods[index % 4],
path: `/api/items/${(index * 7919) % 100000}`,
status: [200, 201, 204, 404, 500][index % 5],
duration: (index * 37) % 1500,
}));
function render(start, end) {
grid.body.replaceChildren(...rows.slice(start, end).map((row) => {
const tr = document.createElement('tr');
tr.innerHTML = `<th scope="row">#${row.id}</th><td>${row.method}</td><td>${row.path}</td><td data-numeric>${row.status}</td><td data-numeric>${row.duration}</td>`;
return tr;
}));
}
grid.addEventListener('aihio-range', (event) => render(event.detail.start, event.detail.end));
grid.addEventListener('aihio-sort', (event) => {
const { column, direction } = event.detail;
const order = direction === 'ascending' ? 1 : -1;
rows.sort((a, b) => (a[column] > b[column] ? 1 : a[column] < b[column] ? -1 : 0) * order);
});
render(grid.start, grid.end);
</script>
Rows from a server
Fetch the rows asked for, and mark the grid loading while they come. A response for rows no longer in view is dropped: the person has scrolled on.
| Order | Customer | Total |
|---|
Show code · 44 lines
<aihio-data-grid id="orders-grid" row-count="25000">
<table aria-label="Orders">
<colgroup>
<col style="width: 8rem">
<col>
<col style="width: 9rem">
</colgroup>
<thead>
<tr>
<th scope="col">Order</th>
<th scope="col">Customer</th>
<th scope="col" data-numeric>Total</th>
</tr>
</thead>
<tbody></tbody>
</table>
</aihio-data-grid>
<script type="module">
const grid = document.getElementById('orders-grid');
// Stands in for fetch('/api/orders?start=...&end=...').
const fetchOrders = (start, end) => new Promise((resolve) => setTimeout(() => resolve(
Array.from({ length: end - start }, (_, offset) => ({
id: 70000 + start + offset,
customer: `Customer ${((start + offset) * 31) % 997}`,
total: (((start + offset) * 7349) % 100000) / 100,
}))
), 150));
async function load(start, end) {
grid.setAttribute('loading', '');
const orders = await fetchOrders(start, end);
if (start !== grid.start || end !== grid.end) return;
grid.body.replaceChildren(...orders.map((order) => {
const tr = document.createElement('tr');
tr.innerHTML = `<th scope="row">#${order.id}</th><td>${order.customer}</td><td data-numeric>€${order.total.toFixed(2)}</td>`;
return tr;
}));
grid.removeAttribute('loading');
}
grid.addEventListener('aihio-range', (event) => load(event.detail.start, event.detail.end));
load(grid.start, grid.end);
</script>
Mistakes
Each one beside its fix, with what aihio lint says about it. The prompt fragment gives agents the
same pairs, and the build fails if the linter stops catching one or a fix stops passing.
Without row-count the grid does not know how many rows there are, so it asks for none and its scroll box has no height to scroll.
Don't
<aihio-data-grid>
<table aria-label="Requests">
<thead>
<tr>
<th scope="col" data-sortable="id">Request</th>
<th scope="col" data-sortable="method">Method</th>
<th scope="col" data-sortable="path">Path</th>
<th scope="col" data-sortable="status" data-numeric>Status</th>
<th scope="col" data-sortable="duration" data-numeric>Duration (ms)</th>
</tr>
</thead>
<tbody></tbody>
</table>
</aihio-data-grid>
aihio lint reports
-
data-grid-row-counterror
Do
| Request | Method | Path | Status | Duration (ms) |
|---|
<aihio-data-grid row-count="100000">
<table aria-label="Requests">
<thead>
<tr>
<th scope="col" data-sortable="id">Request</th>
<th scope="col" data-sortable="method">Method</th>
<th scope="col" data-sortable="path">Path</th>
<th scope="col" data-sortable="status" data-numeric>Status</th>
<th scope="col" data-sortable="duration" data-numeric>Duration (ms)</th>
</tr>
</thead>
<tbody></tbody>
</table>
</aihio-data-grid>
An unnamed grid is announced only as "grid", which says nothing about what its hundred thousand rows are.
Don't
<aihio-data-grid row-count="100000">
<table>
<thead>
<tr>
<th scope="col" data-sortable="id">Request</th>
<th scope="col" data-sortable="method">Method</th>
<th scope="col" data-sortable="path">Path</th>
<th scope="col" data-sortable="status" data-numeric>Status</th>
<th scope="col" data-sortable="duration" data-numeric>Duration (ms)</th>
</tr>
</thead>
<tbody></tbody>
</table>
</aihio-data-grid>
aihio lint reports
-
table-accessible-nameerror
Do
| Request | Method | Path | Status | Duration (ms) |
|---|
<aihio-data-grid row-count="100000">
<table aria-label="Requests">
<thead>
<tr>
<th scope="col" data-sortable="id">Request</th>
<th scope="col" data-sortable="method">Method</th>
<th scope="col" data-sortable="path">Path</th>
<th scope="col" data-sortable="status" data-numeric>Status</th>
<th scope="col" data-sortable="duration" data-numeric>Duration (ms)</th>
</tr>
</thead>
<tbody></tbody>
</table>
</aihio-data-grid>
aihio-table renders every row it is given, so it has no row count.
A table of more rows than a page can hold is aihio-data-grid, or aihio-table with aihio-pagination.
Don't
<aihio-table row-count="100000">
<table aria-label="Requests">
<thead>
<tr>
<th scope="col" data-sortable="id">Request</th>
<th scope="col" data-sortable="method">Method</th>
<th scope="col" data-sortable="path">Path</th>
<th scope="col" data-sortable="status" data-numeric>Status</th>
<th scope="col" data-sortable="duration" data-numeric>Duration (ms)</th>
</tr>
</thead>
<tbody></tbody>
</table>
</aihio-table>
aihio lint reports
-
unknown-attributeerror
Do
| Request | Method | Path | Status | Duration (ms) |
|---|
<aihio-data-grid row-count="100000">
<table aria-label="Requests">
<thead>
<tr>
<th scope="col" data-sortable="id">Request</th>
<th scope="col" data-sortable="method">Method</th>
<th scope="col" data-sortable="path">Path</th>
<th scope="col" data-sortable="status" data-numeric>Status</th>
<th scope="col" data-sortable="duration" data-numeric>Duration (ms)</th>
</tr>
</thead>
<tbody></tbody>
</table>
</aihio-data-grid>
Without a column name aihio-sort reports the header's text, which changes when the page is translated.
Don't
<aihio-data-grid row-count="100000">
<table aria-label="Requests">
<thead>
<tr>
<th scope="col" data-sortable>Request</th>
<th scope="col" data-sortable>Duration (ms)</th>
</tr>
</thead>
<tbody></tbody>
</table>
</aihio-data-grid>
aihio lint reports
-
table-sort-column-namewarn
Do
| Request | Duration (ms) |
|---|
<aihio-data-grid row-count="100000">
<table aria-label="Requests">
<thead>
<tr>
<th scope="col" data-sortable="id">Request</th>
<th scope="col" data-sortable="duration">Duration (ms)</th>
</tr>
</thead>
<tbody></tbody>
</table>
</aihio-data-grid>
Firefox stops a box's height at 17.9 million pixels, about 389,000 rows of the default height.
The scroll ends there, before the rows do, and neither scrolling nor the keyboard reaches the rest.
Don't
<aihio-data-grid row-count="2000000">
<table aria-label="Requests">
<thead>
<tr>
<th scope="col" data-sortable="id">Request</th>
<th scope="col" data-sortable="method">Method</th>
<th scope="col" data-sortable="path">Path</th>
<th scope="col" data-sortable="status" data-numeric>Status</th>
<th scope="col" data-sortable="duration" data-numeric>Duration (ms)</th>
</tr>
</thead>
<tbody></tbody>
</table>
</aihio-data-grid>
aihio lint reports
-
data-grid-row-limitwarn
Do
| Request | Method | Path | Status | Duration (ms) |
|---|---|---|---|---|
| #1 | GET | /api/items/0 | 200 | 12 |
<aihio-table manual-sort>
<table aria-label="Requests">
<thead>
<tr>
<th scope="col" data-sortable="id" aria-sort="ascending">Request</th>
<th scope="col" data-sortable="method">Method</th>
<th scope="col" data-sortable="path">Path</th>
<th scope="col" data-sortable="status" data-numeric>Status</th>
<th scope="col" data-sortable="duration" data-numeric>Duration (ms)</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">#1</th><td>GET</td><td>/api/items/0</td><td data-numeric>200</td><td data-numeric>12</td></tr>
</tbody>
</table>
</aihio-table>
<aihio-pagination page="1" pages="80000" href="/requests?page={page}" aria-label="Request pages"></aihio-pagination>
API
Every attribute, property, method, and event the schema declares for aihio-data-grid.
Attributes
row-count- number
- How many rows there are in all. Setting it, as when a filter changes the rows, asks for the rows in view again.
density-
one of
defaultcompactdefaultdefault - Row height. compact fits more rows in the box.
loading-
boolean
default
false - Marks the rows as stale while new ones load: they are dimmed, and the table has aria-busy.
sort-ascending-text-
string
default
Sorted by {column}, ascending - Announced after sorting a column ascending, with {column} for the header's text. For localisation.
sort-descending-text-
string
default
Sorted by {column}, descending - Announced after sorting a column descending, with {column} for the header's text.
Properties
table-
HTMLTableElement | nullread-only - The
<table>the element enhances. body-
HTMLTableSectionElement | nullread-only - The
<tbody>your code renders the rows into. The grid keeps a section of its own above and below it, so table.tBodies[0] is not it. start-
numberread-only - The first row asked for, counting from 0.
end-
numberread-only - The row after the last one asked for.
rowCount-
number - Get or set row-count.
Methods
scrollToRow(index: number): void- Scrolls so the row at index, counting from 0, is at the top of the view, and asks for the rows there.
Events
aihio-range-
detail
{ start: number, end: number } - Asks for rows: render the rows from start up to (not including) end into body, in order, replacing those there. Fired when the grid connects, when its rows in view change, when row-count is set, and after a sort. Rows fetched for a range that is no longer start to end should be dropped.
aihio-sort-
detail
{ column: string, direction: 'ascending' | 'descending' } - Fired when the person sorts by a column, once its header carries the new aria-sort. Sort your rows by column; the grid then scrolls to the top and asks for the rows again with
aihio-range.
Native elements
aihio-data-grid enhances these elements rather than replacing them, and reads these attributes
on them. The linter checks them like its own.
<th>
A header cell. The grid uses its one header row for column headers, and keeps it in view.
Attributes
data-sortable- string
- Makes the column sortable: the header's content becomes a button, and a click fires
aihio-sortfor your code to sort the rows by this name. aria-sort-
one of
ascendingdescendingnoneother - The order your rows are in. The grid moves it from header to header as people sort.
data-numeric-
boolean
default
false - Lines a column of figures up by place value. Put it on the header and on each of the column's cells.
<td>
A data cell, one line high: text that does not fit ends in an ellipsis.
Attributes
data-numeric-
boolean
default
false - Lines the figure up with the rest of its column.
Composition
- Required children
-
table - Allowed children
-
table
Accessibility
What you have to provide, and what the component already does. The obligations with a rule are checked by
aihio lint and by the dev build's console warnings.
Your obligations
-
error When row-count is missing or not a whole numberSet row-count to how many rows there are in all. The grid asks for rows by number, and sizes its scroll box from the count.
Checked as
data-grid-row-count -
warn When row-count is above 350,000Narrow the rows with a filter, or show them a page at a time with
aihio-tableandaihio-pagination. Firefox stops a box's height at 17.9 million pixels, about 389,000 rows of the default height, and rows past it cannot be scrolled to.Checked as
data-grid-row-limit -
error When the table has no caption, aria-label, or aria-labelledbyName the table: a
<caption>, aria-labelledby pointing at the visible heading above it, or aria-label.Checked as
table-accessible-name -
error When the table has no<th>cellsName each column with a
<th>in the<thead>row.Checked as
table-header-cells -
warn When a sortable header has no column nameGive each sortable header a name,
data-sortable="duration", soaihio-sortsays which column to sort by without depending on the header's wording.Checked as
table-sort-column-name -
warn When people need to find text in the page, print every row, or read the rows in a screen reader's browse modeUse
aihio-tablewithaihio-paginationinstead. A grid has only the rows in view in the page, and those are all that find in page, printing, and browse mode reach.Not machine-checkable: a judgment for the author.
Handled for you
- The native
<table>becomes a grid with aria-rowcount for every row there is, and each rendered row carries aria-rowindex, so a screen reader says "row 51,034 of 100,001" while only the rows in view exist - The grid is one tab stop. Arrow keys move between cells, Page Up and Page Down by the rows in view, Home and End to the ends of a row, and Control+Home and Control+End to the first and last cell
- Moving to a row that is not rendered scrolls it into view, waits for your code to render it, and then focuses it
- A cell holding one link or button is focused on that control; a cell holding several is entered with Enter or F2 and left with Escape. Every other control in the grid is out of the tab order
- Keys pressed in a text field or an open menu inside a cell are left to it
- If the row holding focus scrolls away and your code removes it, focus moves to the header of the same column rather than to the page
- The header stays in view, and sorts the way
aihio-table's does: a button in the<th>, aria-sort on the<th>, and the new order announced in a polite status region - Columns take their widths from
<col>elements or the header cells, so they do not change as rows come and go, and each row is one line high - Cells draw their focus ring inside their edge, where the scroll box cannot clip it
- loading sets aria-busy on the table while the rows are stale