Grid
キーボードナビゲーション、セル選択、アクティベーションを備えたインタラクティブな2Dデータグリッド。
デモ
矢印キーでセル間を移動。Spaceでセルを選択/選択解除。Enterでセルをアクティベート。
アクセシビリティ
ネイティブHTMLとGridロール
grid
ロールは、2Dキーボードナビゲーションを備えたインタラクティブなデータグリッドを作成します。静的なデータテーブルには、代わりにネイティブの
<table>要素を使用してください。grid
は以下の場合に使用します:
- セルがフォーカス可能でインタラクティブ(編集可能、選択可能、またはウィジェットを含む)
- インタラクティブ性のない静的データ表示
- スプレッドシートやデータグリッドに類似したインターフェース
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
grid | コンテナ | グリッドコンテナ(複合ウィジェット) |
row | 行コンテナ | セルを水平方向にグループ化 |
columnheader | ヘッダーセル | 列ヘッダー(この実装ではフォーカス不可) |
rowheader | 行ヘッダーセル | 行ヘッダー(オプション) |
gridcell | データセル | インタラクティブセル(フォーカス可能) |
WAI-ARIA grid role (opens in new tab)
WAI-ARIA プロパティ(グリッドコンテナ)
| 属性 | 値 | 必須 | 説明 |
|---|---|---|---|
role="grid" | - | はい | コンテナをグリッドとして識別 |
aria-label | String | はい* | グリッドのアクセシブルな名前 |
aria-labelledby | ID reference | はい* | aria-labelの代替 |
aria-multiselectable | true | いいえ | 複数選択モード時のみ存在 |
aria-rowcount | 数値 | いいえ | 総行数(仮想化用) |
aria-colcount | 数値 | いいえ | 総列数(仮想化用) |
* グリッドコンテナには aria-label または aria-labelledby のいずれかが必須です。
WAI-ARIA ステート(グリッドセル)
| 属性 | 値 | 必須 | 説明 |
|---|---|---|---|
tabindex | 0 | -1 | はい | フォーカス管理用のroving tabindex |
aria-selected | true | false | いいえ* | グリッドが選択をサポートする場合に存在。選択をサポートする場合、すべてのgridcellにaria-selectedが必要。 |
aria-disabled | true | いいえ* | セルが無効であることを示す |
aria-rowindex | 数値 | いいえ* | 行位置(仮想化用) |
aria-colindex | 数値 | いいえ* | 列位置(仮想化用) |
* 選択をサポートする場合、すべてのgridcellにaria-selectedが必要です。
キーボードサポート
2Dナビゲーション
| キー | アクション |
|---|---|
| → | フォーカスを右のセルに移動 |
| ← | フォーカスを左のセルに移動 |
| ↓ | フォーカスを下の行に移動 |
| ↑ | フォーカスを上の行に移動 |
| Home | フォーカスを行の最初のセルに移動 |
| End | フォーカスを行の最後のセルに移動 |
| Ctrl + Home | フォーカスをグリッドの最初のセルに移動 |
| Ctrl + End | フォーカスをグリッドの最後のセルに移動 |
| PageDown | フォーカスをページサイズ分下に移動(デフォルト5) |
| PageUp | フォーカスをページサイズ分上に移動(デフォルト5) |
選択とアクティベーション
| キー | アクション |
|---|---|
| Space | フォーカス中のセルを選択/選択解除(選択可能時) |
| Enter | フォーカス中のセルをアクティベート(onCellActivateをトリガー) |
フォーカス管理
このコンポーネントはフォーカス管理にroving tabindexを使用します:
- 1つのセルのみがtabindex="0"(フォーカス中のセル)を持ち、他のすべてのセルはtabindex="-1"を持つ
- グリッドは単一のTabストップ(Tabでグリッドに入り、Shift+Tabで離脱)
- ヘッダーセル(columnheader)はフォーカス不可(この実装ではソート機能なし)
- データ行のgridcellのみがキーボードナビゲーションに含まれる
- グリッドを離れて再入場した際、最後にフォーカスされたセルが記憶される
無効化セル
-
aria-disabled="true"を持つ - フォーカス可能(キーボードナビゲーションに含まれる)
- 選択またはアクティベートできない
- 視覚的に区別される(例:グレーアウト)
ソースコード
Grid.astro
---
// =============================================================================
// Types
// =============================================================================
export interface GridCellData {
id: string;
value: string | number;
disabled?: boolean;
colspan?: number;
rowspan?: number;
}
export interface GridColumnDef {
id: string;
header: string;
colspan?: number;
}
export interface GridRowData {
id: string;
cells: GridCellData[];
hasRowHeader?: boolean;
disabled?: boolean;
}
interface Props {
columns: GridColumnDef[];
rows: GridRowData[];
ariaLabel?: string;
ariaLabelledby?: string;
selectable?: boolean;
multiselectable?: boolean;
defaultSelectedIds?: string[];
defaultFocusedId?: string;
totalColumns?: number;
totalRows?: number;
startRowIndex?: number;
startColIndex?: number;
wrapNavigation?: boolean;
enablePageNavigation?: boolean;
pageSize?: number;
class?: string;
renderCell?: (cell: GridCellData, rowId: string, colId: string) => string;
}
// =============================================================================
// Props
// =============================================================================
const {
columns,
rows,
ariaLabel,
ariaLabelledby,
selectable = false,
multiselectable = false,
defaultSelectedIds = [],
defaultFocusedId,
totalColumns,
totalRows,
startRowIndex = 1,
startColIndex = 1,
wrapNavigation = false,
enablePageNavigation = false,
pageSize = 5,
class: className,
renderCell,
} = Astro.props;
// Determine initial focused cell
const initialFocusedId = defaultFocusedId ?? rows[0]?.cells[0]?.id ?? null;
---
<apg-grid
class={`apg-grid ${className ?? ''}`}
data-wrap-navigation={wrapNavigation}
data-enable-page-navigation={enablePageNavigation}
data-page-size={pageSize}
data-selectable={selectable}
data-multiselectable={multiselectable}
>
<div
role="grid"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-multiselectable={multiselectable ? 'true' : undefined}
aria-rowcount={totalRows}
aria-colcount={totalColumns}
>
{/* Header Row */}
<div role="row" aria-rowindex={totalRows ? 1 : undefined}>
{
columns.map((col, colIndex) => (
<div
role="columnheader"
aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
aria-colspan={col.colspan}
data-col-id={col.id}
>
{col.header}
</div>
))
}
</div>
{/* Data Rows */}
{
rows.map((row, rowIndex) => (
<div
role="row"
aria-rowindex={totalRows ? startRowIndex + rowIndex : undefined}
data-row-id={row.id}
>
{row.cells.map((cell, colIndex) => {
const isRowHeader = row.hasRowHeader && colIndex === 0;
const isFocused = cell.id === initialFocusedId;
const isSelected = defaultSelectedIds.includes(cell.id);
const colId = columns[colIndex]?.id ?? '';
return (
<div
role={isRowHeader ? 'rowheader' : 'gridcell'}
tabindex={isFocused ? 0 : -1}
aria-selected={selectable ? (isSelected ? 'true' : 'false') : undefined}
aria-disabled={cell.disabled ? 'true' : undefined}
aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
aria-colspan={cell.colspan}
aria-rowspan={cell.rowspan}
data-cell-id={cell.id}
data-row-id={row.id}
data-col-id={colId}
data-row-index={rowIndex}
data-col-index={colIndex}
data-disabled={cell.disabled ? 'true' : undefined}
class={`apg-grid-cell ${isFocused ? 'focused' : ''} ${isSelected ? 'selected' : ''} ${cell.disabled ? 'disabled' : ''}`}
>
{renderCell ? <Fragment set:html={renderCell(cell, row.id, colId)} /> : cell.value}
</div>
);
})}
</div>
))
}
</div>
</apg-grid>
<script>
class ApgGrid extends HTMLElement {
private focusedId: string | null = null;
private selectedIds: Set<string> = new Set();
private wrapNavigation = false;
private enablePageNavigation = false;
private pageSize = 5;
private selectable = false;
private multiselectable = false;
connectedCallback() {
this.wrapNavigation = this.dataset.wrapNavigation === 'true';
this.enablePageNavigation = this.dataset.enablePageNavigation === 'true';
this.pageSize = parseInt(this.dataset.pageSize || '5', 10);
this.selectable = this.dataset.selectable === 'true';
this.multiselectable = this.dataset.multiselectable === 'true';
// Find initial focused cell
const focusedCell = this.querySelector<HTMLElement>('[tabindex="0"]');
this.focusedId = focusedCell?.dataset.cellId ?? null;
// Load initial selected ids
this.querySelectorAll<HTMLElement>('[aria-selected="true"]').forEach((el) => {
const cellId = el.dataset.cellId;
if (cellId) this.selectedIds.add(cellId);
});
// Set tabindex="-1" on all focusable elements inside grid cells
// This ensures Tab exits the grid instead of moving between widgets
this.querySelectorAll<HTMLElement>(
'[role="gridcell"] a[href], [role="gridcell"] button, [role="rowheader"] a[href], [role="rowheader"] button'
).forEach((el) => {
el.setAttribute('tabindex', '-1');
});
// Add event listeners to all cells
// Use focusin instead of focus because focus doesn't bubble
// This ensures we catch focus on widgets inside cells
this.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]').forEach(
(cell) => {
cell.addEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
cell.addEventListener('focusin', this.handleFocus.bind(this) as EventListener);
}
);
}
disconnectedCallback() {
this.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]').forEach(
(cell) => {
cell.removeEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
cell.removeEventListener('focusin', this.handleFocus.bind(this) as EventListener);
}
);
}
private getCells(): HTMLElement[] {
return Array.from(
this.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]')
);
}
private getRows(): HTMLElement[] {
return Array.from(this.querySelectorAll<HTMLElement>('[role="row"]')).slice(1); // Skip header row
}
private getColumnCount(): number {
return this.querySelectorAll('[role="columnheader"]').length;
}
private getCellAt(rowIndex: number, colIndex: number): HTMLElement | null {
const rows = this.getRows();
const row = rows[rowIndex];
if (!row) return null;
const cells = row.querySelectorAll('[role="gridcell"], [role="rowheader"]');
return cells[colIndex] as HTMLElement | null;
}
private focusCell(cell: HTMLElement) {
const currentFocused = this.querySelector('[tabindex="0"]');
if (currentFocused) {
currentFocused.setAttribute('tabindex', '-1');
currentFocused.classList.remove('focused');
}
cell.setAttribute('tabindex', '0');
cell.classList.add('focused');
// Check if cell contains a focusable element (link, button, etc.)
// Per APG: when cell contains a single widget, focus should be on the widget
const focusableChild = cell.querySelector<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusableChild) {
// Set tabindex="-1" so Tab skips this element and exits the grid
// The widget can still receive programmatic focus
focusableChild.setAttribute('tabindex', '-1');
focusableChild.focus();
} else {
cell.focus();
}
this.focusedId = cell.dataset.cellId ?? null;
}
private handleFocus(event: Event) {
// Use currentTarget (the cell) instead of target (which could be a link inside the cell)
const cell = event.currentTarget as HTMLElement;
const currentFocused = this.querySelector('[tabindex="0"]');
if (currentFocused && currentFocused !== cell) {
currentFocused.setAttribute('tabindex', '-1');
currentFocused.classList.remove('focused');
}
cell.setAttribute('tabindex', '0');
cell.classList.add('focused');
this.focusedId = cell.dataset.cellId ?? null;
}
private findNextCell(
rowIndex: number,
colIndex: number,
direction: 'right' | 'left' | 'up' | 'down'
): HTMLElement | null {
const colCount = this.getColumnCount();
const rowCount = this.getRows().length;
let newRow = rowIndex;
let newCol = colIndex;
switch (direction) {
case 'right':
newCol++;
if (newCol >= colCount) {
if (this.wrapNavigation) {
newCol = 0;
newRow++;
} else {
return null;
}
}
break;
case 'left':
newCol--;
if (newCol < 0) {
if (this.wrapNavigation) {
newCol = colCount - 1;
newRow--;
} else {
return null;
}
}
break;
case 'down':
newRow++;
break;
case 'up':
newRow--;
break;
}
if (newRow < 0 || newRow >= rowCount) return null;
const cell = this.getCellAt(newRow, newCol);
if (!cell) return null;
// Skip disabled cells
if (cell.dataset.disabled === 'true') {
return this.findNextCell(newRow, newCol, direction);
}
return cell;
}
private toggleSelection(cell: HTMLElement) {
if (!this.selectable) return;
if (cell.dataset.disabled === 'true') return;
const cellId = cell.dataset.cellId;
if (!cellId) return;
if (this.multiselectable) {
if (this.selectedIds.has(cellId)) {
this.selectedIds.delete(cellId);
cell.setAttribute('aria-selected', 'false');
} else {
this.selectedIds.add(cellId);
cell.setAttribute('aria-selected', 'true');
}
} else {
// Clear previous selection
this.querySelectorAll('[aria-selected="true"]').forEach((el) => {
el.setAttribute('aria-selected', 'false');
});
this.selectedIds.clear();
if (!this.selectedIds.has(cellId)) {
this.selectedIds.add(cellId);
cell.setAttribute('aria-selected', 'true');
}
}
this.dispatchEvent(
new CustomEvent('selection-change', {
detail: { selectedIds: Array.from(this.selectedIds) },
})
);
}
private selectAll() {
if (!this.selectable || !this.multiselectable) return;
this.getCells().forEach((cell) => {
if (cell.dataset.disabled !== 'true') {
const cellId = cell.dataset.cellId;
if (cellId) {
this.selectedIds.add(cellId);
cell.setAttribute('aria-selected', 'true');
}
}
});
this.dispatchEvent(
new CustomEvent('selection-change', {
detail: { selectedIds: Array.from(this.selectedIds) },
})
);
}
private handleKeyDown(event: KeyboardEvent) {
// Use currentTarget (the cell) instead of target (which could be a link inside the cell)
const cell = event.currentTarget as HTMLElement;
const { key, ctrlKey } = event;
const {
rowIndex: rowIndexStr,
colIndex: colIndexStr,
disabled,
cellId,
rowId,
colId,
} = cell.dataset;
const rowIndex = parseInt(rowIndexStr || '0', 10);
const colIndex = parseInt(colIndexStr || '0', 10);
let handled = true;
switch (key) {
case 'ArrowRight': {
const next = this.findNextCell(rowIndex, colIndex, 'right');
if (next) this.focusCell(next);
break;
}
case 'ArrowLeft': {
const next = this.findNextCell(rowIndex, colIndex, 'left');
if (next) this.focusCell(next);
break;
}
case 'ArrowDown': {
const next = this.findNextCell(rowIndex, colIndex, 'down');
if (next) this.focusCell(next);
break;
}
case 'ArrowUp': {
const next = this.findNextCell(rowIndex, colIndex, 'up');
if (next) this.focusCell(next);
break;
}
case 'Home': {
if (ctrlKey) {
const firstCell = this.getCellAt(0, 0);
if (firstCell) this.focusCell(firstCell);
} else {
const firstInRow = this.getCellAt(rowIndex, 0);
if (firstInRow) this.focusCell(firstInRow);
}
break;
}
case 'End': {
const colCount = this.getColumnCount();
if (ctrlKey) {
const rowCount = this.getRows().length;
const lastCell = this.getCellAt(rowCount - 1, colCount - 1);
if (lastCell) this.focusCell(lastCell);
} else {
const lastInRow = this.getCellAt(rowIndex, colCount - 1);
if (lastInRow) this.focusCell(lastInRow);
}
break;
}
case 'PageDown': {
if (this.enablePageNavigation) {
const rowCount = this.getRows().length;
const targetRow = Math.min(rowIndex + this.pageSize, rowCount - 1);
const targetCell = this.getCellAt(targetRow, colIndex);
if (targetCell) this.focusCell(targetCell);
} else {
handled = false;
}
break;
}
case 'PageUp': {
if (this.enablePageNavigation) {
const targetRow = Math.max(rowIndex - this.pageSize, 0);
const targetCell = this.getCellAt(targetRow, colIndex);
if (targetCell) this.focusCell(targetCell);
} else {
handled = false;
}
break;
}
case ' ': {
this.toggleSelection(cell);
break;
}
case 'Enter': {
if (disabled !== 'true') {
this.dispatchEvent(
new CustomEvent('cell-activate', {
detail: { cellId, rowId, colId },
})
);
}
break;
}
case 'a': {
if (ctrlKey) {
this.selectAll();
} else {
handled = false;
}
break;
}
default:
handled = false;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}
}
customElements.define('apg-grid', ApgGrid);
</script> 使い方
使用例
---
import Grid, { type GridColumnDef, type GridRowData } from '@patterns/grid/Grid.astro';
const columns: GridColumnDef[] = [
{ id: 'name', header: '名前' },
{ id: 'email', header: 'メール' },
{ id: 'role', header: '役割' },
];
const rows: GridRowData[] = [
{
id: 'user1',
cells: [
{ id: 'user1-0', value: '田中太郎' },
{ id: 'user1-1', value: 'tanaka@example.com' },
{ id: 'user1-2', value: '管理者' },
],
},
];
---
<Grid
columns={columns}
rows={rows}
ariaLabel="ユーザー一覧"
selectable
multiselectable
/> API
Grid Props
| Prop | 型 | デフォルト | 説明 |
|---|---|---|---|
columns | GridColumnDef[] | 必須 | 列定義 |
rows | GridRowData[] | 必須 | 行データ |
ariaLabel | string | - | グリッドのアクセシブルな名前 |
selectable | boolean | false | セル選択を有効化 |
multiselectable | boolean | false | 複数セル選択を有効化 |
テスト
テストはキーボード操作、ARIA属性、アクセシビリティ要件にわたるAPG準拠を検証します。Gridコンポーネントは2層のテスト戦略を採用しています。
テスト戦略
ユニットテスト (Testing Library)
フレームワーク固有のTesting Libraryユーティリティを使用して、コンポーネントのレンダリングとインタラクションを検証します。これらのテストはコンポーネントの動作を分離して確認します。
- HTML構造と要素階層(grid, row, gridcell)
- 初期属性値(role, aria-label, tabindex)
- 選択状態の変更(aria-selected)
- CSSクラスの適用
E2Eテスト (Playwright)
4つのすべてのフレームワークで、実際のブラウザ環境でコンポーネントの動作を検証します。これらのテストはフルブラウザコンテキストを必要とするインタラクションをカバーします。
- 2Dキーボードナビゲーション(矢印キー)
- 拡張ナビゲーション(Home, End, Ctrl+Home, Ctrl+End)
- ページナビゲーション(PageUp, PageDown)
- セルの選択とアクティベーション
- フォーカス管理とroving tabindex
- クロスフレームワークの一貫性
テストカテゴリー
高優先度 : APG ARIA属性
| テスト | 説明 |
|---|---|
role="grid" | コンテナがgridロールを持つ |
role="row" | すべての行がrowロールを持つ |
role="gridcell" | データセルがgridcellロールを持つ |
role="columnheader" | ヘッダーセルがcolumnheaderロールを持つ |
role="rowheader" | 行ヘッダーセルがrowheaderロールを持つ(該当時) |
aria-label | グリッドがaria-labelでアクセシブルな名前を持つ |
aria-labelledby | グリッドがaria-labelledbyでアクセシブルな名前を持つ |
aria-multiselectable | 複数選択が有効な場合に存在 |
aria-selected | 選択が有効な場合、すべてのセルに存在 |
aria-disabled | 無効なセルに存在 |
高優先度 : 2Dキーボードナビゲーション
| テスト | 説明 |
|---|---|
ArrowRight | フォーカスを右に1セル移動 |
ArrowLeft | フォーカスを左に1セル移動 |
ArrowDown | フォーカスを下に1行移動 |
ArrowUp | フォーカスを上に1行移動 |
ArrowUp at first row | 最初のデータ行で停止(ヘッダーには移動しない) |
ArrowRight at row end | 行末で停止(デフォルト)または折り返し(wrapNavigation) |
高優先度 : 拡張ナビゲーション
| テスト | 説明 |
|---|---|
Home | フォーカスを行の最初のセルに移動 |
End | フォーカスを行の最後のセルに移動 |
Ctrl+Home | フォーカスをグリッドの最初のセルに移動 |
Ctrl+End | フォーカスをグリッドの最後のセルに移動 |
PageDown | フォーカスをページサイズ分下に移動 |
PageUp | フォーカスをページサイズ分上に移動 |
高優先度 : フォーカス管理 (Roving Tabindex)
| テスト | 説明 |
|---|---|
tabindex="0" | 最初のフォーカス可能なセルがtabindex="0"を持つ |
tabindex="-1" | 他のセルがtabindex="-1"を持つ |
Headers not focusable | columnheaderセルにtabindexがない(フォーカス不可) |
Tab exits grid | Tabでフォーカスがグリッド外に移動 |
Focus update | ナビゲーション時にフォーカスセルのtabindexが更新される |
Disabled cells | 無効セルはフォーカス可能だがアクティベート不可 |
高優先度 : 選択
| テスト | 説明 |
|---|---|
Space toggles | Spaceでセル選択をトグル(選択可能時) |
Single select | 単一選択モードではSpaceで前の選択をクリア |
Multi select | 複数選択モードでは複数セルを選択可能 |
Enter activates | Enterでセルをアクティベート |
Disabled no select | Spaceで無効セルは選択できない |
Disabled no activate | Enterで無効セルはアクティベートできない |
中優先度 : 仮想化サポート
| テスト | 説明 |
|---|---|
aria-rowcount | totalRows指定時に存在 |
aria-colcount | totalColumns指定時に存在 |
aria-rowindex | 仮想化時に行/セルに存在 |
aria-colindex | 仮想化時にセルに存在 |
中優先度 : アクセシビリティ
| テスト | 説明 |
|---|---|
axe-core | アクセシビリティ違反がないこと |
テストツール
- Vitest (opens in new tab) - ユニットテスト用テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ(React, Vue, Svelte)
- Playwright (opens in new tab) - E2Eテスト用ブラウザ自動化
- axe-core/playwright (opens in new tab) - E2Eでの自動アクセシビリティテスト
詳細は testing-strategy.md (opens in new tab) を参照してください。
リソース
- WAI-ARIA APG: Grid パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist