APG Patterns
English
English

Grid

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

デモ

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

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.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 アクセシビリティ違反がないこと

テストツール

詳細は 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"');
    });
  });
});

リソース