データグリッド
stimeo--data-grid
テーブルに列ソート・行選択・ロービングによるセル移動を付与する。
stimeo--data-grid コントローラは、WAI-ARIA の Grid パターンと aria-sort を実装する。グリッド全体が単一のタブストップ(ロービング tabindex:1 つのセルまたはヘッダだけが 0、他は -1)で、矢印キーが DOM フォーカスとタブ可能位置を同時に動かす。Home/End は行内、 Ctrl+Home / Ctrl+End はグリッド全体の先頭/末尾へ移動する。Enter/Space は列ヘッダのソートを循環(none → ascending → descending、sort イベントを発火)し、選択が有効ならフォーカス行の選択をトグルして aria-selected を同期し selectionchange を発火する。実データは操作せず、実際の並べ替え/描画は利用側が担う(このデモでは demo.js が sort イベントを受けて行を並べ替える)。タイマーや Observer は保持しないため Turbo 遷移で漏れるものはない。ライブラリは振る舞いのみを提供する。
| 名前 | 役割 | 拠点 |
|---|---|---|
| Ada Lovelace | エンジニア | ロンドン |
| Grace Hopper | 提督 | ニューヨーク |
| Alan Turing | 研究者 | マンチェスター |
| Katherine Johnson | 数学者 | ハンプトン |
キーボード操作
| キー | 動作 |
|---|---|
| → / ← | 同じ行の次/前のセルへ。 |
| ↓ / ↑ | 同じ列の次/前のセル(ヘッダ含む)へ。 |
| Home / End | 行内の先頭/末尾のセルへ。 |
| Ctrl+Home / Ctrl+End | グリッドの最初/最後のセルへ。 |
| Enter / Space | 列ヘッダのソート循環、またはフォーカス行の選択トグル。 |
<%# 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;
});
}
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--data-grid"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
columnHeader
必須
|
ソート可能な列ヘッダー。aria-sortがnone/ascending/descendingで循環する。 |
data-stimeo--data-grid-target="columnHeader" |
row
必須
|
グリッドの行。aria-selectedが選択状態を示す。 |
data-stimeo--data-grid-target="row" |
cell
必須
|
ロービングキーボード操作の対象となるセル。 | data-stimeo--data-grid-target="cell" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
selection
|
選択モード(none/single/multiple)。既定はnone。 |
data-stimeo--data-grid-selection-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
onKeydown
|
グリッド移動(矢印/Home/End/Ctrl)とEnter/Spaceによるソート・選択を処理する。 | stimeo--data-grid#onKeydown |
sort
|
操作したヘッダーのソートを循環させ、他をリセットしてsortを発火する。 | stimeo--data-grid#sort |
toggleSelect
|
イベント対象が属する行の選択を切り替える。 | stimeo--data-grid#toggleSelect |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
selectionchange
|
行の選択が変わると発火する。detailに選択行を含む。 | stimeo--data-grid:selectionchange |
sort
|
列のソートが変わると発火する。detailに列と方向を含む。 | stimeo--data-grid:sort |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
aria-sort |
列ヘッダ | "none" / "ascending" / "descending"。 |
aria-selected |
行 | 行の選択状態。 |
tabindex |
セル/ヘッダ | アクティブセルが 0、他は -1(ロービング)。 |