APG Patterns
English
English

Grid

キーボードナビゲーション、セル選択、アクティベーションを備えたインタラクティブな2Dデータグリッド。

デモ

矢印キーで移動します。スペースキーでセルを選択します。Enterキーでアクティブにします。

Use arrow keys to navigate between cells. Press Space to select/deselect cells. Press Enter to activate a cell.

Name
Email
Role
Status
alice@example.com
Admin
Active
bob@example.com
Editor
Active
charlie@example.com
Viewer
Inactive
diana@example.com
Admin
Active
eve@example.com
Editor
Active

デモのみ表示 →

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 tabindex1つのセルのみがtabindex="0"(フォーカス中のセル)を持ち、他のすべてのセルはtabindex="-1"を持つ
単一Tabストップグリッドは単一のTabストップ(Tabでグリッドに入り、Shift+Tabで離脱)
ヘッダーセルヘッダーセル(columnheader)はフォーカス不可(この実装ではソート機能なし)
データセルのみデータ行のgridcellのみがキーボードナビゲーションに含まれる
フォーカスメモリグリッドを離れて再入場した際、最後にフォーカスされたセルが記憶される

参考資料

ソースコード

Grid.vue
<script setup lang="ts">
import { computed, ref, onMounted, nextTick } from 'vue';

// =============================================================================
// 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;
  selectedIds?: string[];
  defaultSelectedIds?: string[];
  defaultFocusedId?: string;
  totalColumns?: number;
  totalRows?: number;
  startRowIndex?: number;
  startColIndex?: number;
  wrapNavigation?: boolean;
  enablePageNavigation?: boolean;
  pageSize?: number;
}

// =============================================================================
// Props & Emits
// =============================================================================

const props = withDefaults(defineProps<Props>(), {
  selectable: false,
  multiselectable: false,
  defaultSelectedIds: () => [],
  startRowIndex: 1,
  startColIndex: 1,
  wrapNavigation: false,
  enablePageNavigation: false,
  pageSize: 5,
});

const emit = defineEmits<{
  selectionChange: [selectedIds: string[]];
  focusChange: [focusedId: string | null];
  cellActivate: [cellId: string, rowId: string, colId: string];
}>();

// =============================================================================
// State
// =============================================================================

const internalSelectedIds = ref<string[]>([...props.defaultSelectedIds]);
const selectedIds = computed(() => props.selectedIds ?? internalSelectedIds.value);

const focusedId = ref<string | null>(props.defaultFocusedId ?? props.rows[0]?.cells[0]?.id ?? null);

const gridRef = ref<HTMLDivElement | null>(null);
const cellRefs = ref<Map<string, HTMLDivElement>>(new Map());

// =============================================================================
// Computed
// =============================================================================

// Map cellId to cell info for O(1) lookup
const cellById = computed(() => {
  const map = new Map<
    string,
    { rowIndex: number; colIndex: number; cell: GridCellData; rowId: string }
  >();
  props.rows.forEach((row, rowIndex) => {
    row.cells.forEach((cell, colIndex) => {
      map.set(cell.id, { rowIndex, colIndex, cell, rowId: row.id });
    });
  });
  return map;
});

// =============================================================================
// Methods
// =============================================================================

function getCellPosition(cellId: string) {
  const entry = cellById.value.get(cellId);
  if (!entry) {
    return null;
  }
  const { rowIndex, colIndex } = entry;
  return { rowIndex, colIndex };
}

function getCellAt(rowIndex: number, colIndex: number) {
  const cell = props.rows[rowIndex]?.cells[colIndex];
  if (!cell) return undefined;
  return cellById.value.get(cell.id);
}

function setFocusedId(id: string | null) {
  focusedId.value = id;
  emit('focusChange', id);
}

function focusCell(cellId: string) {
  const cellEl = cellRefs.value.get(cellId);
  if (cellEl) {
    // 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 = cellEl.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 {
      cellEl.focus();
    }
    setFocusedId(cellId);
  }
}

function findNextFocusableCell(
  startRowIndex: number,
  startColIndex: number,
  direction: 'right' | 'left' | 'up' | 'down',
  skipDisabled = true
): { rowIndex: number; colIndex: number; cell: GridCellData } | null {
  const colCount = props.columns.length;
  const rowCount = props.rows.length;

  let rowIdx = startRowIndex;
  let colIdx = startColIndex;

  const step = () => {
    switch (direction) {
      case 'right':
        colIdx++;
        if (colIdx >= colCount) {
          if (props.wrapNavigation) {
            colIdx = 0;
            rowIdx++;
            if (rowIdx >= rowCount) return false;
          } else {
            return false;
          }
        }
        break;
      case 'left':
        colIdx--;
        if (colIdx < 0) {
          if (props.wrapNavigation) {
            colIdx = colCount - 1;
            rowIdx--;
            if (rowIdx < 0) return false;
          } else {
            return false;
          }
        }
        break;
      case 'down':
        rowIdx++;
        if (rowIdx >= rowCount) return false;
        break;
      case 'up':
        rowIdx--;
        if (rowIdx < 0) return false;
        break;
    }
    return true;
  };

  if (!step()) return null;

  let iterations = 0;
  const maxIterations = colCount * rowCount;

  while (iterations < maxIterations) {
    const entry = getCellAt(rowIdx, colIdx);
    if (entry && (!skipDisabled || !entry.cell.disabled)) {
      return { rowIndex: rowIdx, colIndex: colIdx, cell: entry.cell };
    }
    if (!step()) break;
    iterations++;
  }

  return null;
}

function setSelectedIds(ids: string[]) {
  internalSelectedIds.value = ids;
  emit('selectionChange', ids);
}

function toggleSelection(cellId: string, cell: GridCellData) {
  if (!props.selectable || cell.disabled) return;

  if (props.multiselectable) {
    const newIds = selectedIds.value.includes(cellId)
      ? selectedIds.value.filter((id) => id !== cellId)
      : [...selectedIds.value, cellId];
    setSelectedIds(newIds);
  } else {
    const newIds = selectedIds.value.includes(cellId) ? [] : [cellId];
    setSelectedIds(newIds);
  }
}

function selectAll() {
  if (!props.selectable || !props.multiselectable) {
    return;
  }

  const allIds = Array.from(cellById.value.values())
    .filter(({ cell }) => !cell.disabled)
    .map(({ cell }) => cell.id);
  setSelectedIds(allIds);
}

function handleKeyDown(event: KeyboardEvent, cell: GridCellData, rowId: string, colId: string) {
  const pos = getCellPosition(cell.id);
  if (!pos) return;

  const { rowIndex, colIndex } = pos;
  let handled = true;

  switch (event.key) {
    case 'ArrowRight': {
      const next = findNextFocusableCell(rowIndex, colIndex, 'right');
      if (next) focusCell(next.cell.id);
      break;
    }
    case 'ArrowLeft': {
      const next = findNextFocusableCell(rowIndex, colIndex, 'left');
      if (next) focusCell(next.cell.id);
      break;
    }
    case 'ArrowDown': {
      const next = findNextFocusableCell(rowIndex, colIndex, 'down');
      if (next) focusCell(next.cell.id);
      break;
    }
    case 'ArrowUp': {
      const next = findNextFocusableCell(rowIndex, colIndex, 'up');
      if (next) focusCell(next.cell.id);
      break;
    }
    case 'Home': {
      if (event.ctrlKey) {
        const firstCell = props.rows[0]?.cells[0];
        if (firstCell) focusCell(firstCell.id);
      } else {
        const firstCellInRow = props.rows[rowIndex]?.cells[0];
        if (firstCellInRow) focusCell(firstCellInRow.id);
      }
      break;
    }
    case 'End': {
      if (event.ctrlKey) {
        const lastRow = props.rows[props.rows.length - 1];
        const lastCell = lastRow?.cells[lastRow.cells.length - 1];
        if (lastCell) focusCell(lastCell.id);
      } else {
        const currentRow = props.rows[rowIndex];
        const lastCellInRow = currentRow?.cells[currentRow.cells.length - 1];
        if (lastCellInRow) focusCell(lastCellInRow.id);
      }
      break;
    }
    case 'PageDown': {
      if (props.enablePageNavigation) {
        const targetRowIndex = Math.min(rowIndex + props.pageSize, props.rows.length - 1);
        const targetCell = props.rows[targetRowIndex]?.cells[colIndex];
        if (targetCell) focusCell(targetCell.id);
      } else {
        handled = false;
      }
      break;
    }
    case 'PageUp': {
      if (props.enablePageNavigation) {
        const targetRowIndex = Math.max(rowIndex - props.pageSize, 0);
        const targetCell = props.rows[targetRowIndex]?.cells[colIndex];
        if (targetCell) focusCell(targetCell.id);
      } else {
        handled = false;
      }
      break;
    }
    case ' ': {
      toggleSelection(cell.id, cell);
      break;
    }
    case 'Enter': {
      if (!cell.disabled) {
        emit('cellActivate', cell.id, rowId, colId);
      }
      break;
    }
    case 'a': {
      if (event.ctrlKey) {
        selectAll();
      } else {
        handled = false;
      }
      break;
    }
    default:
      handled = false;
  }

  if (handled) {
    event.preventDefault();
    event.stopPropagation();
  }
}

function setCellRef(cellId: string, el: HTMLDivElement | null) {
  if (el) {
    cellRefs.value.set(cellId, el);
  } else {
    cellRefs.value.delete(cellId);
  }
}

// Set tabindex="-1" on all focusable elements inside grid cells
// This ensures Tab exits the grid instead of moving between widgets
onMounted(() => {
  nextTick(() => {
    if (gridRef.value) {
      const focusableElements = gridRef.value.querySelectorAll<HTMLElement>(
        '[role="gridcell"] a[href], [role="gridcell"] button, [role="rowheader"] a[href], [role="rowheader"] button'
      );
      focusableElements.forEach((el) => {
        el.setAttribute('tabindex', '-1');
      });
    }
  });
});
</script>

<template>
  <div
    ref="gridRef"
    role="grid"
    :aria-label="ariaLabel"
    :aria-labelledby="ariaLabelledby"
    :aria-multiselectable="multiselectable ? 'true' : undefined"
    :aria-rowcount="totalRows"
    :aria-colcount="totalColumns"
    class="apg-grid"
  >
    <!-- Header Row -->
    <div role="row" :aria-rowindex="totalRows ? 1 : undefined">
      <div
        v-for="(col, colIndex) in columns"
        :key="col.id"
        role="columnheader"
        :aria-colindex="totalColumns ? startColIndex + colIndex : undefined"
        :aria-colspan="col.colspan"
      >
        {{ col.header }}
      </div>
    </div>

    <!-- Data Rows -->
    <div
      v-for="(row, rowIndex) in rows"
      :key="row.id"
      role="row"
      :aria-rowindex="totalRows ? startRowIndex + rowIndex : undefined"
    >
      <div
        v-for="(cell, colIndex) in row.cells"
        :key="cell.id"
        :ref="(el) => setCellRef(cell.id, el as HTMLDivElement)"
        :role="row.hasRowHeader && colIndex === 0 ? 'rowheader' : 'gridcell'"
        :tabindex="cell.id === focusedId ? 0 : -1"
        :aria-selected="selectable ? (selectedIds.includes(cell.id) ? 'true' : 'false') : undefined"
        :aria-disabled="cell.disabled ? 'true' : undefined"
        :aria-colindex="totalColumns ? startColIndex + colIndex : undefined"
        :aria-colspan="cell.colspan"
        :aria-rowspan="cell.rowspan"
        class="apg-grid-cell"
        :class="{
          focused: cell.id === focusedId,
          selected: selectedIds.includes(cell.id),
          disabled: cell.disabled,
        }"
        @keydown="handleKeyDown($event, cell, row.id, columns[colIndex]?.id ?? '')"
        @focusin="setFocusedId(cell.id)"
      >
        <slot name="cell" :cell="cell" :row-id="row.id" :col-id="columns[colIndex]?.id ?? ''">
          {{ cell.value }}
        </slot>
      </div>
    </div>
  </div>
</template>

使い方

Example
<script setup lang="ts">
import { ref } from 'vue';
import Grid from './Grid.vue';

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' },
    ],
  },
];

const selectedIds = ref<string[]>([]);

function handleSelectionChange(ids: string[]) {
  selectedIds.value = ids;
}
</script>

<template>
  <Grid
    :columns="columns"
    :rows="rows"
    aria-label="User list"
    selectable
    :multiselectable="true"
    :selected-ids="selectedIds"
    @selection-change="handleSelectionChange"
    @cell-activate="(cellId, rowId, colId) => console.log({ cellId, rowId, colId })"
  />
</template>

API

プロパティ デフォルト 説明
columns GridColumnDef[] required 列定義
rows GridRowData[] required 行データ
selectable boolean false セル選択を有効化
multiselectable boolean false 複数セル選択を有効化
selected-ids string[] [] 選択中のセルID

Custom Events

イベント Detail 説明
selection-change string[] 選択が変更されたときに発火
cell-activate (cellId, rowId, colId) セルがアクティベートされたときに発火

テスト

テストはキーボード操作、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 アクセシビリティ違反がないこと

テストツール

詳細は testing-strategy.md (opens in new tab) を参照してください。

Grid.test.vue.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Grid from './Grid.vue';

// Helper function to create basic grid data
const createBasicColumns = () => [
  { id: 'name', header: 'Name' },
  { id: 'email', header: 'Email' },
  { id: 'role', header: 'Role' },
];

const createBasicRows = () => [
  {
    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' },
    ],
  },
  {
    id: 'row3',
    cells: [
      { id: 'row3-0', value: 'Charlie' },
      { id: 'row3-1', value: 'charlie@example.com' },
      { id: 'row3-2', value: 'User' },
    ],
  },
];

const createRowsWithDisabled = () => [
  {
    id: 'row1',
    cells: [
      { id: 'row1-0', value: 'Alice' },
      { id: 'row1-1', value: 'alice@example.com', disabled: true },
      { 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' },
    ],
  },
];

describe('Grid (Vue)', () => {
  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has role="grid" on container', () => {
      render(Grid, {
        props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
      });
      expect(screen.getByRole('grid')).toBeInTheDocument();
    });

    it('has role="row" on all rows', () => {
      render(Grid, {
        props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
      });
      expect(screen.getAllByRole('row')).toHaveLength(4);
    });

    it('has role="gridcell" on data cells', () => {
      render(Grid, {
        props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
      });
      expect(screen.getAllByRole('gridcell')).toHaveLength(9);
    });

    it('has role="columnheader" on header cells', () => {
      render(Grid, {
        props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
      });
      expect(screen.getAllByRole('columnheader')).toHaveLength(3);
    });

    it('has accessible name via aria-label', () => {
      render(Grid, {
        props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
      });
      expect(screen.getByRole('grid', { name: 'Users' })).toBeInTheDocument();
    });

    it('has aria-multiselectable when multiselectable', () => {
      render(Grid, {
        props: {
          columns: createBasicColumns(),
          rows: createBasicRows(),
          ariaLabel: 'Users',
          selectable: true,
          multiselectable: true,
        },
      });
      expect(screen.getByRole('grid')).toHaveAttribute('aria-multiselectable', 'true');
    });

    it('has aria-disabled on disabled cells', () => {
      render(Grid, {
        props: {
          columns: createBasicColumns(),
          rows: createRowsWithDisabled(),
          ariaLabel: 'Users',
        },
      });
      const disabledCell = screen.getByRole('gridcell', { name: 'alice@example.com' });
      expect(disabledCell).toHaveAttribute('aria-disabled', 'true');
    });
  });

  // 🔴 High Priority: Keyboard Navigation
  describe('Keyboard Navigation', () => {
    it('ArrowRight moves focus one cell right', async () => {
      const user = userEvent.setup();
      render(Grid, {
        props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
      });

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();

      await user.keyboard('{ArrowRight}');

      await vi.waitFor(() => {
        expect(screen.getAllByRole('gridcell')[1]).toHaveFocus();
      });
    });

    it('ArrowDown moves focus one row down', async () => {
      const user = userEvent.setup();
      render(Grid, {
        props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
      });

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();

      await user.keyboard('{ArrowDown}');

      await vi.waitFor(() => {
        expect(screen.getAllByRole('gridcell')[3]).toHaveFocus();
      });
    });

    it('ArrowUp stops at first data row', async () => {
      const user = userEvent.setup();
      render(Grid, {
        props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
      });

      const firstDataCell = screen.getAllByRole('gridcell')[0];
      firstDataCell.focus();

      await user.keyboard('{ArrowUp}');

      await vi.waitFor(() => {
        expect(firstDataCell).toHaveFocus();
      });
    });

    it('Home moves to first cell in row', async () => {
      const user = userEvent.setup();
      render(Grid, {
        props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
      });

      const lastCellInRow = screen.getAllByRole('gridcell')[2];
      lastCellInRow.focus();

      await user.keyboard('{Home}');

      await vi.waitFor(() => {
        expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
      });
    });

    it('End moves to last cell in row', async () => {
      const user = userEvent.setup();
      render(Grid, {
        props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
      });

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();

      await user.keyboard('{End}');

      await vi.waitFor(() => {
        expect(screen.getAllByRole('gridcell')[2]).toHaveFocus();
      });
    });
  });

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('first focusable cell has tabIndex="0" by default', () => {
      render(Grid, {
        props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
      });

      const firstCell = screen.getAllByRole('gridcell')[0];
      expect(firstCell).toHaveAttribute('tabindex', '0');
    });

    it('other cells have tabIndex="-1"', () => {
      render(Grid, {
        props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
      });

      const cells = screen.getAllByRole('gridcell');
      expect(cells[0]).toHaveAttribute('tabindex', '0');
      expect(cells[1]).toHaveAttribute('tabindex', '-1');
    });

    it('columnheader cells are not focusable', () => {
      render(Grid, {
        props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
      });

      const headers = screen.getAllByRole('columnheader');
      headers.forEach((header) => {
        expect(header).not.toHaveAttribute('tabindex');
      });
    });
  });

  // 🔴 High Priority: Selection
  describe('Selection', () => {
    it('Space toggles selection', async () => {
      const user = userEvent.setup();
      render(Grid, {
        props: {
          columns: createBasicColumns(),
          rows: createBasicRows(),
          ariaLabel: 'Users',
          selectable: true,
        },
      });

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();

      expect(firstCell).toHaveAttribute('aria-selected', 'false');

      await user.keyboard(' ');

      await vi.waitFor(() => {
        expect(firstCell).toHaveAttribute('aria-selected', 'true');
      });
    });

    it('Enter activates cell', async () => {
      const user = userEvent.setup();
      const onCellActivate = vi.fn();
      render(Grid, {
        props: {
          columns: createBasicColumns(),
          rows: createBasicRows(),
          ariaLabel: 'Users',
          onCellActivate,
        },
      });

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();

      await user.keyboard('{Enter}');

      expect(onCellActivate).toHaveBeenCalled();
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const { container } = render(Grid, {
        props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });
});

リソース