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.tsx
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

// =============================================================================
// 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;
}

export interface GridProps {
  columns: GridColumnDef[];
  rows: GridRowData[];

  // Accessible name (one required)
  ariaLabel?: string;
  ariaLabelledby?: string;

  // Selection
  selectable?: boolean;
  multiselectable?: boolean;
  selectedIds?: string[];
  defaultSelectedIds?: string[];
  onSelectionChange?: (selectedIds: string[]) => void;

  // Focus
  focusedId?: string | null;
  defaultFocusedId?: string;
  onFocusChange?: (focusedId: string | null) => void;

  // Virtualization
  totalColumns?: number;
  totalRows?: number;
  startRowIndex?: number; // 1-based
  startColIndex?: number; // 1-based

  // Behavior
  wrapNavigation?: boolean;
  enablePageNavigation?: boolean;
  pageSize?: number;

  // Callbacks
  onCellActivate?: (cellId: string, rowId: string, colId: string) => void;
  renderCell?: (cell: GridCellData, rowId: string, colId: string) => React.ReactNode;

  // Styling
  className?: string;
}

// =============================================================================
// Component
// =============================================================================

export function Grid({
  columns,
  rows,
  ariaLabel,
  ariaLabelledby,
  selectable = false,
  multiselectable = false,
  selectedIds: controlledSelectedIds,
  defaultSelectedIds = [],
  onSelectionChange,
  focusedId: controlledFocusedId,
  defaultFocusedId,
  onFocusChange,
  totalColumns,
  totalRows,
  startRowIndex = 1,
  startColIndex = 1,
  wrapNavigation = false,
  enablePageNavigation = false,
  pageSize = 5,
  onCellActivate,
  renderCell,
  className,
}: GridProps) {
  // ==========================================================================
  // State
  // ==========================================================================

  const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(defaultSelectedIds);
  const selectedIds = controlledSelectedIds ?? internalSelectedIds;

  const [internalFocusedId, setInternalFocusedId] = useState<string | null>(() => {
    if (defaultFocusedId) return defaultFocusedId;
    // Default to first cell
    return rows[0]?.cells[0]?.id ?? null;
  });
  const focusedId = controlledFocusedId !== undefined ? controlledFocusedId : internalFocusedId;

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

  // ==========================================================================
  // Computed values
  // ==========================================================================

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

  const getCellPosition = useCallback(
    (cellId: string) => {
      const entry = cellById.get(cellId);
      if (!entry) {
        return null;
      }
      const { rowIndex, colIndex } = entry;
      return { rowIndex, colIndex };
    },
    [cellById]
  );

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

  const getColumnCount = useCallback(() => {
    return columns.length;
  }, [columns]);

  const getRowCount = useCallback(() => {
    return rows.length;
  }, [rows]);

  // ==========================================================================
  // Focus Management
  // ==========================================================================

  const setFocusedId = useCallback(
    (id: string | null) => {
      setInternalFocusedId(id);
      onFocusChange?.(id);
    },
    [onFocusChange]
  );

  const focusCell = useCallback(
    (cellId: string) => {
      const cellEl = cellRefs.current.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);
      }
    },
    [setFocusedId]
  );

  // Find next focusable cell (skipping disabled cells if needed)
  const findNextFocusableCell = useCallback(
    (
      startRowIndex: number,
      startColIndex: number,
      direction: 'right' | 'left' | 'up' | 'down',
      skipDisabled = true
    ): { rowIndex: number; colIndex: number; cell: GridCellData } | null => {
      const colCount = getColumnCount();
      const rowCount = getRowCount();

      let rowIdx = startRowIndex;
      let colIdx = startColIndex;

      const step = () => {
        switch (direction) {
          case 'right':
            colIdx++;
            if (colIdx >= colCount) {
              if (wrapNavigation) {
                colIdx = 0;
                rowIdx++;
                if (rowIdx >= rowCount) {
                  return false; // End of grid
                }
              } else {
                return false; // Stay at edge
              }
            }
            break;
          case 'left':
            colIdx--;
            if (colIdx < 0) {
              if (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;
      };

      // Take one step first
      if (!step()) return null;

      // Find non-disabled cell
      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;
    },
    [getColumnCount, getRowCount, wrapNavigation, getCellAt]
  );

  // ==========================================================================
  // Selection Management
  // ==========================================================================

  const setSelectedIds = useCallback(
    (ids: string[]) => {
      setInternalSelectedIds(ids);
      onSelectionChange?.(ids);
    },
    [onSelectionChange]
  );

  const toggleSelection = useCallback(
    (cellId: string, cell: GridCellData) => {
      if (!selectable || cell.disabled) {
        return;
      }

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

  const selectAll = useCallback(() => {
    if (!selectable || !multiselectable) {
      return;
    }

    const allIds = Array.from(cellById.values())
      .filter(({ cell }) => !cell.disabled)
      .map(({ cell }) => cell.id);
    setSelectedIds(allIds);
  }, [selectable, multiselectable, cellById, setSelectedIds]);

  // ==========================================================================
  // Keyboard Handling
  // ==========================================================================

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

      const { rowIndex, colIndex } = pos;
      const { key, ctrlKey } = event;

      let handled = true;

      switch (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 (ctrlKey) {
            // Ctrl+Home: Go to first cell in grid
            const firstCell = rows[0]?.cells[0];
            if (firstCell) focusCell(firstCell.id);
          } else {
            // Home: Go to first cell in row
            const firstCellInRow = rows[rowIndex]?.cells[0];
            if (firstCellInRow) focusCell(firstCellInRow.id);
          }
          break;
        }
        case 'End': {
          if (ctrlKey) {
            // Ctrl+End: Go to last cell in grid
            const lastRow = rows[rows.length - 1];
            const lastCell = lastRow?.cells[lastRow.cells.length - 1];
            if (lastCell) focusCell(lastCell.id);
          } else {
            // End: Go to last cell in row
            const currentRow = rows[rowIndex];
            const lastCellInRow = currentRow?.cells[currentRow.cells.length - 1];
            if (lastCellInRow) focusCell(lastCellInRow.id);
          }
          break;
        }
        case 'PageDown': {
          if (enablePageNavigation) {
            const targetRowIndex = Math.min(rowIndex + pageSize, rows.length - 1);
            const targetCell = rows[targetRowIndex]?.cells[colIndex];
            if (targetCell) focusCell(targetCell.id);
          } else {
            handled = false;
          }
          break;
        }
        case 'PageUp': {
          if (enablePageNavigation) {
            const targetRowIndex = Math.max(rowIndex - pageSize, 0);
            const targetCell = rows[targetRowIndex]?.cells[colIndex];
            if (targetCell) focusCell(targetCell.id);
          } else {
            handled = false;
          }
          break;
        }
        case ' ': {
          toggleSelection(cell.id, cell);
          break;
        }
        case 'Enter': {
          if (!cell.disabled) {
            onCellActivate?.(cell.id, rowId, colId);
          }
          break;
        }
        case 'a': {
          if (ctrlKey) {
            selectAll();
          } else {
            handled = false;
          }
          break;
        }
        default:
          handled = false;
      }

      if (handled) {
        event.preventDefault();
        event.stopPropagation();
      }
    },
    [
      getCellPosition,
      findNextFocusableCell,
      focusCell,
      rows,
      enablePageNavigation,
      pageSize,
      toggleSelection,
      onCellActivate,
      selectAll,
    ]
  );

  // ==========================================================================
  // Effects
  // ==========================================================================

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

  // Focus the focused cell when focusedId changes externally
  useEffect(() => {
    if (focusedId) {
      const cellEl = cellRefs.current.get(focusedId);
      if (cellEl && document.activeElement !== cellEl) {
        // Only focus if grid is already focused
        if (gridRef.current?.contains(document.activeElement)) {
          cellEl.focus();
        }
      }
    }
  }, [focusedId]);

  // ==========================================================================
  // Render
  // ==========================================================================

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

      {/* Data Rows */}
      {rows.map((row, rowIndex) => (
        <div
          key={row.id}
          role="row"
          aria-rowindex={totalRows ? startRowIndex + rowIndex : undefined}
        >
          {row.cells.map((cell, colIndex) => {
            const isRowHeader = row.hasRowHeader && colIndex === 0;
            const isFocused = cell.id === focusedId;
            const isSelected = selectedIds.includes(cell.id);
            const colId = columns[colIndex]?.id ?? '';

            return (
              <div
                key={cell.id}
                ref={(el) => {
                  if (el) {
                    cellRefs.current.set(cell.id, el);
                  } else {
                    cellRefs.current.delete(cell.id);
                  }
                }}
                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}
                onKeyDown={(e) => handleKeyDown(e, cell, row.id, colId)}
                onFocus={() => setFocusedId(cell.id)}
                className={`apg-grid-cell ${isFocused ? 'focused' : ''} ${isSelected ? 'selected' : ''} ${cell.disabled ? 'disabled' : ''}`}
              >
                {renderCell ? renderCell(cell, row.id, colId) : cell.value}
              </div>
            );
          })}
        </div>
      ))}
    </div>
  );
}

export default Grid;

使い方

Example
import { Grid } from './Grid';
import type { GridColumnDef, GridRowData } from './Grid';

const columns: GridColumnDef[] = [
  { id: 'name', header: 'Name' },
  { id: 'email', header: 'Email' },
  { id: 'role', header: 'Role' },
];

const rows: GridRowData[] = [
  {
    id: 'user1',
    cells: [
      { id: 'user1-0', value: 'Alice Johnson' },
      { id: 'user1-1', value: 'alice@example.com' },
      { id: 'user1-2', value: 'Admin' },
    ],
  },
  {
    id: 'user2',
    cells: [
      { id: 'user2-0', value: 'Bob Smith' },
      { id: 'user2-1', value: 'bob@example.com' },
      { id: 'user2-2', value: 'User' },
    ],
  },
];

// Basic Grid
<Grid
  columns={columns}
  rows={rows}
  ariaLabel="User list"
/>

// With selection
<Grid
  columns={columns}
  rows={rows}
  ariaLabel="User list"
  selectable
  multiselectable
  selectedIds={selectedIds}
  onSelectionChange={(ids) => setSelectedIds(ids)}
  onCellActivate={(cellId, rowId, colId) => {
    console.log('Activated:', { cellId, rowId, colId });
  }}
/>

API

プロパティ デフォルト 説明
columns GridColumnDef[] required 列定義
rows GridRowData[] required 行データ
ariaLabel string - グリッドのアクセシブルな名前
ariaLabelledby string - アクセシブルな名前のID参照
selectable boolean false セル選択を有効化
multiselectable boolean false 複数セル選択を有効化
selectedIds string[] [] 選択中のセルID
onSelectionChange (ids: string[]) => void - 選択変更コールバック
onCellActivate (cellId, rowId, colId) => void - セルアクティベーションコールバック
wrapNavigation boolean false 行端でナビゲーションを折り返す
pageSize number 5 PageUp/Downでスキップする行数

テスト

テストはキーボード操作、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.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Grid, type GridColumnDef, type GridRowData } from './Grid';

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

const createBasicRows = (): GridRowData[] => [
  {
    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' },
    ],
  },
];

// Rows with disabled cells
const createRowsWithDisabled = (): GridRowData[] => [
  {
    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' },
    ],
  },
];

// Rows with row header
const createRowsWithRowHeader = (): GridRowData[] => [
  {
    id: 'row1',
    hasRowHeader: true,
    cells: [
      { id: 'row1-0', value: '1' },
      { id: 'row1-1', value: 'Alice' },
      { id: 'row1-2', value: 'Admin' },
    ],
  },
  {
    id: 'row2',
    hasRowHeader: true,
    cells: [
      { id: 'row2-0', value: '2' },
      { id: 'row2-1', value: 'Bob' },
      { id: 'row2-2', value: 'User' },
    ],
  },
];

// Rows with spanned cells
const createRowsWithSpan = (): GridRowData[] => [
  {
    id: 'row1',
    cells: [
      { id: 'row1-0', value: 'Merged', colspan: 2 },
      { id: 'row1-2', value: 'Normal' },
    ],
  },
  {
    id: 'row2',
    cells: [
      { id: 'row2-0', value: 'A' },
      { id: 'row2-1', value: 'B' },
      { id: 'row2-2', value: 'C' },
    ],
  },
];

// Columns with span
const createColumnsWithSpan = (): GridColumnDef[] => [
  { id: 'info', header: 'Info', colspan: 2 },
  { id: 'role', header: 'Role' },
];

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

    it('has role="row" on all rows', () => {
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
      // Header row + 3 data rows = 4 rows
      expect(screen.getAllByRole('row')).toHaveLength(4);
    });

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

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

    it('has role="rowheader" when hasRowHeader', () => {
      render(
        <Grid columns={createBasicColumns()} rows={createRowsWithRowHeader()} ariaLabel="Users" />
      );
      expect(screen.getAllByRole('rowheader')).toHaveLength(2);
    });

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

    it('has accessible name via aria-labelledby', () => {
      render(
        <div>
          <h2 id="grid-title">User List</h2>
          <Grid
            columns={createBasicColumns()}
            rows={createBasicRows()}
            ariaLabelledby="grid-title"
          />
        </div>
      );
      const grid = screen.getByRole('grid');
      expect(grid).toHaveAttribute('aria-labelledby', 'grid-title');
    });

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

    it('has aria-selected on selectable cells', () => {
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
        />
      );
      const cells = screen.getAllByRole('gridcell');
      cells.forEach((cell) => {
        expect(cell).toHaveAttribute('aria-selected', 'false');
      });
    });

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

    it('has aria-colspan on spanned cells', () => {
      render(<Grid columns={createBasicColumns()} rows={createRowsWithSpan()} ariaLabel="Users" />);
      const mergedCell = screen.getByRole('gridcell', { name: 'Merged' });
      expect(mergedCell).toHaveAttribute('aria-colspan', '2');
    });

    it('has aria-colspan on spanned columnheader', () => {
      render(<Grid columns={createColumnsWithSpan()} rows={createBasicRows()} ariaLabel="Users" />);
      const infoHeader = screen.getByRole('columnheader', { name: 'Info' });
      expect(infoHeader).toHaveAttribute('aria-colspan', '2');
    });
  });

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

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

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

      expect(screen.getAllByRole('gridcell')[1]).toHaveFocus();
    });

    it('ArrowLeft moves focus one cell left', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const secondCell = screen.getAllByRole('gridcell')[1];
      secondCell.focus();

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

      expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
    });

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

      const firstCell = screen.getAllByRole('gridcell')[0]; // row1, col0
      firstCell.focus();

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

      // Should move to row2, col0
      expect(screen.getAllByRole('gridcell')[3]).toHaveFocus();
    });

    it('ArrowUp moves focus one row up', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const secondRowFirstCell = screen.getAllByRole('gridcell')[3]; // row2, col0
      secondRowFirstCell.focus();

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

      // Should move to row1, col0
      expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
    });

    it('ArrowRight stops at row end (default)', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const lastCellInRow = screen.getAllByRole('gridcell')[2]; // row1, col2 (last in row)
      lastCellInRow.focus();

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

      // Should stay at the same cell
      expect(lastCellInRow).toHaveFocus();
    });

    it('ArrowRight wraps to next row when wrapNavigation is true', async () => {
      const user = userEvent.setup();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          wrapNavigation
        />
      );

      const lastCellInRow = screen.getAllByRole('gridcell')[2]; // row1, col2 (last in row)
      lastCellInRow.focus();

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

      // Should wrap to first cell of next row
      expect(screen.getAllByRole('gridcell')[3]).toHaveFocus();
    });

    it('ArrowDown stops at grid bottom', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const lastRowCell = screen.getAllByRole('gridcell')[6]; // row3, col0 (last row)
      lastRowCell.focus();

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

      // Should stay at the same cell
      expect(lastRowCell).toHaveFocus();
    });

    it('ArrowUp stops at first data row (does not enter headers)', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const firstDataCell = screen.getAllByRole('gridcell')[0]; // row1, col0
      firstDataCell.focus();

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

      // Should stay at the first data cell, not move to header
      expect(firstDataCell).toHaveFocus();
    });

    it('skips disabled cells during horizontal navigation', async () => {
      const user = userEvent.setup();
      render(
        <Grid columns={createBasicColumns()} rows={createRowsWithDisabled()} ariaLabel="Users" />
      );

      const firstCell = screen.getAllByRole('gridcell')[0]; // row1, col0 (Alice)
      firstCell.focus();

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

      // Should skip disabled cell (alice@example.com) and focus Admin
      expect(screen.getByRole('gridcell', { name: 'Admin' })).toHaveFocus();
    });
  });

  // 🔴 High Priority: Keyboard - Extended Navigation
  describe('Keyboard - Extended Navigation', () => {
    it('Home moves to first cell in row', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const lastCellInRow = screen.getAllByRole('gridcell')[2]; // row1, col2
      lastCellInRow.focus();

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

      expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
    });

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

      const firstCell = screen.getAllByRole('gridcell')[0]; // row1, col0
      firstCell.focus();

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

      expect(screen.getAllByRole('gridcell')[2]).toHaveFocus();
    });

    it('Ctrl+Home moves to first cell in grid', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const lastCell = screen.getAllByRole('gridcell')[8]; // row3, col2 (last cell)
      lastCell.focus();

      await user.keyboard('{Control>}{Home}{/Control}');

      expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
    });

    it('Ctrl+End moves to last cell in grid', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const firstCell = screen.getAllByRole('gridcell')[0]; // row1, col0
      firstCell.focus();

      await user.keyboard('{Control>}{End}{/Control}');

      expect(screen.getAllByRole('gridcell')[8]).toHaveFocus();
    });

    it('PageDown moves down by pageSize', async () => {
      const user = userEvent.setup();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          enablePageNavigation
          pageSize={2}
        />
      );

      const firstCell = screen.getAllByRole('gridcell')[0]; // row1, col0
      firstCell.focus();

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

      // Should move 2 rows down
      expect(screen.getAllByRole('gridcell')[6]).toHaveFocus();
    });

    it('PageUp moves up by pageSize', async () => {
      const user = userEvent.setup();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          enablePageNavigation
          pageSize={2}
        />
      );

      const lastRowCell = screen.getAllByRole('gridcell')[6]; // row3, col0
      lastRowCell.focus();

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

      // Should move 2 rows up
      expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
    });
  });

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

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

    it('defaultFocusedId sets initial focus', () => {
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          defaultFocusedId="row2-1"
        />
      );

      const targetCell = screen.getByRole('gridcell', { name: 'bob@example.com' });
      expect(targetCell).toHaveAttribute('tabindex', '0');
    });

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

      const cells = screen.getAllByRole('gridcell');
      // First cell should have tabindex="0", others should have tabindex="-1"
      expect(cells[0]).toHaveAttribute('tabindex', '0');
      expect(cells[1]).toHaveAttribute('tabindex', '-1');
      expect(cells[2]).toHaveAttribute('tabindex', '-1');
    });

    it('focused cell updates tabIndex on navigation', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

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

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

      expect(cells[0]).toHaveAttribute('tabindex', '-1');
      expect(cells[1]).toHaveAttribute('tabindex', '0');
    });

    it('disabled cells are focusable', async () => {
      const user = userEvent.setup();
      render(
        <Grid columns={createBasicColumns()} rows={createRowsWithDisabled()} ariaLabel="Users" />
      );

      const disabledCell = screen.getByRole('gridcell', { name: 'alice@example.com' });
      // Disabled cell should still have tabindex (either 0 or -1)
      expect(disabledCell).toHaveAttribute('tabindex');
    });

    it('Tab focuses grid then exits', async () => {
      const user = userEvent.setup();
      render(
        <div>
          <button>Before</button>
          <Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
          <button>After</button>
        </div>
      );

      const beforeButton = screen.getByRole('button', { name: 'Before' });
      beforeButton.focus();

      await user.tab();
      // Should focus grid (first cell)
      expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();

      await user.tab();
      // Should exit grid to next element
      expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
    });

    it('Shift+Tab exits grid to previous element', async () => {
      const user = userEvent.setup();
      render(
        <div>
          <button>Before</button>
          <Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
          <button>After</button>
        </div>
      );

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

      // Use fireEvent for Shift+Tab due to jsdom limitations
      fireEvent.keyDown(firstCell, { key: 'Tab', shiftKey: true });

      // Note: actual focus behavior depends on browser, but we verify the event is handled
    });

    it('columnheader cells are not focusable', () => {
      render(<Grid 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 (single)', async () => {
      const user = userEvent.setup();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
        />
      );

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

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

      await user.keyboard(' ');

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

    it('Space toggles selection (multi)', async () => {
      const user = userEvent.setup();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
          multiselectable
        />
      );

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

      await user.keyboard(' ');
      expect(cells[0]).toHaveAttribute('aria-selected', 'true');

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

      // Both should be selected in multiselect mode
      expect(cells[0]).toHaveAttribute('aria-selected', 'true');
      expect(cells[1]).toHaveAttribute('aria-selected', 'true');
    });

    it('single selection clears previous on Space', async () => {
      const user = userEvent.setup();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
        />
      );

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

      await user.keyboard(' ');
      expect(cells[0]).toHaveAttribute('aria-selected', 'true');

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

      // Previous selection should be cleared
      expect(cells[0]).toHaveAttribute('aria-selected', 'false');
      expect(cells[1]).toHaveAttribute('aria-selected', 'true');
    });

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

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

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

      expect(onCellActivate).toHaveBeenCalledWith('row1-0', 'row1', 'name');
    });

    it('Enter does not activate disabled cell', async () => {
      const user = userEvent.setup();
      const onCellActivate = vi.fn();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createRowsWithDisabled()}
          ariaLabel="Users"
          onCellActivate={onCellActivate}
        />
      );

      const disabledCell = screen.getByRole('gridcell', { name: 'alice@example.com' });
      disabledCell.focus();

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

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

    it('Space does not select disabled cell', async () => {
      const user = userEvent.setup();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createRowsWithDisabled()}
          ariaLabel="Users"
          selectable
        />
      );

      const disabledCell = screen.getByRole('gridcell', { name: 'alice@example.com' });
      disabledCell.focus();

      await user.keyboard(' ');

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

    it('Ctrl+A selects all (multiselectable only)', async () => {
      const user = userEvent.setup();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
          multiselectable
        />
      );

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

      await user.keyboard('{Control>}a{/Control}');

      const cells = screen.getAllByRole('gridcell');
      cells.forEach((cell) => {
        expect(cell).toHaveAttribute('aria-selected', 'true');
      });
    });

    it('calls onSelectionChange callback', async () => {
      const user = userEvent.setup();
      const onSelectionChange = vi.fn();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
          onSelectionChange={onSelectionChange}
        />
      );

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

      await user.keyboard(' ');

      expect(onSelectionChange).toHaveBeenCalledWith(['row1-0']);
    });

    it('controlled selectedIds overrides internal state', () => {
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
          selectedIds={['row2-1']}
        />
      );

      const targetCell = screen.getByRole('gridcell', { name: 'bob@example.com' });
      expect(targetCell).toHaveAttribute('aria-selected', 'true');

      const otherCells = screen.getAllByRole('gridcell').filter((cell) => cell !== targetCell);
      otherCells.forEach((cell) => {
        expect(cell).toHaveAttribute('aria-selected', 'false');
      });
    });
  });

  // 🟡 Medium Priority: Virtualization Support
  describe('Virtualization Support', () => {
    it('has aria-rowcount when totalRows provided', () => {
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          totalRows={100}
        />
      );

      expect(screen.getByRole('grid')).toHaveAttribute('aria-rowcount', '100');
    });

    it('has aria-colcount when totalColumns provided', () => {
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          totalColumns={10}
        />
      );

      expect(screen.getByRole('grid')).toHaveAttribute('aria-colcount', '10');
    });

    it('has aria-rowindex on rows when virtualizing', () => {
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          totalRows={100}
          startRowIndex={10}
        />
      );

      const rows = screen.getAllByRole('row');
      // Skip header row (index 0), check data rows
      expect(rows[1]).toHaveAttribute('aria-rowindex', '10');
      expect(rows[2]).toHaveAttribute('aria-rowindex', '11');
      expect(rows[3]).toHaveAttribute('aria-rowindex', '12');
    });

    it('has aria-colindex on cells when virtualizing', () => {
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          totalColumns={10}
          startColIndex={5}
        />
      );

      const firstRowCells = screen.getAllByRole('gridcell').slice(0, 3);
      expect(firstRowCells[0]).toHaveAttribute('aria-colindex', '5');
      expect(firstRowCells[1]).toHaveAttribute('aria-colindex', '6');
      expect(firstRowCells[2]).toHaveAttribute('aria-colindex', '7');
    });
  });

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

    it('has no axe violations with selection enabled', async () => {
      const { container } = render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
          multiselectable
        />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });
});

リソース