Data Grid
stimeo--data-grid
Adds column sorting, row selection and keyboard movement between cells to a table.
Makes a table workable from the keyboard, following the WAI-ARIA Grid pattern. Tab stops on the table once, and from there the arrow keys move between cells. Home and End go to the ends of a row, and with Ctrl to the start and end of the whole table. Enter or Space on a header cycles the sort through none, ascending and descending, and which way it is sorted is announced. With selection turned on, the focused row can be selected and deselected. Actually sorting and redrawing the rows stays with you: this reports what was asked for and leaves the data alone.
| Name | Role | Location |
|---|---|---|
| Ada Lovelace | Engineer | London |
| Grace Hopper | Admiral | New York |
| Alan Turing | Researcher | Manchester |
| Katherine Johnson | Mathematician | Hampton |
Keyboard
| Key | Action |
|---|---|
| → / ← | Move to the next / previous cell in the row. Under RTL the two swap. |
| ↓ / ↑ | Move to the next / previous cell in the column (headers included). |
| Home / End | Move to the first / last cell of the row. |
| Ctrl+Home / Ctrl+End | Move to the first / last cell of the grid. |
| Enter / Space | Cycle a header's sort, or toggle the focused row's selection. |
<%# Markup for the data-grid (basic data grid) demo.
The library cycles column headers' aria-sort and fires sort events, syncs rows'
aria-selected, and moves between cells with the arrow keys via roving. Actually
sorting/rendering the data is the consumer's job (here demo.js), in response to
stimeo--data-grid:sort. %>
<table
class="data-grid"
data-controller="stimeo--data-grid"
role="grid"
aria-label="<%= t("components.data_grid.demo.label") %>"
data-stimeo--data-grid-selection-value="single">
<thead>
<tr role="row">
<% t("components.data_grid.demo.columns").each do |column| %>
<th
role="columnheader"
aria-sort="none"
tabindex="-1"
scope="col"
data-stimeo--data-grid-target="columnHeader"
data-action="click->stimeo--data-grid#sort keydown->stimeo--data-grid#onKeydown">
<%= column %>
</th>
<% end %>
</tr>
</thead>
<tbody>
<% t("components.data_grid.demo.rows").each do |cells| %>
<tr role="row" aria-selected="false" data-stimeo--data-grid-target="row">
<% cells.each_with_index do |cell, i| %>
<td
role="gridcell"
tabindex="<%= i.zero? ? 0 : -1 %>"
data-stimeo--data-grid-target="cell"
data-action="keydown->stimeo--data-grid#onKeydown">
<%= cell %>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
<p
class="data-grid__status"
data-data-grid-status
data-selected-template="<%= t("components.data_grid.demo.selected_template") %>"
data-empty-text="<%= t("components.data_grid.demo.selected_empty") %>"
aria-live="polite"></p>
/*
* Presentation-only styles for the data-grid demo.
* The library toggles column headers' aria-sort, rows' aria-selected, and cells'
* tabindex (roving). The sort-direction arrows and selected-row highlight are built
* by reacting to those attributes.
*/
.data-grid {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.data-grid th,
.data-grid td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border-default);
text-align: left;
}
.data-grid th {
cursor: pointer;
font-weight: 600;
color: var(--fg, var(--color-text));
user-select: none;
}
/* Show the sort direction with an arrow (reacts to aria-sort). The arrow uses the
theme-aware accent *text* token: the raw brand accent measures ~2.9:1 on the light
canvas, under the 4.5:1 floor. It resolves back to the brand value in dark. Note that
axe cannot see ::after text at all, so this pair needs checking by hand. */
.data-grid th[aria-sort="ascending"]::after {
content: " ▲";
color: var(--accent-700);
}
.data-grid th[aria-sort="descending"]::after {
content: " ▼";
color: var(--accent-700);
}
/* A theme-aware soft accent surface, not the raw `--vital-100`: this rule sets a
background but lets the text inherit `--color-text`, which flips with the theme.
A fixed light mint therefore paired light text on light in dark mode — measured
1.02:1, i.e. the row the keyboard is on was effectively invisible. */
.data-grid tr[aria-selected="true"] {
background: var(--color-primary-soft);
}
.data-grid td:focus-visible,
.data-grid th:focus-visible {
outline: 2px solid var(--accent, var(--color-primary));
outline-offset: -2px;
}
.data-grid__status {
margin: 0.6rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
// Demo that subscribes to data-grid events (consumer-side JS).
//
// The core controller (stimeo--data-grid) cycles aria-sort, syncs rows' aria-selected,
// and roves between cells, but does not sort the actual data. Here we respond to
// stimeo--data-grid:sort by reordering the <tbody> rows, and to
// stimeo--data-grid:selectionchange by showing the selected count (the consumer's job).
document.querySelectorAll('[data-controller~="stimeo--data-grid"]').forEach((grid) => {
const tbody = grid.querySelector('tbody');
const status = grid.parentElement?.querySelector('[data-data-grid-status]');
if (!tbody) return;
// Remember the original row order so we can restore it when sort returns to none.
const originalOrder = Array.from(tbody.rows);
const headers = Array.from(grid.querySelectorAll('[role="columnheader"]'));
grid.addEventListener('stimeo--data-grid:sort', (event) => {
const { column, direction } = event.detail;
const columnIndex = headers.indexOf(column);
if (direction === 'none' || columnIndex < 0) {
originalOrder.forEach((row) => tbody.appendChild(row));
return;
}
const sorted = Array.from(tbody.rows).sort((a, b) => {
const left = a.cells[columnIndex]?.textContent?.trim() ?? '';
const right = b.cells[columnIndex]?.textContent?.trim() ?? '';
const order = left.localeCompare(right, undefined, { numeric: true });
return direction === 'ascending' ? order : -order;
});
sorted.forEach((row) => tbody.appendChild(row));
});
// For the bilingual catalog the copy isn't hardcoded: it uses the localized template
// the ERB passes (the "{count}" token in data-selected-template) and the empty text.
if (status) {
const template = status.dataset.selectedTemplate || '{count}';
const emptyText = status.dataset.emptyText || '';
grid.addEventListener('stimeo--data-grid:selectionchange', (event) => {
const count = event.detail.rows.length;
status.textContent = count ? template.replace('{count}', String(count)) : emptyText;
});
}
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--data-grid"
Targets
| Name | Description | Attribute |
|---|---|---|
columnHeader
required
|
A sortable column header; its aria-sort cycles none/ascending/descending. |
data-stimeo--data-grid-target="columnHeader" |
row
required
|
A grid row whose aria-selected reflects its selection state. |
data-stimeo--data-grid-target="row" |
cell
required
|
A navigable grid cell participating in roving keyboard navigation. | data-stimeo--data-grid-target="cell" |
Values
| Name | Description | Attribute |
|---|---|---|
selection
|
The selection mode (none, single, or multiple); defaults to none. |
data-stimeo--data-grid-selection-value |
Actions
| Name | Description | Action |
|---|---|---|
onKeydown
|
Handles grid navigation (arrows/Home/End/Ctrl) and Enter/Space sort or select activation. | stimeo--data-grid#onKeydown |
sort
|
Cycles the activated header's sort, resets others, and emits sort. | stimeo--data-grid#sort |
toggleSelect
|
Toggles selection of the row owning the event target. | stimeo--data-grid#toggleSelect |
Events
| Name | Description | Event |
|---|---|---|
selectionchange
|
Dispatched when row selection changes; detail carries the selected rows. | stimeo--data-grid:selectionchange |
sort
|
Dispatched when a column's sort changes; detail carries the column and direction. | stimeo--data-grid:sort |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-sort |
Column header | "none" / "ascending" / "descending". |
aria-selected |
Row | "true" on selected rows, "false" on the rest. Single selection keeps at most one "true". With selection set to none, no row carries the attribute at all. |
tabindex |
Cell / Header | 0 on the active cell, -1 on the rest (roving). |