APG Patterns
English
English

Data Grid

ソート、行選択、範囲選択、セル編集機能を備えた高度なインタラクティブデータグリッド。

デモ

Name
Email
Role
Status
Alice Johnson
alice@example.com
Admin
Active
Bob Smith
bob@example.com
Editor
Active
Charlie Brown
charlie@example.com
Viewer
Inactive
Diana Prince
diana@example.com
Admin
Active
Eve Wilson
eve@example.com
Editor
Active

Navigation: Arrow keys to navigate, Home/End for row bounds, Ctrl+Home/End for grid bounds.

Sorting: Click or press Enter/Space on a sortable column header to cycle sort direction.

Row Selection: Click checkboxes or press Space to select/deselect rows.

Editing: Press Enter or F2 on an editable cell (Role/Status, indicated by pen icon) to edit. Role uses combobox with autocomplete, Status uses select dropdown. Escape to cancel.

デモのみ表示 →

Data Grid vs Grid

Data Grid は基本的な Grid パターンを拡張し、データ操作のための追加機能を提供します。

機能 Grid Data Grid
2Dナビゲーション あり あり
セル選択 あり あり
列ソート なし あり(aria-sort)
行選択 なし あり(チェックボックス)
範囲選択 なし あり(Shift+矢印)
セル編集 なし あり(Enter/F2)
ヘッダーナビゲーション なし あり(ソート可能ヘッダー)

アクセシビリティ

WAI-ARIA ロール

ロール対象要素説明
gridコンテナグリッドとして要素を識別します。グリッドはセルの行を含みます。
row各行セルの行を識別します
gridcell各セルグリッド内のインタラクティブなセルを識別します
rowheader行ヘッダーセル行のヘッダーとしてセルを識別します
columnheader列ヘッダーセル列のヘッダーとしてセルを識別します

WAI-ARIA プロパティ

aria-rowcount

行が仮想化されている場合に必須

総行数
必須
いいえ

aria-colcount

列が非表示または仮想化されている場合に必須

総列数
必須
いいえ

aria-rowindex

行が仮想化されている場合に必須

グリッド内の行の位置
必須
いいえ

aria-colindex

列が非表示または仮想化されている場合に必須

グリッド内の列の位置
必須
いいえ

aria-sort

列のソート状態を示します

ascending | descending | none | other
必須
いいえ

aria-describedby

グリッドに関する追加のコンテキストを提供します

説明要素へのID参照
必須
いいえ

WAI-ARIA ステート

aria-selected

対象要素
gridcell または row
true | false
必須
いいえ
変更トリガー
クリック、Space、Ctrl/Cmd+クリック

aria-readonly

対象要素
grid または gridcell
true | false
必須
いいえ
変更トリガー
グリッド/セルの設定

aria-disabled

対象要素
grid、row、または gridcell
true | false
必須
いいえ
変更トリガー
グリッド/行/セルの状態変更

キーボードサポート

キーアクション
ArrowRightフォーカスを右に1セル移動します。末尾の場合は次の行に折り返します。
ArrowLeftフォーカスを左に1セル移動します。先頭の場合は前の行に折り返します。
ArrowDownフォーカスを下に1セル移動します。
ArrowUpフォーカスを上に1セル移動します。
Home行の最初のセルにフォーカスを移動します。
End行の最後のセルにフォーカスを移動します。
Ctrl + Homeグリッドの最初のセルにフォーカスを移動します。
Ctrl + Endグリッドの最後のセルにフォーカスを移動します。
Page Downフォーカスを1ページ下に移動します(実装依存)。
Page Upフォーカスを1ページ上に移動します(実装依存)。
Space / Enterセルをアクティブ化します(例:編集、選択)。
Escape編集モードをキャンセルまたは選択解除します。
  • テーブルがインタラクティブな場合にのみ role=“grid” を使用してください。静的なデータにはネイティブの <table> 要素を使用してください。
  • 効率的なキーボードナビゲーションのためにローヴィングタブインデックスが推奨されます。
  • フォーカスされているセルに視覚的なフォーカスインジケーターを提供することを検討してください。

フォーカス管理

イベント振る舞い
グリッドコンテナまたは最初のフォーカス可能なセルに tabindex="0"
フォーカス中のセルtabindex="0"
他のセルtabindex="-1"
セル内のインタラクティブなコンテンツEnterでセル内のコンテンツにフォーカス移動、Escapeで移動終了

参考資料

ソースコード

DataGrid.tsx
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

// =============================================================================
// Types
// =============================================================================

export type SortDirection = 'ascending' | 'descending' | 'none' | 'other';

// =============================================================================
// Helper Functions
// =============================================================================

/** Get sort indicator character based on sort direction */
function getSortIndicator(direction?: SortDirection): string {
  if (direction === 'ascending') return ' ▲';
  if (direction === 'descending') return ' ▼';
  return ' ⇅';
}
export type EditType = 'text' | 'select' | 'combobox';

export interface DataGridCellData {
  id: string;
  value: string | number;
  disabled?: boolean;
  colspan?: number;
  rowspan?: number;
  editable?: boolean;
  readonly?: boolean;
}

export interface DataGridColumnDef {
  id: string;
  header: string;
  sortable?: boolean;
  sortDirection?: SortDirection;
  colspan?: number;
  isRowLabel?: boolean; // This column provides accessible labels for row checkboxes
  editable?: boolean; // Column-level editable flag
  editType?: EditType; // Type of editor: text, select, or combobox
  options?: string[]; // Options for select/combobox
}

export interface DataGridRowData {
  id: string;
  cells: DataGridCellData[];
  hasRowHeader?: boolean;
  disabled?: boolean;
}

export interface DataGridProps {
  columns: DataGridColumnDef[];
  rows: DataGridRowData[];

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

  // Row Selection
  rowSelectable?: boolean;
  rowMultiselectable?: boolean;
  selectedRowIds?: string[];
  defaultSelectedRowIds?: string[];
  onRowSelectionChange?: (rowIds: string[]) => void;

  // Sorting
  onSort?: (columnId: string, direction: SortDirection) => void;

  // Range Selection
  enableRangeSelection?: boolean;
  onRangeSelect?: (cellIds: string[]) => void;

  // Cell Editing
  editable?: boolean;
  readonly?: boolean;
  editingCellId?: string | null;
  onEditStart?: (cellId: string, rowId: string, colId: string) => void;
  onEditEnd?: (cellId: string, value: string, cancelled: boolean) => void;
  onCellValueChange?: (cellId: string, newValue: string) => void;

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

  // Cell Selection (from Grid)
  selectable?: boolean;
  multiselectable?: boolean;
  selectedIds?: string[];
  defaultSelectedIds?: string[];
  onSelectionChange?: (selectedIds: string[]) => 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: DataGridCellData, rowId: string, colId: string) => React.ReactNode;

  // Styling
  className?: string;
}

// =============================================================================
// Helper Functions
// =============================================================================

function getNextSortDirection(current: SortDirection | undefined): SortDirection {
  switch (current) {
    case 'ascending':
      return 'descending';
    case 'descending':
      return 'ascending';
    case 'none':
    default:
      return 'ascending';
  }
}

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

export function DataGrid({
  columns,
  rows,
  ariaLabel,
  ariaLabelledby,
  rowSelectable = false,
  rowMultiselectable = false,
  selectedRowIds: controlledSelectedRowIds,
  defaultSelectedRowIds = [],
  onRowSelectionChange,
  onSort,
  enableRangeSelection = false,
  onRangeSelect,
  editable = false,
  readonly = false,
  editingCellId: controlledEditingCellId,
  onEditStart,
  onEditEnd,
  onCellValueChange,
  focusedId: controlledFocusedId,
  defaultFocusedId,
  onFocusChange,
  selectable = false,
  multiselectable = false,
  selectedIds: controlledSelectedIds,
  defaultSelectedIds = [],
  onSelectionChange,
  totalColumns,
  totalRows,
  startRowIndex = 1,
  startColIndex = 1,
  wrapNavigation = false,
  enablePageNavigation = false,
  pageSize = 5,
  onCellActivate,
  renderCell,
  className,
}: DataGridProps) {
  // ==========================================================================
  // State
  // ==========================================================================

  // Row selection
  const [internalSelectedRowIds, setInternalSelectedRowIds] =
    useState<string[]>(defaultSelectedRowIds);
  const selectedRowIds = controlledSelectedRowIds ?? internalSelectedRowIds;

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

  // Focus
  const [internalFocusedId, setInternalFocusedId] = useState<string | null>(() => {
    if (defaultFocusedId) return defaultFocusedId;
    // Default to first focusable item based on row selection mode
    // rowMultiselectable: header checkbox cell is first (Select all rows)
    // rowSelectable only: first row's checkbox cell
    // Otherwise: first data cell
    if (rowSelectable && rowMultiselectable) {
      return 'header-checkbox';
    }
    if (rowSelectable) {
      return rows[0] ? `checkbox-${rows[0].id}` : null;
    }
    return rows[0]?.cells[0]?.id ?? null;
  });
  const focusedId = controlledFocusedId !== undefined ? controlledFocusedId : internalFocusedId;

  // Edit mode
  const [internalEditingCellId, setInternalEditingCellId] = useState<string | null>(null);
  const editingCellId =
    controlledEditingCellId !== undefined ? controlledEditingCellId : internalEditingCellId;
  const [editValue, setEditValue] = useState<string>('');
  const [originalEditValue, setOriginalEditValue] = useState<string>('');
  const [editingColId, setEditingColId] = useState<string | null>(null);

  // Combobox state
  const [comboboxExpanded, setComboboxExpanded] = useState(false);
  const [comboboxActiveIndex, setComboboxActiveIndex] = useState(-1);
  const [filteredOptions, setFilteredOptions] = useState<string[]>([]);

  // Range selection anchor
  const [anchorCellId, setAnchorCellId] = useState<string | null>(null);

  // Ref to track if edit is being ended (to prevent double callback)
  const isEndingEditRef = useRef(false);

  const gridRef = useRef<HTMLDivElement>(null);
  const cellRefs = useRef<Map<string, HTMLDivElement>>(new Map());
  const headerRefs = useRef<Map<string, HTMLDivElement>>(new Map());
  const inputRef = useRef<HTMLInputElement>(null);
  const selectRef = useRef<HTMLSelectElement>(null);
  const listboxRef = useRef<HTMLUListElement>(null);

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

  // Check if we have sortable headers
  const hasSortableHeaders = useMemo(() => columns.some((col) => col.sortable), [columns]);

  // Check if header row has focusable items (sortable headers OR header checkbox)
  const hasHeaderFocusable = useMemo(
    () => hasSortableHeaders || (rowSelectable && rowMultiselectable),
    [hasSortableHeaders, rowSelectable, rowMultiselectable]
  );

  // Find the column that provides row labels (for aria-labelledby on row checkboxes)
  // Priority: 1. Column with isRowLabel: true, 2. First column (fallback)
  const rowLabelColumn = useMemo(() => {
    const labelColumn = columns.find((col) => col.isRowLabel);
    return labelColumn ?? columns[0];
  }, [columns]);

  // Build a flat list of focusable items (sortable headers + cells)
  const focusableItems = useMemo(() => {
    const items: Array<{
      id: string;
      type: 'header' | 'cell' | 'checkbox' | 'header-checkbox';
      rowIndex: number; // -1 for header
      colIndex: number;
      columnId?: string;
      rowId?: string;
      cell?: DataGridCellData;
      disabled?: boolean;
    }> = [];

    // Column offset when rowSelectable is enabled (checkbox column takes index 0)
    const colOffset = rowSelectable ? 1 : 0;

    // Header checkbox cell at row index -1, colIndex 0 (when rowMultiselectable)
    if (rowSelectable && rowMultiselectable) {
      items.push({
        id: 'header-checkbox',
        type: 'header-checkbox',
        rowIndex: -1,
        colIndex: 0,
      });
    }

    // Sortable headers at row index -1
    columns.forEach((col, colIndex) => {
      if (col.sortable) {
        items.push({
          id: `header-${col.id}`,
          type: 'header',
          rowIndex: -1,
          colIndex: colIndex + colOffset,
          columnId: col.id,
        });
      }
    });

    // Checkbox cells and data cells
    rows.forEach((row, rowIndex) => {
      // Add checkbox cell if row selection is enabled
      if (rowSelectable) {
        items.push({
          id: `checkbox-${row.id}`,
          type: 'checkbox',
          rowIndex,
          colIndex: 0,
          rowId: row.id,
          disabled: row.disabled,
        });
      }

      // Data cells
      row.cells.forEach((cell, colIndex) => {
        items.push({
          id: cell.id,
          type: 'cell',
          rowIndex,
          colIndex: colIndex + colOffset,
          rowId: row.id,
          columnId: columns[colIndex]?.id,
          cell,
          disabled: cell.disabled || row.disabled,
        });
      });
    });

    return items;
  }, [columns, rows, rowSelectable, rowMultiselectable]);

  // Map for quick lookup
  const itemById = useMemo(() => {
    const map = new Map<string, (typeof focusableItems)[0]>();
    focusableItems.forEach((item) => map.set(item.id, item));
    return map;
  }, [focusableItems]);

  // Get position of a cell/header
  const getItemPosition = useCallback(
    (id: string) => {
      const item = itemById.get(id);
      if (!item) return null;
      return { rowIndex: item.rowIndex, colIndex: item.colIndex };
    },
    [itemById]
  );

  // Get item at position
  const getItemAt = useCallback(
    (rowIndex: number, colIndex: number) => {
      if (rowIndex === -1) {
        // Header row - find header-checkbox or sortable header at this column
        return focusableItems.find(
          (item) =>
            (item.type === 'header' || item.type === 'header-checkbox') &&
            item.rowIndex === -1 &&
            item.colIndex === colIndex
        );
      }
      // Data row - find cell or checkbox at this position
      return focusableItems.find(
        (item) =>
          (item.type === 'cell' || item.type === 'checkbox') &&
          item.rowIndex === rowIndex &&
          item.colIndex === colIndex
      );
    },
    [focusableItems]
  );

  const getColumnCount = useCallback(
    () => columns.length + (rowSelectable ? 1 : 0),
    [columns, rowSelectable]
  );
  const getRowCount = useCallback(() => rows.length, [rows]);

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

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

  const focusItem = useCallback(
    (id: string) => {
      const item = itemById.get(id);
      if (!item) return;

      if (item.type === 'header') {
        const headerEl = headerRefs.current.get(item.columnId!);
        if (headerEl) {
          headerEl.focus();
          setFocusedId(id);
        }
      } else if (item.type === 'header-checkbox') {
        const cellEl = cellRefs.current.get(id);
        if (cellEl) {
          cellEl.focus();
          setFocusedId(id);
        }
      } else {
        const cellEl = cellRefs.current.get(id);
        if (cellEl) {
          cellEl.focus();
          setFocusedId(id);
        }
      }
    },
    [itemById, setFocusedId]
  );

  // Find next focusable item (skipping disabled cells)
  const findNextFocusable = useCallback(
    (
      startRowIndex: number,
      startColIndex: number,
      direction: 'right' | 'left' | 'up' | 'down',
      skipDisabled = true
    ): (typeof focusableItems)[0] | 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;
              } else {
                return false;
              }
            }
            break;
          case 'left':
            colIdx--;
            if (colIdx < 0) {
              if (wrapNavigation) {
                colIdx = colCount - 1;
                rowIdx--;
                // Allow going up to header row (-1) if header has focusable items
                if (rowIdx < (hasHeaderFocusable ? -1 : 0)) return false;
              } else {
                return false;
              }
            }
            break;
          case 'down':
            rowIdx++;
            if (rowIdx >= rowCount) return false;
            break;
          case 'up':
            rowIdx--;
            // Allow going up to header row (-1) if header has focusable items
            if (rowIdx < (hasHeaderFocusable ? -1 : 0)) return false;
            break;
        }
        return true;
      };

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

      // Find non-disabled item
      let iterations = 0;
      const maxIterations = colCount * (rowCount + 1);

      while (iterations < maxIterations) {
        const item = getItemAt(rowIdx, colIdx);
        if (item) {
          // For header row (-1), always allow (headers are not disabled)
          if (rowIdx === -1) {
            return item;
          }
          // For data cells, check disabled state
          if (!skipDisabled || !item.disabled) {
            return item;
          }
        }
        if (!step()) break;
        iterations++;
      }

      return null;
    },
    [getColumnCount, getRowCount, wrapNavigation, hasHeaderFocusable, getItemAt]
  );

  // ==========================================================================
  // Row Selection
  // ==========================================================================

  const setSelectedRowIds = useCallback(
    (ids: string[]) => {
      setInternalSelectedRowIds(ids);
      onRowSelectionChange?.(ids);
    },
    [onRowSelectionChange]
  );

  const toggleRowSelection = useCallback(
    (rowId: string, row: DataGridRowData) => {
      if (!rowSelectable || row.disabled) return;

      if (rowMultiselectable) {
        const newIds = selectedRowIds.includes(rowId)
          ? selectedRowIds.filter((id) => id !== rowId)
          : [...selectedRowIds, rowId];
        setSelectedRowIds(newIds);
      } else {
        const newIds = selectedRowIds.includes(rowId) ? [] : [rowId];
        setSelectedRowIds(newIds);
      }
    },
    [rowSelectable, rowMultiselectable, selectedRowIds, setSelectedRowIds]
  );

  // Toggle all row selection
  const toggleAllRowSelection = useCallback(() => {
    if (!rowSelectable || !rowMultiselectable) return;

    const allRowIds = rows.filter((r) => !r.disabled).map((r) => r.id);
    const allSelected = allRowIds.every((id) => selectedRowIds.includes(id));

    if (allSelected) {
      setSelectedRowIds([]);
    } else {
      setSelectedRowIds(allRowIds);
    }
  }, [rowSelectable, rowMultiselectable, rows, selectedRowIds, setSelectedRowIds]);

  // Get select all checkbox state
  const getSelectAllState = useCallback((): 'all' | 'some' | 'none' => {
    const allRowIds = rows.filter((r) => !r.disabled).map((r) => r.id);
    if (allRowIds.length === 0) return 'none';

    const selectedCount = allRowIds.filter((id) => selectedRowIds.includes(id)).length;
    if (selectedCount === 0) return 'none';
    if (selectedCount === allRowIds.length) return 'all';
    return 'some';
  }, [rows, selectedRowIds]);

  // ==========================================================================
  // Cell Selection
  // ==========================================================================

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

  const toggleSelection = useCallback(
    (cellId: string, cell: DataGridCellData) => {
      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 = focusableItems
      .filter((item) => item.type === 'cell' && !item.disabled)
      .map((item) => item.id);
    setSelectedIds(allIds);
  }, [selectable, multiselectable, focusableItems, setSelectedIds]);

  // ==========================================================================
  // Range Selection
  // ==========================================================================

  const getCellsInRange = useCallback(
    (startId: string, endId: string): string[] => {
      const startItem = itemById.get(startId);
      const endItem = itemById.get(endId);
      if (!startItem || !endItem || startItem.type === 'header' || endItem.type === 'header') {
        return [];
      }

      const minRow = Math.min(startItem.rowIndex, endItem.rowIndex);
      const maxRow = Math.max(startItem.rowIndex, endItem.rowIndex);
      const minCol = Math.min(startItem.colIndex, endItem.colIndex);
      const maxCol = Math.max(startItem.colIndex, endItem.colIndex);

      const cellIds: string[] = [];
      for (let r = minRow; r <= maxRow; r++) {
        for (let c = minCol; c <= maxCol; c++) {
          const item = getItemAt(r, c);
          if (item && item.type === 'cell' && !item.disabled) {
            cellIds.push(item.id);
          }
        }
      }
      return cellIds;
    },
    [itemById, getItemAt]
  );

  const extendRangeSelection = useCallback(
    (currentCellId: string, newFocusId: string) => {
      if (!enableRangeSelection) return;

      // If no anchor yet, use the current cell (before movement) as anchor
      const anchor = anchorCellId ?? currentCellId;
      if (!anchorCellId) {
        setAnchorCellId(currentCellId);
      }

      const cellIds = getCellsInRange(anchor, newFocusId);
      onRangeSelect?.(cellIds);
    },
    [enableRangeSelection, anchorCellId, getCellsInRange, onRangeSelect]
  );

  // ==========================================================================
  // Sorting
  // ==========================================================================

  const handleSort = useCallback(
    (columnId: string) => {
      const column = columns.find((col) => col.id === columnId);
      if (!column?.sortable || !onSort) return;

      const nextDirection = getNextSortDirection(column.sortDirection);
      onSort(columnId, nextDirection);
    },
    [columns, onSort]
  );

  // ==========================================================================
  // Cell Editing
  // ==========================================================================

  // Helper to check if a cell is editable (cell-level or column-level)
  const isCellEditable = useCallback(
    (cell: DataGridCellData, colId: string) => {
      if (cell.readonly) return false;
      // Cell-level editable takes priority
      if (cell.editable !== undefined) return cell.editable;
      // Column-level editable
      const column = columns.find((col) => col.id === colId);
      return column?.editable ?? false;
    },
    [columns]
  );

  // Helper to get column's editType
  const getColumnEditType = useCallback(
    (colId: string): EditType => {
      const column = columns.find((col) => col.id === colId);
      return column?.editType ?? 'text';
    },
    [columns]
  );

  // Helper to get column's options
  const getColumnOptions = useCallback(
    (colId: string): string[] => {
      const column = columns.find((col) => col.id === colId);
      return column?.options ?? [];
    },
    [columns]
  );

  const startEdit = useCallback(
    (cellId: string, rowId: string, colId: string) => {
      if (!editable || readonly) return;

      const item = itemById.get(cellId);
      if (!item || item.type === 'header' || !item.cell) return;

      // Check if cell is editable (cell-level or column-level)
      if (!isCellEditable(item.cell, colId)) return;

      const value = String(item.cell.value);
      setOriginalEditValue(value);
      setEditValue(value);
      setEditingColId(colId);
      setInternalEditingCellId(cellId);

      // Initialize combobox state if editType is combobox
      const editType = getColumnEditType(colId);
      if (editType === 'combobox') {
        const options = getColumnOptions(colId);
        setFilteredOptions(options);
        setComboboxExpanded(true);
        setComboboxActiveIndex(-1);
      }

      onEditStart?.(cellId, rowId, colId);
    },
    [editable, readonly, itemById, isCellEditable, getColumnEditType, getColumnOptions, onEditStart]
  );

  const endEdit = useCallback(
    (cellId: string, cancelled: boolean, explicitValue?: string) => {
      // Guard: prevent double callback using ref
      if (isEndingEditRef.current) return;
      // Guard: only end edit if we're currently editing this cell
      if (internalEditingCellId !== cellId) return;

      isEndingEditRef.current = true;
      // Use explicit value if provided (for combobox/select option clicks),
      // otherwise fall back to current editValue state
      const finalValue = cancelled ? originalEditValue : (explicitValue ?? editValue);
      setInternalEditingCellId(null);
      setEditingColId(null);
      setComboboxExpanded(false);
      setComboboxActiveIndex(-1);
      onEditEnd?.(cellId, finalValue, cancelled);

      // Focus back to cell
      const cellEl = cellRefs.current.get(cellId);
      if (cellEl) {
        cellEl.focus();
      }

      // Reset the flag after the current event loop
      setTimeout(() => {
        isEndingEditRef.current = false;
      }, 0);
    },
    [editValue, originalEditValue, onEditEnd, internalEditingCellId]
  );

  // ==========================================================================
  // Keyboard Handling - Header
  // ==========================================================================

  const handleHeaderKeyDown = useCallback(
    (event: React.KeyboardEvent, column: DataGridColumnDef) => {
      const pos = getItemPosition(`header-${column.id}`);
      if (!pos) return;

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

      let handled = true;

      switch (key) {
        case 'ArrowRight': {
          // colIndex includes colOffset, so we need to adjust for columns array access
          const colOffset = rowSelectable ? 1 : 0;
          // Find next sortable header or wrap to data if none
          let nextColIdx = colIndex - colOffset + 1;
          while (nextColIdx < columns.length) {
            if (columns[nextColIdx].sortable) {
              focusItem(`header-${columns[nextColIdx].id}`);
              return (event.preventDefault(), event.stopPropagation());
            }
            nextColIdx++;
          }
          // No more sortable headers to the right, stay at current
          handled = false;
          break;
        }
        case 'ArrowLeft': {
          // colIndex includes colOffset, so we need to adjust for columns array access
          const colOffset = rowSelectable ? 1 : 0;
          let prevColIdx = colIndex - colOffset - 1;
          while (prevColIdx >= 0) {
            if (columns[prevColIdx].sortable) {
              focusItem(`header-${columns[prevColIdx].id}`);
              return (event.preventDefault(), event.stopPropagation());
            }
            prevColIdx--;
          }
          // No more sortable headers to the left, try header checkbox
          if (rowMultiselectable) {
            focusItem('header-checkbox');
            break;
          }
          handled = false;
          break;
        }
        case 'ArrowDown': {
          // Move to first data row, same column
          // colIndex includes colOffset, but rows[].cells[] doesn't include checkbox column
          const colOffset = rowSelectable ? 1 : 0;
          const cellColIndex = colIndex - colOffset;
          const firstRowCell = rows[0]?.cells[cellColIndex];
          if (firstRowCell) {
            focusItem(firstRowCell.id);
          }
          break;
        }
        case 'Home': {
          if (ctrlKey) {
            // Ctrl+Home: Go to first sortable header or first cell
            const firstSortable = columns.find((col) => col.sortable);
            if (firstSortable) {
              focusItem(`header-${firstSortable.id}`);
            } else {
              const firstCell = rows[0]?.cells[0];
              if (firstCell) focusItem(firstCell.id);
            }
          } else {
            // Home: First sortable header in row
            const firstSortable = columns.find((col) => col.sortable);
            if (firstSortable) {
              focusItem(`header-${firstSortable.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) focusItem(lastCell.id);
          } else {
            // End: Last sortable header in row
            const lastSortable = [...columns].reverse().find((col) => col.sortable);
            if (lastSortable) {
              focusItem(`header-${lastSortable.id}`);
            }
          }
          break;
        }
        case 'Enter':
        case ' ': {
          if (column.sortable) {
            handleSort(column.id);
          }
          break;
        }
        default:
          handled = false;
      }

      if (handled) {
        event.preventDefault();
        event.stopPropagation();
      }
    },
    [columns, rows, getItemPosition, focusItem, handleSort, rowMultiselectable]
  );

  // ==========================================================================
  // Keyboard Handling - Header Checkbox Cell
  // ==========================================================================

  const handleHeaderCheckboxKeyDown = useCallback(
    (event: React.KeyboardEvent) => {
      const { key, ctrlKey } = event;
      let handled = true;

      switch (key) {
        case 'ArrowRight': {
          // Move to first sortable header if exists
          const firstSortable = columns.find((col) => col.sortable);
          if (firstSortable) {
            focusItem(`header-${firstSortable.id}`);
          }
          break;
        }
        case 'ArrowLeft': {
          // Already at leftmost position
          handled = false;
          break;
        }
        case 'ArrowDown': {
          // Move to first data row checkbox
          if (rows[0]) {
            focusItem(`checkbox-${rows[0].id}`);
          }
          break;
        }
        case 'ArrowUp': {
          // Already at top row
          handled = false;
          break;
        }
        case 'Home': {
          // Already at home position for header row
          if (ctrlKey) {
            // Stay at current position (first cell in grid)
          }
          break;
        }
        case 'End': {
          if (ctrlKey) {
            // Go to last cell in grid
            const lastRow = rows[rows.length - 1];
            const lastCell = lastRow?.cells[lastRow.cells.length - 1];
            if (lastCell) focusItem(lastCell.id);
          } else {
            // Go to last sortable header or stay
            const lastSortable = [...columns].reverse().find((col) => col.sortable);
            if (lastSortable) {
              focusItem(`header-${lastSortable.id}`);
            }
          }
          break;
        }
        case ' ':
        case 'Enter': {
          toggleAllRowSelection();
          break;
        }
        default:
          handled = false;
      }

      if (handled) {
        event.preventDefault();
        event.stopPropagation();
      }
    },
    [columns, rows, focusItem, toggleAllRowSelection]
  );

  // ==========================================================================
  // Keyboard Handling - Cell
  // ==========================================================================

  const handleCellKeyDown = useCallback(
    (event: React.KeyboardEvent, cell: DataGridCellData, rowId: string, colId: string) => {
      // If in edit mode, handle differently
      if (editingCellId === cell.id) {
        if (event.key === 'Escape') {
          event.preventDefault();
          event.stopPropagation();
          endEdit(cell.id, true);
        }
        // Let other keys work normally in input
        return;
      }

      const pos = getItemPosition(cell.id);
      if (!pos) return;

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

      let handled = true;

      switch (key) {
        case 'ArrowRight': {
          if (shiftKey && enableRangeSelection) {
            const next = findNextFocusable(rowIndex, colIndex, 'right');
            if (next) {
              focusItem(next.id);
              extendRangeSelection(cell.id, next.id);
            }
          } else {
            const next = findNextFocusable(rowIndex, colIndex, 'right');
            if (next) {
              focusItem(next.id);
              setAnchorCellId(null);
            }
          }
          break;
        }
        case 'ArrowLeft': {
          if (shiftKey && enableRangeSelection) {
            const next = findNextFocusable(rowIndex, colIndex, 'left');
            if (next) {
              focusItem(next.id);
              extendRangeSelection(cell.id, next.id);
            }
          } else {
            const next = findNextFocusable(rowIndex, colIndex, 'left');
            if (next) {
              focusItem(next.id);
              setAnchorCellId(null);
            }
          }
          break;
        }
        case 'ArrowDown': {
          if (shiftKey && enableRangeSelection) {
            const next = findNextFocusable(rowIndex, colIndex, 'down');
            if (next) {
              focusItem(next.id);
              extendRangeSelection(cell.id, next.id);
            }
          } else {
            const next = findNextFocusable(rowIndex, colIndex, 'down');
            if (next) {
              focusItem(next.id);
              setAnchorCellId(null);
            }
          }
          break;
        }
        case 'ArrowUp': {
          if (shiftKey && enableRangeSelection) {
            const next = findNextFocusable(rowIndex, colIndex, 'up');
            if (next) {
              focusItem(next.id);
              extendRangeSelection(cell.id, next.id);
            }
          } else {
            const next = findNextFocusable(rowIndex, colIndex, 'up');
            if (next) {
              focusItem(next.id);
              setAnchorCellId(null);
            }
          }
          break;
        }
        case 'Home': {
          if (ctrlKey && shiftKey && enableRangeSelection) {
            // Ctrl+Shift+Home: extend selection to grid start
            const firstCell = rows[0]?.cells[0];
            if (firstCell) {
              focusItem(firstCell.id);
              extendRangeSelection(cell.id, firstCell.id);
            }
          } else if (ctrlKey) {
            // Ctrl+Home: Go to first cell in grid
            const firstCell = rows[0]?.cells[0];
            if (firstCell) {
              focusItem(firstCell.id);
              setAnchorCellId(null);
            }
          } else if (shiftKey && enableRangeSelection) {
            // Shift+Home: extend selection to row start
            const firstCellInRow = rows[rowIndex]?.cells[0];
            if (firstCellInRow) {
              focusItem(firstCellInRow.id);
              extendRangeSelection(cell.id, firstCellInRow.id);
            }
          } else {
            // Home: Go to first cell in row
            const firstCellInRow = rows[rowIndex]?.cells[0];
            if (firstCellInRow) {
              focusItem(firstCellInRow.id);
              setAnchorCellId(null);
            }
          }
          break;
        }
        case 'End': {
          const currentRow = rows[rowIndex];
          const lastRow = rows[rows.length - 1];

          if (ctrlKey && shiftKey && enableRangeSelection) {
            // Ctrl+Shift+End: extend selection to grid end
            const lastCell = lastRow?.cells[lastRow.cells.length - 1];
            if (lastCell) {
              focusItem(lastCell.id);
              extendRangeSelection(cell.id, lastCell.id);
            }
          } else if (ctrlKey) {
            // Ctrl+End: Go to last cell in grid
            const lastCell = lastRow?.cells[lastRow.cells.length - 1];
            if (lastCell) {
              focusItem(lastCell.id);
              setAnchorCellId(null);
            }
          } else if (shiftKey && enableRangeSelection) {
            // Shift+End: extend selection to row end
            const lastCellInRow = currentRow?.cells[currentRow.cells.length - 1];
            if (lastCellInRow) {
              focusItem(lastCellInRow.id);
              extendRangeSelection(cell.id, lastCellInRow.id);
            }
          } else {
            // End: Go to last cell in row
            const lastCellInRow = currentRow?.cells[currentRow.cells.length - 1];
            if (lastCellInRow) {
              focusItem(lastCellInRow.id);
              setAnchorCellId(null);
            }
          }
          break;
        }
        case 'PageDown': {
          if (enablePageNavigation) {
            const targetRowIndex = Math.min(rowIndex + pageSize, rows.length - 1);
            const targetCell = rows[targetRowIndex]?.cells[colIndex];
            if (targetCell) {
              focusItem(targetCell.id);
              setAnchorCellId(null);
            }
          } else {
            handled = false;
          }
          break;
        }
        case 'PageUp': {
          if (enablePageNavigation) {
            const targetRowIndex = Math.max(rowIndex - pageSize, 0);
            const targetCell = rows[targetRowIndex]?.cells[colIndex];
            if (targetCell) {
              focusItem(targetCell.id);
              setAnchorCellId(null);
            }
          } else {
            handled = false;
          }
          break;
        }
        case ' ': {
          if (selectable) {
            toggleSelection(cell.id, cell);
          }
          break;
        }
        case 'Enter': {
          if (editable && isCellEditable(cell, colId) && !cell.disabled) {
            startEdit(cell.id, rowId, colId);
          } else if (!cell.disabled) {
            onCellActivate?.(cell.id, rowId, colId);
          }
          break;
        }
        case 'F2': {
          if (editable && isCellEditable(cell, colId) && !cell.disabled) {
            startEdit(cell.id, rowId, colId);
          }
          break;
        }
        case 'a': {
          if (ctrlKey) {
            selectAll();
          } else {
            handled = false;
          }
          break;
        }
        default:
          handled = false;
      }

      if (handled) {
        event.preventDefault();
        event.stopPropagation();
      }
    },
    [
      editingCellId,
      endEdit,
      getItemPosition,
      findNextFocusable,
      focusItem,
      enableRangeSelection,
      extendRangeSelection,
      rows,
      enablePageNavigation,
      pageSize,
      selectable,
      toggleSelection,
      editable,
      isCellEditable,
      startEdit,
      onCellActivate,
      selectAll,
    ]
  );

  // ==========================================================================
  // Keyboard Handling - Checkbox Cell
  // ==========================================================================

  const handleCheckboxCellKeyDown = useCallback(
    (event: React.KeyboardEvent, rowId: string, row: DataGridRowData) => {
      const checkboxCellId = `checkbox-${rowId}`;
      const pos = getItemPosition(checkboxCellId);
      if (!pos) return;

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

      let handled = true;

      switch (key) {
        case 'ArrowRight': {
          const next = findNextFocusable(rowIndex, colIndex, 'right');
          if (next) {
            focusItem(next.id);
          }
          break;
        }
        case 'ArrowLeft': {
          const next = findNextFocusable(rowIndex, colIndex, 'left');
          if (next) {
            focusItem(next.id);
          }
          break;
        }
        case 'ArrowDown': {
          const next = findNextFocusable(rowIndex, colIndex, 'down');
          if (next) {
            focusItem(next.id);
          }
          break;
        }
        case 'ArrowUp': {
          const next = findNextFocusable(rowIndex, colIndex, 'up');
          if (next) {
            focusItem(next.id);
          }
          break;
        }
        case 'Home': {
          if (ctrlKey) {
            // Ctrl+Home: Go to first cell in grid (first checkbox cell)
            const firstCheckboxId = `checkbox-${rows[0]?.id}`;
            if (firstCheckboxId) {
              focusItem(firstCheckboxId);
            }
          } else {
            // Home: Stay on checkbox (it's the first cell in the row)
            // Do nothing, already at home position
          }
          break;
        }
        case 'End': {
          const currentRow = rows[rowIndex];
          const lastRow = rows[rows.length - 1];

          if (ctrlKey) {
            // Ctrl+End: Go to last cell in grid
            const lastCell = lastRow?.cells[lastRow.cells.length - 1];
            if (lastCell) {
              focusItem(lastCell.id);
            }
          } else {
            // End: Go to last cell in row
            const lastCellInRow = currentRow?.cells[currentRow.cells.length - 1];
            if (lastCellInRow) {
              focusItem(lastCellInRow.id);
            }
          }
          break;
        }
        case ' ':
        case 'Enter': {
          // Toggle row selection
          if (!row.disabled) {
            toggleRowSelection(rowId, row);
          }
          break;
        }
        default:
          handled = false;
      }

      if (handled) {
        event.preventDefault();
        event.stopPropagation();
      }
    },
    [getItemPosition, findNextFocusable, focusItem, rows, toggleRowSelection]
  );

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

  // Focus input/select when entering edit mode
  useEffect(() => {
    if (editingCellId && editingColId) {
      const editType = getColumnEditType(editingColId);
      if (editType === 'select' && selectRef.current) {
        selectRef.current.focus();
      } else if (inputRef.current) {
        inputRef.current.focus();
        inputRef.current.select();
      }
    }
  }, [editingCellId, editingColId, getColumnEditType]);

  // Focus the focused cell when focusedId changes externally
  useEffect(() => {
    if (focusedId && !editingCellId) {
      const item = itemById.get(focusedId);
      if (item) {
        if (item.type === 'header') {
          const headerEl = headerRefs.current.get(item.columnId!);
          if (headerEl && document.activeElement !== headerEl) {
            if (gridRef.current?.contains(document.activeElement)) {
              headerEl.focus();
            }
          }
        } else {
          const cellEl = cellRefs.current.get(focusedId);
          if (cellEl && document.activeElement !== cellEl) {
            if (gridRef.current?.contains(document.activeElement)) {
              cellEl.focus();
            }
          }
        }
      }
    }
  }, [focusedId, editingCellId, itemById]);

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

  // Determine aria-multiselectable
  const showMultiselectable = rowMultiselectable || multiselectable;

  // CSS variable for grid column count
  const gridStyle: Record<string, string | number> = {
    '--apg-data-grid-columns': columns.length,
  };

  return (
    <div
      ref={gridRef}
      role="grid"
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      aria-multiselectable={showMultiselectable ? 'true' : undefined}
      aria-readonly={readonly ? 'true' : undefined}
      aria-rowcount={totalRows}
      aria-colcount={totalColumns}
      className={`apg-data-grid ${className ?? ''}`}
      style={gridStyle}
    >
      {/* Header Row */}
      <div role="row" aria-rowindex={totalRows ? 1 : undefined}>
        {rowSelectable &&
          (() => {
            const isHeaderCheckboxFocused = focusedId === 'header-checkbox';
            return (
              <div
                ref={(el) => {
                  if (el) {
                    cellRefs.current.set('header-checkbox', el);
                  } else {
                    cellRefs.current.delete('header-checkbox');
                  }
                }}
                role="columnheader"
                tabIndex={rowMultiselectable ? (isHeaderCheckboxFocused ? 0 : -1) : undefined}
                aria-colindex={totalColumns ? startColIndex : undefined}
                className={`apg-data-grid-header apg-data-grid-checkbox-cell ${isHeaderCheckboxFocused ? 'focused' : ''}`}
                onKeyDown={rowMultiselectable ? handleHeaderCheckboxKeyDown : undefined}
                onFocus={() => rowMultiselectable && setFocusedId('header-checkbox')}
              >
                {rowMultiselectable && (
                  <input
                    type="checkbox"
                    tabIndex={-1}
                    checked={getSelectAllState() === 'all'}
                    ref={(el) => {
                      if (el) {
                        el.indeterminate = getSelectAllState() === 'some';
                      }
                    }}
                    aria-label="Select all rows"
                    onChange={(e) => {
                      e.stopPropagation();
                      toggleAllRowSelection();
                    }}
                  />
                )}
              </div>
            );
          })()}
        {columns.map((col, colIndex) => {
          const isSortable = col.sortable;
          const headerId = `header-${col.id}`;
          const isFocused = focusedId === headerId;

          return (
            <div
              key={col.id}
              ref={(el) => {
                if (el) {
                  headerRefs.current.set(col.id, el);
                } else {
                  headerRefs.current.delete(col.id);
                }
              }}
              role="columnheader"
              tabIndex={isSortable ? (isFocused ? 0 : -1) : undefined}
              aria-colindex={
                totalColumns ? startColIndex + colIndex + (rowSelectable ? 1 : 0) : undefined
              }
              aria-colspan={col.colspan}
              aria-sort={isSortable ? col.sortDirection || 'none' : undefined}
              onKeyDown={(e) => isSortable && handleHeaderKeyDown(e, col)}
              onFocus={() => isSortable && setFocusedId(headerId)}
              onClick={() => isSortable && handleSort(col.id)}
              className={`apg-data-grid-header ${isSortable ? 'sortable' : ''} ${isFocused ? 'focused' : ''}`}
            >
              {col.header}
              {isSortable && (
                <span
                  aria-hidden="true"
                  className={`sort-indicator ${!col.sortDirection || col.sortDirection === 'none' ? 'unsorted' : ''}`}
                >
                  {getSortIndicator(col.sortDirection)}
                </span>
              )}
            </div>
          );
        })}
      </div>

      {/* Data Rows */}
      {rows.map((row, rowIndex) => {
        const isRowSelected = selectedRowIds.includes(row.id);
        const isRowDisabled = row.disabled;

        return (
          <div
            key={row.id}
            role="row"
            aria-rowindex={totalRows ? startRowIndex + rowIndex : undefined}
            aria-selected={rowSelectable ? (isRowSelected ? 'true' : 'false') : undefined}
            aria-disabled={isRowDisabled ? 'true' : undefined}
          >
            {/* Row selection checkbox */}
            {rowSelectable &&
              (() => {
                const checkboxCellId = `checkbox-${row.id}`;
                const isCheckboxFocused = focusedId === checkboxCellId;
                return (
                  <div
                    ref={(el) => {
                      if (el) {
                        cellRefs.current.set(checkboxCellId, el);
                      } else {
                        cellRefs.current.delete(checkboxCellId);
                      }
                    }}
                    role="gridcell"
                    tabIndex={isCheckboxFocused ? 0 : -1}
                    aria-colindex={totalColumns ? startColIndex : undefined}
                    className={`apg-data-grid-cell apg-data-grid-checkbox-cell ${isCheckboxFocused ? 'focused' : ''}`}
                    onKeyDown={(e) => handleCheckboxCellKeyDown(e, row.id, row)}
                    onFocus={() => setFocusedId(checkboxCellId)}
                  >
                    <input
                      type="checkbox"
                      tabIndex={-1}
                      checked={isRowSelected}
                      disabled={isRowDisabled}
                      aria-labelledby={
                        rowLabelColumn ? `cell-${row.id}-${rowLabelColumn.id}` : undefined
                      }
                      onChange={(e) => {
                        e.stopPropagation();
                        toggleRowSelection(row.id, row);
                      }}
                    />
                  </div>
                );
              })()}

            {/* Data cells */}
            {row.cells.map((cell, colIndex) => {
              const isRowHeader = row.hasRowHeader && colIndex === 0;
              const cellId = cell.id;
              const isFocused = focusedId === cellId;
              const isCellSelected = selectedIds.includes(cellId);
              const colId = columns[colIndex]?.id ?? '';
              const isDisabled = cell.disabled || isRowDisabled;
              const isEditing = editingCellId === cellId;
              const cellIsEditable = editable && isCellEditable(cell, colId) && !isDisabled;
              const editType = getColumnEditType(colId);
              const columnOptions = getColumnOptions(colId);

              // Determine aria-readonly for this cell
              // APG: In editable grids, non-editable cells should have aria-readonly="true"
              const getAriaReadonly = (): 'true' | 'false' | undefined => {
                if (!editable) return undefined;
                if (cell.readonly === true) return 'true';
                if (cellIsEditable) return 'false';
                return 'true'; // Non-editable cell in editable grid
              };
              const showAriaReadonly = getAriaReadonly();

              // Generate id for label column cell to be referenced by row checkbox aria-labelledby
              const isLabelColumn = rowLabelColumn && colId === rowLabelColumn.id;
              const labelCellId = isLabelColumn ? `cell-${row.id}-${colId}` : undefined;

              // Unique IDs for combobox ARIA
              const comboboxListId = `${cellId}-listbox`;

              // Render edit content based on editType
              const renderEditContent = () => {
                if (editType === 'select') {
                  return (
                    <select
                      ref={selectRef}
                      value={editValue}
                      onChange={(e) => {
                        const newValue = e.target.value;
                        setEditValue(newValue);
                        onCellValueChange?.(cellId, newValue);
                        // End edit immediately with explicit value
                        endEdit(cellId, false, newValue);
                      }}
                      onBlur={() => endEdit(cellId, false)}
                      onKeyDown={(e) => {
                        if (e.key === 'Escape') {
                          e.preventDefault();
                          e.stopPropagation();
                          endEdit(cellId, true);
                        } else if (e.key === 'Enter') {
                          e.preventDefault();
                          e.stopPropagation();
                          endEdit(cellId, false);
                        }
                      }}
                      className="apg-data-grid-select"
                    >
                      {columnOptions.map((option) => (
                        <option key={option} value={option}>
                          {option}
                        </option>
                      ))}
                    </select>
                  );
                }

                if (editType === 'combobox') {
                  return (
                    <div className="apg-data-grid-combobox">
                      <input
                        ref={inputRef}
                        type="text"
                        role="combobox"
                        aria-expanded={comboboxExpanded}
                        aria-controls={comboboxListId}
                        aria-autocomplete="list"
                        aria-activedescendant={
                          comboboxActiveIndex >= 0
                            ? `${cellId}-option-${comboboxActiveIndex}`
                            : undefined
                        }
                        value={editValue}
                        onChange={(e) => {
                          const newValue = e.target.value;
                          setEditValue(newValue);
                          onCellValueChange?.(cellId, newValue);
                          // Filter options based on input
                          const filtered = columnOptions.filter((opt) =>
                            opt.toLowerCase().includes(newValue.toLowerCase())
                          );
                          setFilteredOptions(filtered);
                          setComboboxExpanded(true);
                          setComboboxActiveIndex(-1);
                        }}
                        onBlur={(e) => {
                          // Check if focus is moving to listbox
                          if (
                            e.relatedTarget instanceof Node &&
                            listboxRef.current?.contains(e.relatedTarget)
                          ) {
                            return;
                          }
                          setComboboxExpanded(false);
                          endEdit(cellId, false);
                        }}
                        onKeyDown={(e) => {
                          if (e.key === 'Escape') {
                            e.preventDefault();
                            e.stopPropagation();
                            setComboboxExpanded(false);
                            endEdit(cellId, true);
                          } else if (e.key === 'Enter') {
                            e.preventDefault();
                            e.stopPropagation();
                            const selectedOption =
                              comboboxActiveIndex >= 0
                                ? filteredOptions[comboboxActiveIndex]
                                : undefined;
                            if (selectedOption) {
                              setEditValue(selectedOption);
                              onCellValueChange?.(cellId, selectedOption);
                            }
                            setComboboxExpanded(false);
                            endEdit(cellId, false, selectedOption);
                          } else if (e.key === 'ArrowDown') {
                            e.preventDefault();
                            if (!comboboxExpanded) {
                              setComboboxExpanded(true);
                            } else {
                              setComboboxActiveIndex((prev) =>
                                Math.min(prev + 1, filteredOptions.length - 1)
                              );
                            }
                          } else if (e.key === 'ArrowUp') {
                            e.preventDefault();
                            setComboboxActiveIndex((prev) => Math.max(prev - 1, -1));
                          }
                        }}
                        className="apg-data-grid-input"
                      />
                      {comboboxExpanded && filteredOptions.length > 0 && (
                        <ul
                          ref={listboxRef}
                          id={comboboxListId}
                          role="listbox"
                          className="apg-data-grid-listbox"
                        >
                          {filteredOptions.map((option, index) => (
                            <li
                              key={option}
                              id={`${cellId}-option-${index}`}
                              role="option"
                              aria-selected={index === comboboxActiveIndex}
                              className={`apg-data-grid-option ${index === comboboxActiveIndex ? 'active' : ''}`}
                              onMouseDown={(e) => {
                                e.preventDefault();
                                setEditValue(option);
                                onCellValueChange?.(cellId, option);
                                setComboboxExpanded(false);
                                endEdit(cellId, false, option);
                              }}
                            >
                              {option}
                            </li>
                          ))}
                        </ul>
                      )}
                    </div>
                  );
                }

                // Default: text input
                return (
                  <input
                    ref={inputRef}
                    type="text"
                    value={editValue}
                    onChange={(e) => {
                      setEditValue(e.target.value);
                      onCellValueChange?.(cellId, e.target.value);
                    }}
                    onBlur={() => endEdit(cellId, false)}
                    onKeyDown={(e) => {
                      if (e.key === 'Escape') {
                        e.preventDefault();
                        e.stopPropagation();
                        endEdit(cellId, true);
                      } else if (e.key === 'Enter') {
                        e.preventDefault();
                        e.stopPropagation();
                        endEdit(cellId, false);
                      }
                    }}
                    className="apg-data-grid-input"
                  />
                );
              };

              return (
                <div
                  key={cellId}
                  id={labelCellId}
                  ref={(el) => {
                    if (el) {
                      cellRefs.current.set(cellId, el);
                    } else {
                      cellRefs.current.delete(cellId);
                    }
                  }}
                  role={isRowHeader ? 'rowheader' : 'gridcell'}
                  tabIndex={isFocused && !isEditing ? 0 : -1}
                  aria-selected={
                    selectable && !rowSelectable ? (isCellSelected ? 'true' : 'false') : undefined
                  }
                  aria-disabled={isDisabled ? 'true' : undefined}
                  aria-colindex={
                    totalColumns ? startColIndex + colIndex + (rowSelectable ? 1 : 0) : undefined
                  }
                  aria-colspan={cell.colspan}
                  aria-rowspan={cell.rowspan}
                  aria-readonly={showAriaReadonly}
                  onKeyDown={(e) => handleCellKeyDown(e, cell, row.id, colId)}
                  onFocus={() => !isEditing && setFocusedId(cellId)}
                  onDoubleClick={() => cellIsEditable && startEdit(cellId, row.id, colId)}
                  className={`apg-data-grid-cell ${isFocused ? 'focused' : ''} ${isCellSelected ? 'selected' : ''} ${isDisabled ? 'disabled' : ''} ${isEditing ? 'editing' : ''} ${cellIsEditable && !isEditing ? 'editable' : ''}`}
                >
                  {(() => {
                    if (isEditing) return renderEditContent();
                    if (renderCell) return renderCell(cell, row.id, colId);
                    return cell.value;
                  })()}
                </div>
              );
            })}
          </div>
        );
      })}
    </div>
  );
}

export default DataGrid;

使い方

Example
import { DataGrid } from './DataGrid';
import type { DataGridColumnDef, DataGridRowData, SortDirection } from './DataGrid';

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

const rows: DataGridRowData[] = [
  {
    id: 'user1',
    cells: [
      { id: 'user1-name', value: 'Alice Johnson', editable: true },
      { id: 'user1-email', value: 'alice@example.com', editable: true },
      { id: 'user1-role', value: 'Admin' },
    ],
  },
  {
    id: 'user2',
    cells: [
      { id: 'user2-name', value: 'Bob Smith', editable: true },
      { id: 'user2-email', value: 'bob@example.com', editable: true },
      { id: 'user2-role', value: 'User' },
    ],
  },
];

// Basic Data Grid with sorting
<DataGrid
  columns={columns}
  rows={rows}
  ariaLabel="User list"
  onSort={(columnId, direction) => handleSort(columnId, direction)}
/>

// With row selection
<DataGrid
  columns={columns}
  rows={rows}
  ariaLabel="User list"
  rowSelectable
  rowMultiselectable
  selectedRowIds={selectedRowIds}
  onRowSelectionChange={(ids) => setSelectedRowIds(ids)}
/>

// With range selection and editing
<DataGrid
  columns={columns}
  rows={rows}
  ariaLabel="User list"
  enableRangeSelection
  editable
  onEditEnd={(cellId, value, cancelled) => {
    if (!cancelled) updateCell(cellId, value);
  }}
/>

API

プロパティ デフォルト 説明
columns DataGridColumnDef[] required 列定義
rows DataGridRowData[] required 行データ
ariaLabel string - グリッドのアクセシブルな名前
rowSelectable boolean false チェックボックスによる行選択を有効化
rowMultiselectable boolean false 複数行選択を有効化
selectedRowIds string[] [] 選択された行のID
onRowSelectionChange (ids: string[]) => void - 行選択変更コールバック
onSort (columnId, direction) => void - ソートコールバック
enableRangeSelection boolean false Shift+矢印での範囲選択を有効化
editable boolean false セル編集を有効化
onEditEnd (cellId, value, cancelled) => void - 編集終了コールバック

テスト

テストは、キーボード操作、ARIA属性、アクセシビリティ要件全体のAPG準拠を検証します。データグリッドコンポーネントは、基本的なグリッドテスト戦略をソート、行選択、範囲選択、セル編集の追加テストで拡張しています。

テスト戦略

ユニットテスト(Testing Library)

フレームワーク固有のTesting Libraryユーティリティを使用して、コンポーネントのレンダリングとインタラクションを検証します。これらのテストは、分離された状態での正しいコンポーネントの動作を確認します。

  • HTML構造と要素階層(grid、row、gridcell)
  • 初期属性値(role、aria-label、tabindex、aria-sort)
  • 選択状態の変更(行とセルのaria-selected)
  • 編集モード状態(aria-readonly)
  • ソート方向の更新(aria-sort)
  • CSSクラスの適用

E2Eテスト(Playwright)

4つのフレームワークすべてにわたって実際のブラウザ環境でコンポーネントの動作を検証します。これらのテストは、完全なブラウザコンテキストを必要とするインタラクションをカバーします。

  • 2Dキーボードナビゲーション(矢印キー)
  • ヘッダーナビゲーションとソート
  • Shift+矢印による範囲選択
  • セル編集ワークフロー(Enter、F2、Escape)
  • チェックボックスによる行選択
  • ヘッダーとセル間のフォーカス管理
  • フレームワーク間の一貫性

テストカテゴリ

高優先度: APG ARIA属性

テスト 説明
role="grid" コンテナにgridロールがある
role="row" すべての行にrowロールがある
role="gridcell" データセルにgridcellロールがある
role="columnheader" ヘッダーセルにcolumnheaderロールがある
aria-sort ソート可能なヘッダーにaria-sortがある
aria-sort updates ソートアクションでaria-sortが更新される
aria-selected on rows rowSelectableの場合、行にaria-selectedがある
aria-readonly on grid readonlyプロップの場合、グリッドにaria-readonlyがある
aria-readonly on cells 編集可能性に基づいてセルにaria-readonlyがある
aria-multiselectable 行またはセルのマルチセレクトが有効な場合に存在

高優先度: ソート

テスト 説明
Enter on header ソート可能なヘッダーでEnterがソートをトリガー
Space on header ソート可能なヘッダーでSpaceがソートをトリガー
Sort cycle ソート循環: none → ascending → descending → ascending
Non-sortable headers ソート不可のヘッダーはEnter/Spaceに応答しない

高優先度: 範囲選択

テスト 説明
Shift+ArrowDown 選択を下に拡張
Shift+ArrowUp 選択を上に拡張
Shift+Home 選択を行の先頭まで拡張
Shift+End 選択を行の末尾まで拡張
Ctrl+Shift+Home 選択をグリッドの先頭まで拡張
Ctrl+Shift+End 選択をグリッドの末尾まで拡張
Selection anchor 最初の選択時に選択アンカーが設定される

高優先度: 行選択

テスト 説明
Checkbox toggle チェックボックスのクリックで行選択を切り替え
aria-selected 行要素でaria-selectedが更新される
Callback fires onRowSelectionChangeコールバックが発火
Select all 全選択チェックボックスがすべての行を選択/解除
Indeterminate 一部選択時に全選択が不確定状態を表示

高優先度: セル編集

テスト 説明
Enter starts edit 編集可能なセルでEnterが編集モードに入る
F2 starts edit 編集可能なセルでF2が編集モードに入る
Escape cancels Escapeが編集をキャンセルして元の値を復元
Navigation disabled 編集モード中はグリッドナビゲーションが無効
Focus on input 編集開始時にフォーカスが入力フィールドに移動
Focus returns 編集終了時にフォーカスがセルに戻る
onEditStart 編集モードに入るときにonEditStartコールバックが発火
onEditEnd 編集モードを終了するときにonEditEndコールバックが発火
Readonly cell 読み取り専用セルは編集モードに入らない

高優先度: フォーカス管理

テスト 説明
Sortable headers focusable ソート可能なヘッダーにtabindexがある
Non-sortable not focusable ソート不可のヘッダーにtabindexがない
First has tabindex=0 最初のフォーカス可能な要素にtabindex="0"がある
Header to data ヘッダーからArrowDownで最初のデータ行に入る
Data to header 最初の行からArrowUpでソート可能なヘッダーに入る
Roving tabindex ローヴィングタブインデックスが正しく更新される

中優先度: 仮想化サポート

テスト 説明
aria-rowcount totalRows提供時に存在(1ベース)
aria-colcount totalColumns提供時に存在(1ベース)
aria-rowindex 仮想化時に行に存在(1ベース)
aria-colindex 仮想化時にセルに存在(1ベース)

中優先度: アクセシビリティ

テスト 説明
axe-core アクセシビリティ違反なし
Sort indicators ソートインジケーターにアクセシブルな名前がある
Checkbox labels チェックボックスにアクセシブルなラベルがある

テストツール

testing-strategy.md (opens in new tab) を参照して完全なドキュメントをご覧ください。

DataGrid.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import {
  DataGrid,
  type DataGridColumnDef,
  type DataGridRowData,
  type SortDirection,
} from './DataGrid';

// Helper function to create sortable columns
const createSortableColumns = (): DataGridColumnDef[] => [
  { id: 'name', header: 'Name', sortable: true, sortDirection: 'none' },
  { id: 'email', header: 'Email', sortable: true, sortDirection: 'none' },
  { id: 'role', header: 'Role', sortable: false },
];

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

// Helper function to create basic rows
const createBasicRows = (): DataGridRowData[] => [
  {
    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 editable cells
const createEditableRows = (): DataGridRowData[] => [
  {
    id: 'row1',
    cells: [
      { id: 'row1-0', value: 'Alice', editable: true },
      { id: 'row1-1', value: 'alice@example.com', editable: true },
      { id: 'row1-2', value: 'Admin', readonly: true },
    ],
  },
  {
    id: 'row2',
    cells: [
      { id: 'row2-0', value: 'Bob', editable: true },
      { id: 'row2-1', value: 'bob@example.com', editable: true },
      { id: 'row2-2', value: 'User' },
    ],
  },
];

// Rows with disabled cells/rows
const createRowsWithDisabled = (): DataGridRowData[] => [
  {
    id: 'row1',
    cells: [
      { id: 'row1-0', value: 'Alice' },
      { id: 'row1-1', value: 'alice@example.com', disabled: true },
      { id: 'row1-2', value: 'Admin' },
    ],
  },
  {
    id: 'row2',
    disabled: true,
    cells: [
      { id: 'row2-0', value: 'Bob' },
      { id: 'row2-1', value: 'bob@example.com' },
      { id: 'row2-2', value: 'User' },
    ],
  },
];

// Rows with row header
const createRowsWithRowHeader = (): DataGridRowData[] => [
  {
    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' },
    ],
  },
];

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

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

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

    it('has role="row" on all rows', () => {
      render(
        <DataGrid 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(
        <DataGrid 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(
        <DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
      );
      expect(screen.getAllByRole('columnheader')).toHaveLength(3);
    });

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

    it('sortable columnheader has aria-sort', () => {
      render(
        <DataGrid columns={createSortableColumns()} rows={createBasicRows()} ariaLabel="Users" />
      );
      const nameHeader = screen.getByRole('columnheader', { name: 'Name' });
      expect(nameHeader).toHaveAttribute('aria-sort', 'none');
    });

    it('non-sortable columnheader does NOT have aria-sort', () => {
      render(
        <DataGrid columns={createSortableColumns()} rows={createBasicRows()} ariaLabel="Users" />
      );
      const roleHeader = screen.getByRole('columnheader', { name: 'Role' });
      expect(roleHeader).not.toHaveAttribute('aria-sort');
    });

    it('aria-sort updates on sort action', async () => {
      const columns: DataGridColumnDef[] = [
        { id: 'name', header: 'Name', sortable: true, sortDirection: 'none' },
      ];
      const onSort = vi.fn((_columnId: string, direction: SortDirection) => {
        columns[0].sortDirection = direction;
      });
      const { rerender } = render(
        <DataGrid columns={columns} rows={createBasicRows()} ariaLabel="Users" onSort={onSort} />
      );

      const header = screen.getByRole('columnheader', { name: 'Name' });
      header.focus();
      await userEvent.setup().keyboard('{Enter}');

      // Rerender with updated column
      rerender(
        <DataGrid columns={columns} rows={createBasicRows()} ariaLabel="Users" onSort={onSort} />
      );

      expect(header).toHaveAttribute('aria-sort', 'ascending');
    });

    it('row has aria-selected when rowSelectable', () => {
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          rowSelectable
        />
      );
      const rows = screen.getAllByRole('row');
      // Skip header row, check data rows
      expect(rows[1]).toHaveAttribute('aria-selected', 'false');
      expect(rows[2]).toHaveAttribute('aria-selected', 'false');
    });

    it('has accessible name via aria-label', () => {
      render(
        <DataGrid 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>
          <DataGrid
            columns={createBasicColumns()}
            rows={createBasicRows()}
            ariaLabelledby="grid-title"
          />
        </div>
      );
      const grid = screen.getByRole('grid');
      expect(grid).toHaveAttribute('aria-labelledby', 'grid-title');
    });

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

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

    it('has aria-readonly="true" when readonly prop is true', () => {
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          editable
          readonly
        />
      );
      expect(screen.getByRole('grid')).toHaveAttribute('aria-readonly', 'true');
    });

    it('editable cells have aria-readonly="false" or omitted', () => {
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createEditableRows()}
          ariaLabel="Users"
          editable
        />
      );
      const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
      const ariaReadonly = editableCell.getAttribute('aria-readonly');
      expect(ariaReadonly === null || ariaReadonly === 'false').toBe(true);
    });

    it('readonly cells have aria-readonly="true"', () => {
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createEditableRows()}
          ariaLabel="Users"
          editable
        />
      );
      const readonlyCell = screen.getByRole('gridcell', { name: 'Admin' });
      expect(readonlyCell).toHaveAttribute('aria-readonly', 'true');
    });

    it('has aria-rowcount/aria-colcount when virtualizing', () => {
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          totalRows={100}
          totalColumns={10}
        />
      );
      const grid = screen.getByRole('grid');
      expect(grid).toHaveAttribute('aria-rowcount', '100');
      expect(grid).toHaveAttribute('aria-colcount', '10');
    });

    it('has aria-rowindex on rows when virtualizing (1-based, header row = 1)', () => {
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          totalRows={100}
          startRowIndex={10}
        />
      );
      const rows = screen.getAllByRole('row');
      // Header row should have aria-rowindex="1"
      expect(rows[0]).toHaveAttribute('aria-rowindex', '1');
      // Data rows start at startRowIndex
      expect(rows[1]).toHaveAttribute('aria-rowindex', '10');
      expect(rows[2]).toHaveAttribute('aria-rowindex', '11');
    });

    it('has aria-colindex on cells/headers when virtualizing', () => {
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          totalColumns={10}
          startColIndex={5}
        />
      );
      const headers = screen.getAllByRole('columnheader');
      expect(headers[0]).toHaveAttribute('aria-colindex', '5');
      expect(headers[1]).toHaveAttribute('aria-colindex', '6');

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

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

      // Disabled row should have aria-disabled on its cells
      const disabledRow = screen.getAllByRole('row')[2];
      expect(disabledRow).toHaveAttribute('aria-disabled', 'true');
    });

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

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

    it('has aria-rowspan on gridcells/rowheaders with rowspan > 1', () => {
      render(
        <DataGrid columns={createBasicColumns()} rows={createRowsWithSpan()} ariaLabel="Users" />
      );
      const spannedCell = screen.getByRole('rowheader', { name: 'Header' });
      expect(spannedCell).toHaveAttribute('aria-rowspan', '2');
    });
  });

  // ========================================
  // High Priority: Keyboard - Base Navigation
  // ========================================
  describe('Keyboard - Base Navigation', () => {
    it('ArrowRight moves focus one cell right', async () => {
      const user = userEvent.setup();
      render(
        <DataGrid 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(
        <DataGrid 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(
        <DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
      );

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();
      await user.keyboard('{ArrowDown}');

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

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

      const secondRowFirstCell = screen.getAllByRole('gridcell')[3];
      secondRowFirstCell.focus();
      await user.keyboard('{ArrowUp}');

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

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

      const lastCellInRow = screen.getAllByRole('gridcell')[2];
      lastCellInRow.focus();
      await user.keyboard('{ArrowRight}');

      expect(lastCellInRow).toHaveFocus();
    });

    it('ArrowUp from first data row enters sortable header', async () => {
      const user = userEvent.setup();
      render(
        <DataGrid columns={createSortableColumns()} rows={createBasicRows()} ariaLabel="Users" />
      );

      const firstDataCell = screen.getAllByRole('gridcell')[0];
      firstDataCell.focus();
      await user.keyboard('{ArrowUp}');

      // Should move to sortable header
      const sortableHeader = screen.getByRole('columnheader', { name: 'Name' });
      expect(sortableHeader).toHaveFocus();
    });

    it('ArrowDown from header enters first data row', async () => {
      const user = userEvent.setup();
      render(
        <DataGrid columns={createSortableColumns()} rows={createBasicRows()} ariaLabel="Users" />
      );

      const sortableHeader = screen.getByRole('columnheader', { name: 'Name' });
      sortableHeader.focus();
      await user.keyboard('{ArrowDown}');

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

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

      const lastCellInRow = screen.getAllByRole('gridcell')[2];
      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(
        <DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
      );

      const firstCell = screen.getAllByRole('gridcell')[0];
      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(
        <DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
      );

      const lastCell = screen.getAllByRole('gridcell')[8];
      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(
        <DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
      );

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();
      await user.keyboard('{Control>}{End}{/Control}');

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

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

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();
      await user.keyboard('{PageDown}');

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

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

      const lastRowCell = screen.getAllByRole('gridcell')[6];
      lastRowCell.focus();
      await user.keyboard('{PageUp}');

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

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

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

      expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
    });

    it('Shift+Tab exits grid to previous focusable element', async () => {
      render(
        <div>
          <button>Before</button>
          <DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
        </div>
      );

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();
      fireEvent.keyDown(firstCell, { key: 'Tab', shiftKey: true });

      // Note: actual focus behavior depends on browser
    });

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

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();
      await user.keyboard('{ArrowRight}');

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

  // ========================================
  // High Priority: Keyboard - Sorting
  // ========================================
  describe('Keyboard - Sorting', () => {
    it('Enter on sortable header triggers sort', async () => {
      const onSort = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createSortableColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          onSort={onSort}
        />
      );

      const header = screen.getByRole('columnheader', { name: 'Name' });
      header.focus();
      await user.keyboard('{Enter}');

      expect(onSort).toHaveBeenCalledWith('name', 'ascending');
    });

    it('Space on sortable header triggers sort', async () => {
      const onSort = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createSortableColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          onSort={onSort}
        />
      );

      const header = screen.getByRole('columnheader', { name: 'Name' });
      header.focus();
      await user.keyboard(' ');

      expect(onSort).toHaveBeenCalledWith('name', 'ascending');
    });

    it('sort cycles: none -> ascending -> descending -> ascending', async () => {
      const onSort = vi.fn();
      const user = userEvent.setup();
      const columns: DataGridColumnDef[] = [
        { id: 'name', header: 'Name', sortable: true, sortDirection: 'none' },
      ];

      const { rerender } = render(
        <DataGrid columns={columns} rows={createBasicRows()} ariaLabel="Users" onSort={onSort} />
      );

      const header = screen.getByRole('columnheader', { name: 'Name' });
      header.focus();

      // First: none -> ascending
      await user.keyboard('{Enter}');
      expect(onSort).toHaveBeenLastCalledWith('name', 'ascending');

      // Update column and rerender
      columns[0].sortDirection = 'ascending';
      rerender(
        <DataGrid columns={columns} rows={createBasicRows()} ariaLabel="Users" onSort={onSort} />
      );

      // Second: ascending -> descending
      await user.keyboard('{Enter}');
      expect(onSort).toHaveBeenLastCalledWith('name', 'descending');

      // Update column and rerender
      columns[0].sortDirection = 'descending';
      rerender(
        <DataGrid columns={columns} rows={createBasicRows()} ariaLabel="Users" onSort={onSort} />
      );

      // Third: descending -> ascending (loop)
      await user.keyboard('{Enter}');
      expect(onSort).toHaveBeenLastCalledWith('name', 'ascending');
    });

    it('non-sortable headers do not respond to Enter/Space', async () => {
      const onSort = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createSortableColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          onSort={onSort}
        />
      );

      // Focus on non-sortable header (Role column)
      // Non-sortable headers should not be focusable, so this test verifies the behavior
      const roleHeader = screen.getByRole('columnheader', { name: 'Role' });
      roleHeader.focus();
      await user.keyboard('{Enter}');
      await user.keyboard(' ');

      // onSort should not have been called for non-sortable header
      expect(onSort).not.toHaveBeenCalled();
    });
  });

  // ========================================
  // High Priority: Keyboard - Range Selection
  // ========================================
  describe('Keyboard - Range Selection', () => {
    it('Shift+ArrowDown extends selection downward', async () => {
      const onRangeSelect = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          enableRangeSelection
          onRangeSelect={onRangeSelect}
        />
      );

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();
      await user.keyboard('{Shift>}{ArrowDown}{/Shift}');

      expect(onRangeSelect).toHaveBeenCalled();
      // Should include first cell and cell below
      const selectedIds = onRangeSelect.mock.calls[0][0];
      expect(selectedIds).toContain('row1-0');
      expect(selectedIds).toContain('row2-0');
    });

    it('Shift+ArrowUp extends selection upward', async () => {
      const onRangeSelect = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          enableRangeSelection
          onRangeSelect={onRangeSelect}
        />
      );

      const secondRowCell = screen.getAllByRole('gridcell')[3];
      secondRowCell.focus();
      await user.keyboard('{Shift>}{ArrowUp}{/Shift}');

      expect(onRangeSelect).toHaveBeenCalled();
      const selectedIds = onRangeSelect.mock.calls[0][0];
      expect(selectedIds).toContain('row1-0');
      expect(selectedIds).toContain('row2-0');
    });

    it('Shift+Home extends selection to row start', async () => {
      const onRangeSelect = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          enableRangeSelection
          onRangeSelect={onRangeSelect}
        />
      );

      const lastCellInRow = screen.getAllByRole('gridcell')[2];
      lastCellInRow.focus();
      await user.keyboard('{Shift>}{Home}{/Shift}');

      expect(onRangeSelect).toHaveBeenCalled();
      const selectedIds = onRangeSelect.mock.calls[0][0];
      expect(selectedIds).toContain('row1-0');
      expect(selectedIds).toContain('row1-1');
      expect(selectedIds).toContain('row1-2');
    });

    it('Shift+End extends selection to row end', async () => {
      const onRangeSelect = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          enableRangeSelection
          onRangeSelect={onRangeSelect}
        />
      );

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();
      await user.keyboard('{Shift>}{End}{/Shift}');

      expect(onRangeSelect).toHaveBeenCalled();
      const selectedIds = onRangeSelect.mock.calls[0][0];
      expect(selectedIds).toContain('row1-0');
      expect(selectedIds).toContain('row1-1');
      expect(selectedIds).toContain('row1-2');
    });

    it('Ctrl+Shift+Home extends selection to grid start', async () => {
      const onRangeSelect = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          enableRangeSelection
          onRangeSelect={onRangeSelect}
        />
      );

      const lastCell = screen.getAllByRole('gridcell')[8];
      lastCell.focus();
      await user.keyboard('{Control>}{Shift>}{Home}{/Shift}{/Control}');

      expect(onRangeSelect).toHaveBeenCalled();
      // Should include all cells from start to current
    });

    it('Ctrl+Shift+End extends selection to grid end', async () => {
      const onRangeSelect = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          enableRangeSelection
          onRangeSelect={onRangeSelect}
        />
      );

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();
      await user.keyboard('{Control>}{Shift>}{End}{/Shift}{/Control}');

      expect(onRangeSelect).toHaveBeenCalled();
      // Should include all cells from current to end
    });

    it('selection anchor is set on first selection', async () => {
      const onRangeSelect = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          enableRangeSelection
          onRangeSelect={onRangeSelect}
        />
      );

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

      // First Shift+ArrowDown
      await user.keyboard('{Shift>}{ArrowDown}{/Shift}');
      const firstCall = onRangeSelect.mock.calls[0][0];

      // Second Shift+ArrowDown (should extend from anchor)
      await user.keyboard('{Shift>}{ArrowDown}{/Shift}');
      const secondCall = onRangeSelect.mock.calls[1][0];

      // Second call should have more cells (anchor + 2 rows down)
      expect(secondCall.length).toBeGreaterThan(firstCall.length);
    });
  });

  // ========================================
  // High Priority: Row Selection
  // ========================================
  describe('Row Selection', () => {
    it('checkbox click toggles row selection', async () => {
      const onRowSelectionChange = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          rowSelectable
          onRowSelectionChange={onRowSelectionChange}
        />
      );

      const checkbox = screen.getAllByRole('checkbox')[0];
      await user.click(checkbox);

      expect(onRowSelectionChange).toHaveBeenCalledWith(['row1']);
    });

    it('Space on checkbox cell toggles row selection', async () => {
      const onRowSelectionChange = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          rowSelectable
          onRowSelectionChange={onRowSelectionChange}
        />
      );

      const checkbox = screen.getAllByRole('checkbox')[0];
      checkbox.focus();
      await user.keyboard(' ');

      expect(onRowSelectionChange).toHaveBeenCalledWith(['row1']);
    });

    it('aria-selected updates on row element', async () => {
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          rowSelectable
        />
      );

      const rows = screen.getAllByRole('row');
      const dataRow = rows[1];
      expect(dataRow).toHaveAttribute('aria-selected', 'false');

      const checkbox = screen.getAllByRole('checkbox')[0];
      await user.click(checkbox);

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

    it('onRowSelectionChange callback fires', async () => {
      const onRowSelectionChange = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          rowSelectable
          onRowSelectionChange={onRowSelectionChange}
        />
      );

      const checkbox = screen.getAllByRole('checkbox')[0];
      await user.click(checkbox);

      expect(onRowSelectionChange).toHaveBeenCalledTimes(1);
      expect(onRowSelectionChange).toHaveBeenCalledWith(['row1']);
    });
  });

  // ========================================
  // High Priority: Selection Model Exclusivity
  // ========================================
  describe('Selection Model Exclusivity', () => {
    it('when rowSelectable: aria-selected on rows only', () => {
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          rowSelectable
        />
      );

      const rows = screen.getAllByRole('row');
      // Data rows should have aria-selected
      expect(rows[1]).toHaveAttribute('aria-selected');

      // Cells should NOT have aria-selected
      const cells = screen.getAllByRole('gridcell');
      cells.forEach((cell) => {
        expect(cell).not.toHaveAttribute('aria-selected');
      });
    });

    it('when selectable (cell): aria-selected on gridcells only', () => {
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
        />
      );

      // Cells should have aria-selected
      const cells = screen.getAllByRole('gridcell');
      cells.forEach((cell) => {
        expect(cell).toHaveAttribute('aria-selected');
      });

      // Data rows should NOT have aria-selected
      const rows = screen.getAllByRole('row');
      expect(rows[1]).not.toHaveAttribute('aria-selected');
    });

    it('aria-multiselectable on grid (not on individual elements)', () => {
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          rowSelectable
          rowMultiselectable
        />
      );

      const grid = screen.getByRole('grid');
      expect(grid).toHaveAttribute('aria-multiselectable', 'true');

      // Rows/cells should NOT have aria-multiselectable
      const rows = screen.getAllByRole('row');
      rows.forEach((row) => {
        expect(row).not.toHaveAttribute('aria-multiselectable');
      });
    });
  });

  // ========================================
  // High Priority: Focus Management
  // ========================================
  describe('Focus Management', () => {
    it('sortable columnheaders are focusable (tabindex)', () => {
      render(
        <DataGrid columns={createSortableColumns()} rows={createBasicRows()} ariaLabel="Users" />
      );

      const sortableHeader = screen.getByRole('columnheader', { name: 'Name' });
      expect(sortableHeader).toHaveAttribute('tabindex');
    });

    it('non-sortable columnheaders are NOT focusable', () => {
      render(
        <DataGrid columns={createSortableColumns()} rows={createBasicRows()} ariaLabel="Users" />
      );

      const nonSortableHeader = screen.getByRole('columnheader', { name: 'Role' });
      expect(nonSortableHeader).not.toHaveAttribute('tabindex');
    });

    it('first focusable cell has tabindex="0"', () => {
      render(
        <DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
      );

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

    it('roving tabindex updates correctly', async () => {
      const user = userEvent.setup();
      render(
        <DataGrid 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');
    });
  });

  // ========================================
  // High Priority: Cell Editing
  // ========================================
  describe('Cell Editing', () => {
    it('Enter on editable cell enters edit mode', async () => {
      const onEditStart = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createEditableRows()}
          ariaLabel="Users"
          editable
          onEditStart={onEditStart}
        />
      );

      const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
      editableCell.focus();
      await user.keyboard('{Enter}');

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

    it('F2 on editable cell enters edit mode', async () => {
      const onEditStart = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createEditableRows()}
          ariaLabel="Users"
          editable
          onEditStart={onEditStart}
        />
      );

      const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
      editableCell.focus();
      await user.keyboard('{F2}');

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

    it('Escape in edit mode cancels and restores grid navigation', async () => {
      const onEditEnd = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createEditableRows()}
          ariaLabel="Users"
          editable
          onEditEnd={onEditEnd}
        />
      );

      const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
      editableCell.focus();
      await user.keyboard('{Enter}');

      // Should be in edit mode, type something
      await user.keyboard('New Value');
      await user.keyboard('{Escape}');

      expect(onEditEnd).toHaveBeenCalledWith('row1-0', expect.any(String), true);
    });

    it('edit mode disables grid keyboard navigation', async () => {
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createEditableRows()}
          ariaLabel="Users"
          editable
        />
      );

      const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
      editableCell.focus();
      await user.keyboard('{Enter}');

      // Arrow keys should work within input, not navigate grid
      await user.keyboard('{ArrowRight}');

      // Should still be in edit mode (focus on input, not next cell)
      const input = screen.getByRole('textbox');
      expect(input).toBeInTheDocument();
    });

    it('focus moves to input field on edit start', async () => {
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createEditableRows()}
          ariaLabel="Users"
          editable
        />
      );

      const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
      editableCell.focus();
      await user.keyboard('{Enter}');

      const input = screen.getByRole('textbox');
      expect(input).toHaveFocus();
    });

    it('focus returns to cell on edit end', async () => {
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createEditableRows()}
          ariaLabel="Users"
          editable
        />
      );

      const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
      editableCell.focus();
      await user.keyboard('{Enter}');

      // Wait for edit mode to be active
      await waitFor(() => {
        expect(screen.getByRole('textbox')).toBeInTheDocument();
      });

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

      // Wait for focus to return to cell
      await waitFor(() => {
        expect(editableCell).toHaveFocus();
      });
    });

    it('onEditStart callback fires when entering edit mode', async () => {
      const onEditStart = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createEditableRows()}
          ariaLabel="Users"
          editable
          onEditStart={onEditStart}
        />
      );

      const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
      editableCell.focus();
      await user.keyboard('{Enter}');

      expect(onEditStart).toHaveBeenCalledTimes(1);
    });

    it('onEditEnd callback fires when exiting edit mode', async () => {
      const onEditEnd = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createEditableRows()}
          ariaLabel="Users"
          editable
          onEditEnd={onEditEnd}
        />
      );

      const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
      editableCell.focus();
      await user.keyboard('{Enter}');
      await user.keyboard('{Escape}');

      expect(onEditEnd).toHaveBeenCalledTimes(1);
    });

    it('non-editable cell does not enter edit mode on Enter/F2', async () => {
      const onEditStart = vi.fn();
      const user = userEvent.setup();
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createEditableRows()}
          ariaLabel="Users"
          editable
          onEditStart={onEditStart}
        />
      );

      // Admin cell is readonly
      const readonlyCell = screen.getByRole('gridcell', { name: 'Admin' });
      readonlyCell.focus();
      await user.keyboard('{Enter}');
      await user.keyboard('{F2}');

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

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

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

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

    it('sort indicators have accessible names', () => {
      render(
        <DataGrid
          columns={[{ id: 'name', header: 'Name', sortable: true, sortDirection: 'ascending' }]}
          rows={createBasicRows()}
          ariaLabel="Users"
        />
      );

      const header = screen.getByRole('columnheader', { name: /Name/ });
      // Header should have aria-sort which provides accessible state
      expect(header).toHaveAttribute('aria-sort', 'ascending');
    });

    it('checkboxes have accessible labels', () => {
      render(
        <DataGrid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          rowSelectable
        />
      );

      const checkboxes = screen.getAllByRole('checkbox');
      checkboxes.forEach((checkbox) => {
        // Each checkbox should have an accessible name
        const label =
          checkbox.getAttribute('aria-label') || checkbox.getAttribute('aria-labelledby');
        expect(label).toBeTruthy();
      });
    });
  });
});

リソース