Data Grid
stimeo--data-grid
Adds column sorting, row selection, and roving cell navigation to a table.
The stimeo--data-grid controller implements the WAI-ARIA Grid pattern plus aria-sort. The whole grid is a single Tab stop (roving tabindex: exactly one cell or header is 0, the rest -1) and the arrow keys move both DOM focus and that tabbable position; Home/End move within a row and Ctrl+Home/Ctrl+End across the grid. Enter/Space cycles a header's sort (none → ascending → descending, emitting the sort event) or toggles the focused row's selection when selection is enabled, syncing aria-selected and emitting selectionchange. It does not touch the data — the consumer performs the real sort/render (here demo.js reorders the rows on the sort event). No timers or observers are held, so there is nothing to leak across Turbo navigations. Behavior only — the look is yours.
| 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. |
| ↓ / ↑ | 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). */
.data-grid th[aria-sort="ascending"]::after {
content: " ▲";
color: var(--accent, var(--color-primary));
}
.data-grid th[aria-sort="descending"]::after {
content: " ▼";
color: var(--accent, var(--color-primary));
}
.data-grid tr[aria-selected="true"] {
background: var(--vital-100);
}
.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 | The row's selection state. |
tabindex |
Cell / Header | 0 on the active cell, -1 on the rest (roving). |