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"
data-stimeo--data-grid-name-value="user_id">
<%# The container the selected rows' data-value are generated into as hidden
inputs; both the container and the values are optional. A caption keeps the
container inside the table's content model. %>
<caption class="visually-hidden"><div data-stimeo--data-grid-target="fields"></div></caption>
<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>
<%# data-value is the record id the selection submits — a number, not copy, so
it is derived from the row order rather than localized. A row without one
submits nothing. %>
<% t("components.data_grid.demo.rows").each_with_index do |cells, row_index| %>
<tr role="row" aria-selected="false" data-value="<%= row_index + 1 %>"
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>
<%# Readout: what the component just reported. Purely visual — the component
already announces what a screen reader needs, so this line is aria-hidden to
keep it from being read twice. The value is a code fragment (API identifiers
and values), so only the label is translated. %>
<p class="demo-readout" aria-hidden="true">
<span><%= t("catalog.readout.field") %></span>
<span class="demo-readout__value" data-readout
data-empty-text="<%= t("catalog.readout.empty") %>">—</span>
</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 show the selected count
// (the consumer's job) from stimeo--data-grid:selectionchange, a selection the user
// made, and stimeo--data-grid:reconcile, a selection the page moved.
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 || '';
// Both events carry the selected rows, so one handler shows either.
const showCount = (event) => {
const count = event.detail.rows.length;
status.textContent = count ? template.replace('{count}', String(count)) : emptyText;
};
grid.addEventListener('stimeo--data-grid:selectionchange', showCount);
grid.addEventListener('stimeo--data-grid:reconcile', showCount);
}
});
// The hidden field mirrors the value, and for a move the user made it emits the
// native `change` a form already listens to — so `stimeo--auto-submit`, form
// validation, or any `change` handler on the `<form>` reacts without knowing what
// this component is. A refresh that follows a morph or a repair is silent, and
// the component reports a selection the page moved — a selected row removed, a
// morph or a script rewriting a row's `aria-selected` or a selected row's
// `data-value`, a runtime `selection` that collapses it — as
// `stimeo--data-grid:reconcile`. This demo reads both events, so the readout
// follows the user's moves and the page's.
// The page chrome uses some of these controllers too (the nav, the tab strip),
// so everything below is scoped to the demo itself.
const demoStage = document.querySelector(".demo-stage") ?? document;
const fieldReadout = demoStage.querySelector("[data-readout]");
const fieldSources = demoStage.querySelectorAll("[data-stimeo--data-grid-target='fields']");
// An input without a `name` is not a submitted control, so it is left out.
const submitted = () =>
Array.from(demoStage.querySelectorAll("[data-stimeo--data-grid-target='fields'] input"))
.filter((input) => input.name)
.map((input) => `${input.name}=${input.value}`)
.join(" & ") || fieldReadout?.dataset.emptyText || "";
const showSubmitted = () => {
if (fieldReadout) fieldReadout.textContent = submitted();
};
for (const source of fieldSources) source.addEventListener("change", showSubmitted);
demoStage.addEventListener("stimeo--data-grid:reconcile", showSubmitted);
showSubmitted();
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" |
fields
|
Optional container the selected rows' data-value are generated into as named hidden inputs. |
data-stimeo--data-grid-target="fields" |
Values
| Name | Description | Attribute |
|---|---|---|
selection
|
The selection mode (none, single, or multiple); defaults to none. |
data-stimeo--data-grid-selection-value |
name
|
The name the generated hidden inputs submit under. |
data-stimeo--data-grid-name-value |
form
|
The form attribute given to the generated hidden inputs; empty leaves it off. |
data-stimeo--data-grid-form-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 the user changes the row selection; detail carries the selected rows. detail.reason reports user / focus / pointer / api. |
stimeo--data-grid:selectionchange |
sort
|
Dispatched when a column's sort changes; detail carries the column and direction. detail.reason reports user / focus / pointer / api. |
stimeo--data-grid:sort |
reconcile
|
Dispatched when the page moves the selection instead: a selected row removed, a morph rewriting the rows, or a runtime selection mode that collapses it. { rows } without reason; not fired on connect. |
stimeo--data-grid:reconcile |
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). |