Grid
キーボードナビゲーション、セル選択、アクティベーションを備えたインタラクティブな2Dデータグリッド。
デモ
矢印キーで移動します。スペースキーでセルを選択します。Enterキーでアクティブにします。
Name
Email
Role
Status
Grid vs Table
インタラクティブなデータグリッドにはgridロールを、静的なデータ表示には
tableロールを使用します。
| 機能 | Grid | Table |
|---|---|---|
| キーボードナビゲーション | 2D(矢印キー) | テーブルナビゲーション(ブラウザデフォルト) |
| セルフォーカス | 必須(roving tabindex) | 不要 |
| 選択 | aria-selected | 未対応 |
| 編集 | オプション | 未対応 |
| ユースケース | スプレッドシート風、データグリッド | 静的データ表示 |
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
grid | コンテナ | グリッドコンテナ(複合ウィジェット) |
row | 行コンテナ | セルを水平方向にグループ化 |
columnheader | ヘッダーセル | 列ヘッダー(この実装ではフォーカス不可) |
rowheader | 行ヘッダーセル | 行ヘッダー(オプション) |
gridcell | データセル | インタラクティブセル(フォーカス可能) |
WAI-ARIA プロパティ
role="grid"
コンテナをグリッドとして識別
- 値
- -
- 必須
- はい
aria-label
グリッドのアクセシブルな名前
- 値
- String
- 必須
はい*(aria-label または aria-labelledby のいずれか)
aria-labelledby
aria-labelの代替
- 値
- ID reference
- 必須
はい*(aria-label または aria-labelledby のいずれか)
aria-multiselectable
複数選択モード時のみ存在
- 値
- true
- 必須
- いいえ
aria-rowcount
総行数(仮想化用)
- 値
- 数値
- 必須
- いいえ
aria-colcount
総列数(仮想化用)
- 値
- 数値
- 必須
- いいえ
WAI-ARIA ステート
tabindex
- 対象要素
- gridcell
- 値
- 0 | -1
- 必須
- はい
- 変更トリガー
- フォーカス管理用のroving tabindex
aria-selected
- 対象要素
- gridcell
- 値
- true | false
- 必須
- いいえ
- 変更トリガー
グリッドが選択をサポートする場合に存在。選択をサポートする場合、すべてのgridcellにaria-selectedが必要。
aria-disabled
- 対象要素
- gridcell
- 値
- true
- 必須
- いいえ
- 変更トリガー
- セルが無効であることを示す
aria-rowindex
- 対象要素
- row, gridcell
- 値
- 数値
- 必須
- いいえ
- 変更トリガー
- 行位置(仮想化用)
aria-colindex
- 対象要素
- gridcell
- 値
- 数値
- 必須
- いいえ
- 変更トリガー
- 列位置(仮想化用)
キーボードサポート
2Dナビゲーション
| キー | アクション |
|---|---|
| → | フォーカスを右のセルに移動 |
| ← | フォーカスを左のセルに移動 |
| ↓ | フォーカスを下の行に移動 |
| ↑ | フォーカスを上の行に移動 |
| Home | フォーカスを行の最初のセルに移動 |
| End | フォーカスを行の最後のセルに移動 |
| Ctrl + Home | フォーカスをグリッドの最初のセルに移動 |
| Ctrl + End | フォーカスをグリッドの最後のセルに移動 |
| PageDown | フォーカスをページサイズ分下に移動(デフォルト5) |
| PageUp | フォーカスをページサイズ分上に移動(デフォルト5) |
選択とアクティベーション
| キー | アクション |
|---|---|
| Space | フォーカス中のセルを選択/選択解除(選択可能時) |
| Enter | フォーカス中のセルをアクティベート(onCellActivateをトリガー) |
- グリッドコンテナには aria-label または aria-labelledby のいずれかが必須です。
- 無効化セルはaria-disabled=“true”を持ち、フォーカス可能(キーボードナビゲーションに含まれる)ですが、選択またはアクティベートできず、視覚的に区別されます(例:グレーアウト)。
フォーカス管理
| イベント | 振る舞い |
|---|---|
| Roving tabindex | 1つのセルのみがtabindex="0"(フォーカス中のセル)を持ち、他のすべてのセルはtabindex="-1"を持つ |
| 単一Tabストップ | グリッドは単一のTabストップ(Tabでグリッドに入り、Shift+Tabで離脱) |
| ヘッダーセル | ヘッダーセル(columnheader)はフォーカス不可(この実装ではソート機能なし) |
| データセルのみ | データ行のgridcellのみがキーボードナビゲーションに含まれる |
| フォーカスメモリ | グリッドを離れて再入場した際、最後にフォーカスされたセルが記憶される |
参考資料
ソースコード
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> 使い方
Example
---
import Grid from '@patterns/grid/Grid.astro';
const columns = [
{ id: 'name', header: 'Name' },
{ id: 'email', header: 'Email' },
{ id: 'role', header: 'Role' },
];
const rows = [
{
id: 'user1',
cells: [
{ id: 'user1-0', value: 'Alice Johnson' },
{ id: 'user1-1', value: 'alice@example.com' },
{ id: 'user1-2', value: 'Admin' },
],
},
];
---
<!-- Basic Grid -->
<Grid
columns={columns}
rows={rows}
ariaLabel="User list"
/>
<!-- With selection enabled -->
<Grid
columns={columns}
rows={rows}
ariaLabel="User list"
selectable
multiselectable
/> API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
columns | GridColumnDef[] | required | 列定義 |
rows | GridRowData[] | required | 行データ |
ariaLabel | string | - | グリッドのアクセシブルな名前 |
selectable | boolean | false | セル選択を有効化 |
multiselectable | boolean | false | 複数セル選択を有効化 |
Astro版はクライアントサイドのインタラクティビティのためにカスタム要素
<apg-grid> を使用しています。キーボードナビゲーションと選択はハイドレーション後にJavaScriptで処理されます。 テスト
テストはキーボード操作、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) を参照してください。
Grid.test.astro.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, expect, it } from 'vitest';
import Grid from './Grid.astro';
// Helper data
const basicColumns = [
{ id: 'name', header: 'Name' },
{ id: 'email', header: 'Email' },
{ id: 'role', header: 'Role' },
];
const basicRows = [
{
id: 'row1',
cells: [
{ id: 'row1-0', value: 'Alice' },
{ id: 'row1-1', value: 'alice@example.com' },
{ id: 'row1-2', value: 'Admin' },
],
},
{
id: 'row2',
cells: [
{ id: 'row2-0', value: 'Bob' },
{ id: 'row2-1', value: 'bob@example.com' },
{ id: 'row2-2', value: 'User' },
],
},
];
const rowsWithDisabled = [
{
id: 'row1',
cells: [
{ id: 'row1-0', value: 'Alice' },
{ id: 'row1-1', value: 'alice@example.com', disabled: true },
{ id: 'row1-2', value: 'Admin' },
],
},
];
describe('Grid (Astro)', () => {
describe('ARIA Attributes', () => {
it('renders role="grid" on container', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
expect(result).toContain('role="grid"');
});
it('renders role="row" on rows', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
const rowMatches = result.match(/role="row"/g);
// Header row + 2 data rows = 3 rows
expect(rowMatches?.length).toBe(3);
});
it('renders role="gridcell" on data cells', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
const cellMatches = result.match(/role="gridcell"/g);
// 2 rows * 3 columns = 6 cells
expect(cellMatches?.length).toBe(6);
});
it('renders role="columnheader" on header cells', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
const headerMatches = result.match(/role="columnheader"/g);
expect(headerMatches?.length).toBe(3);
});
it('renders aria-label on grid', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
expect(result).toContain('aria-label="Users"');
});
it('renders aria-labelledby when provided', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabelledby: 'grid-title' },
});
expect(result).toContain('aria-labelledby="grid-title"');
});
it('renders aria-multiselectable when multiselectable', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: {
columns: basicColumns,
rows: basicRows,
ariaLabel: 'Users',
selectable: true,
multiselectable: true,
},
});
expect(result).toContain('aria-multiselectable="true"');
});
it('renders aria-disabled on disabled cells', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: rowsWithDisabled, ariaLabel: 'Users' },
});
expect(result).toContain('aria-disabled="true"');
});
it('renders aria-selected on selectable cells', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users', selectable: true },
});
expect(result).toContain('aria-selected="false"');
});
});
describe('Structure', () => {
it('renders Web Component wrapper', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
expect(result).toContain('<apg-grid');
expect(result).toContain('</apg-grid>');
});
it('renders cell values', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
expect(result).toContain('Alice');
expect(result).toContain('alice@example.com');
expect(result).toContain('Admin');
});
it('renders column headers', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
expect(result).toContain('Name');
expect(result).toContain('Email');
expect(result).toContain('Role');
});
});
describe('Focus Management', () => {
it('renders tabindex="0" on first focusable cell', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
// First gridcell should have tabindex="0"
const gridcellPattern = /role="gridcell"[^>]*tabindex="0"/;
expect(result).toMatch(gridcellPattern);
});
it('renders tabindex="-1" on other cells', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
// Other gridcells should have tabindex="-1"
const negativeTabindexMatches = result.match(/tabindex="-1"/g);
expect(negativeTabindexMatches?.length).toBeGreaterThan(0);
});
it('columnheader cells do not have tabindex', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
// columnheader should not have tabindex
const headerWithTabindex = /role="columnheader"[^>]*tabindex/;
expect(result).not.toMatch(headerWithTabindex);
});
});
describe('Virtualization', () => {
it('renders aria-rowcount when totalRows provided', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users', totalRows: 100 },
});
expect(result).toContain('aria-rowcount="100"');
});
it('renders aria-colcount when totalColumns provided', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users', totalColumns: 10 },
});
expect(result).toContain('aria-colcount="10"');
});
});
}); リソース
- WAI-ARIA APG: Grid パターン (opens in new tab)
- WAI-ARIA APG: Data Grid 例 (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist