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 パターンを拡張し、データ操作のための追加機能を提供します。

機能GridData 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.vue
<script setup lang="ts">
import { computed, ref, onMounted, nextTick, watch } from 'vue';

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

export type SortDirection = 'ascending' | 'descending' | 'none' | 'other';
export type EditType = 'text' | 'select' | 'combobox';

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

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

/** Get aria-readonly value for a cell in editable grid */
function getAriaReadonly(
  gridEditable: boolean,
  cellReadonly: boolean | undefined,
  cellEditable: boolean
): 'true' | 'false' | undefined {
  if (!gridEditable) return undefined;
  if (cellReadonly === true) return 'true';
  if (cellEditable) return 'false';
  return 'true'; // Non-editable cell in editable grid
}

/** Get aria-selected value for a cell */
function getAriaSelected(
  selectable: boolean,
  rowSelectable: boolean,
  isSelected: boolean
): 'true' | 'false' | undefined {
  if (!selectable) return undefined;
  if (rowSelectable) return undefined; // Row selection takes precedence
  return isSelected ? 'true' : 'false';
}

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

interface Props {
  columns: DataGridColumnDef[];
  rows: DataGridRowData[];
  ariaLabel?: string;
  ariaLabelledby?: string;
  rowSelectable?: boolean;
  rowMultiselectable?: boolean;
  selectedRowIds?: string[];
  defaultSelectedRowIds?: string[];
  enableRangeSelection?: boolean;
  editable?: boolean;
  readonly?: boolean;
  editingCellId?: string | null;
  selectable?: boolean;
  multiselectable?: boolean;
  selectedIds?: string[];
  defaultSelectedIds?: string[];
  defaultFocusedId?: string;
  totalColumns?: number;
  totalRows?: number;
  startRowIndex?: number;
  startColIndex?: number;
  wrapNavigation?: boolean;
  enablePageNavigation?: boolean;
  pageSize?: number;
}

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

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

const emit = defineEmits<{
  sort: [columnId: string, direction: SortDirection];
  rowSelectionChange: [rowIds: string[]];
  rangeSelect: [cellIds: string[]];
  editStart: [cellId: string, rowId: string, colId: string];
  editEnd: [cellId: string, value: string, cancelled: boolean];
  cellValueChange: [cellId: string, newValue: string];
  selectionChange: [selectedIds: string[]];
  focusChange: [focusedId: string | null];
  cellActivate: [cellId: string, rowId: string, colId: string];
}>();

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

// Row selection
const internalSelectedRowIds = ref<string[]>([...props.defaultSelectedRowIds]);
const selectedRowIds = computed(() => props.selectedRowIds ?? internalSelectedRowIds.value);

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

// Focus
// 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
const getInitialFocusedId = () => {
  if (props.defaultFocusedId) return props.defaultFocusedId;
  if (props.rowSelectable && props.rowMultiselectable) {
    return 'header-checkbox';
  }
  if (props.rowSelectable) {
    return props.rows[0] ? `checkbox-${props.rows[0].id}` : null;
  }
  return props.rows[0]?.cells[0]?.id ?? null;
};
const focusedId = ref<string | null>(getInitialFocusedId());

// Edit mode
const internalEditingCellId = ref<string | null>(null);
const editingCellId = computed(() =>
  props.editingCellId !== undefined ? props.editingCellId : internalEditingCellId.value
);
const editValue = ref<string>('');
const originalEditValue = ref<string>('');
const editingColId = ref<string | null>(null);
const isEndingEdit = ref(false);

// Combobox state
const comboboxExpanded = ref(false);
const comboboxActiveIndex = ref(-1);
const filteredOptions = ref<string[]>([]);

// Range selection anchor
const anchorCellId = ref<string | null>(null);

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

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

const hasSortableHeaders = computed(() => props.columns.some((col) => col.sortable));

// Check if header row has focusable items (sortable headers OR header checkbox)
const hasHeaderFocusable = computed(
  () => hasSortableHeaders.value || (props.rowSelectable && props.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 = computed(() => {
  const labelColumn = props.columns.find((col) => col.isRowLabel);
  return labelColumn ?? props.columns[0];
});

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

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

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

  // Sortable headers at row index -1
  props.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
  props.rows.forEach((row, rowIndex) => {
    // Add checkbox cell if row selection is enabled
    if (props.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: props.columns[colIndex]?.id,
        cell,
        disabled: cell.disabled || row.disabled,
      });
    });
  });

  return items;
});

const itemById = computed(() => {
  const map = new Map<string, (typeof focusableItems.value)[0]>();
  focusableItems.value.forEach((item) => map.set(item.id, item));
  return map;
});

const showMultiselectable = computed(() => props.rowMultiselectable || props.multiselectable);

// =============================================================================
// Methods - Focus Management
// =============================================================================

function getItemPosition(id: string) {
  const item = itemById.value.get(id);
  if (!item) return null;
  return { rowIndex: item.rowIndex, colIndex: item.colIndex };
}

function getItemAt(rowIndex: number, colIndex: number) {
  if (rowIndex === -1) {
    // Header row - find header-checkbox or sortable header at this column
    return focusableItems.value.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.value.find(
    (item) =>
      (item.type === 'cell' || item.type === 'checkbox') &&
      item.rowIndex === rowIndex &&
      item.colIndex === colIndex
  );
}

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

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

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

function findNextFocusable(
  startRowIndex: number,
  startColIndex: number,
  direction: 'right' | 'left' | 'up' | 'down',
  skipDisabled = true
) {
  const colCount = props.columns.length + (props.rowSelectable ? 1 : 0);
  const rowCount = props.rows.length;

  let rowIdx = startRowIndex;
  let colIdx = startColIndex;

  const step = () => {
    switch (direction) {
      case 'right':
        colIdx++;
        if (colIdx >= colCount) {
          if (props.wrapNavigation) {
            colIdx = 0;
            rowIdx++;
            if (rowIdx >= rowCount) return false;
          } else {
            return false;
          }
        }
        break;
      case 'left':
        colIdx--;
        if (colIdx < 0) {
          if (props.wrapNavigation) {
            colIdx = colCount - 1;
            rowIdx--;
            // Allow going up to header row (-1) if header has focusable items
            if (rowIdx < (hasHeaderFocusable.value ? -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.value ? -1 : 0)) return false;
        break;
    }
    return true;
  };

  if (!step()) return null;

  let iterations = 0;
  const maxIterations = colCount * (rowCount + 1);

  while (iterations < maxIterations) {
    const item = getItemAt(rowIdx, colIdx);
    if (item) {
      if (rowIdx === -1) {
        return item;
      }
      if (!skipDisabled || !item.disabled) {
        return item;
      }
    }
    if (!step()) break;
    iterations++;
  }

  return null;
}

// =============================================================================
// Methods - Row Selection
// =============================================================================

function setSelectedRowIds(ids: string[]) {
  internalSelectedRowIds.value = ids;
  emit('rowSelectionChange', ids);
}

function toggleRowSelection(rowId: string, row: DataGridRowData) {
  if (!props.rowSelectable || row.disabled) return;

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

function toggleAllRowSelection() {
  if (!props.rowSelectable || !props.rowMultiselectable) return;

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

  if (allSelected) {
    setSelectedRowIds([]);
  } else {
    setSelectedRowIds(allRowIds);
  }
}

function getSelectAllState(): 'all' | 'some' | 'none' {
  const allRowIds = props.rows.filter((r) => !r.disabled).map((r) => r.id);
  if (allRowIds.length === 0) return 'none';

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

// =============================================================================
// Methods - Cell Selection
// =============================================================================

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

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

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

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

  const allIds = focusableItems.value
    .filter((item) => item.type === 'cell' && !item.disabled)
    .map((item) => item.id);
  setSelectedIds(allIds);
}

// =============================================================================
// Methods - Range Selection
// =============================================================================

function getCellsInRange(startId: string, endId: string): string[] {
  const startItem = itemById.value.get(startId);
  const endItem = itemById.value.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;
}

function extendRangeSelection(currentCellId: string, newFocusId: string) {
  if (!props.enableRangeSelection) return;

  const anchor = anchorCellId.value ?? currentCellId;
  if (!anchorCellId.value) {
    anchorCellId.value = currentCellId;
  }

  const cellIds = getCellsInRange(anchor, newFocusId);
  emit('rangeSelect', cellIds);
}

// =============================================================================
// Methods - Sorting
// =============================================================================

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

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

  const nextDirection = getNextSortDirection(column.sortDirection);
  emit('sort', columnId, nextDirection);
}

// =============================================================================
// Methods - Cell Editing
// =============================================================================

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

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

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

function startEdit(cellId: string, rowId: string, colId: string) {
  if (!props.editable || props.readonly) return;

  const item = itemById.value.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);
  originalEditValue.value = value;
  editValue.value = value;
  editingColId.value = colId;
  internalEditingCellId.value = cellId;

  // Initialize combobox state if editType is combobox
  const editType = getColumnEditType(colId);
  if (editType === 'combobox') {
    const options = getColumnOptions(colId);
    filteredOptions.value = options;
    comboboxExpanded.value = true;
    comboboxActiveIndex.value = -1;
  }

  emit('editStart', cellId, rowId, colId);
}

function endEdit(cellId: string, cancelled: boolean, explicitValue?: string) {
  if (isEndingEdit.value) return;
  if (internalEditingCellId.value !== cellId) return;

  isEndingEdit.value = true;
  // Use explicit value if provided (for combobox/select option clicks),
  // otherwise fall back to current editValue state
  const finalValue = cancelled ? originalEditValue.value : (explicitValue ?? editValue.value);
  internalEditingCellId.value = null;
  editingColId.value = null;
  comboboxExpanded.value = false;
  comboboxActiveIndex.value = -1;
  emit('editEnd', cellId, finalValue, cancelled);

  const cellEl = cellRefs.value.get(cellId);
  if (cellEl) {
    cellEl.focus();
  }

  setTimeout(() => {
    isEndingEdit.value = false;
  }, 0);
}

// =============================================================================
// Methods - Keyboard Handling
// =============================================================================

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

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

  switch (event.key) {
    case 'ArrowRight': {
      // colIndex includes colOffset, so we need to adjust for columns array access
      const colOffset = props.rowSelectable ? 1 : 0;
      let nextColIdx = colIndex - colOffset + 1;
      while (nextColIdx < props.columns.length) {
        if (props.columns[nextColIdx].sortable) {
          focusItem(`header-${props.columns[nextColIdx].id}`);
          event.preventDefault();
          event.stopPropagation();
          return;
        }
        nextColIdx++;
      }
      handled = false;
      break;
    }
    case 'ArrowLeft': {
      // colIndex includes colOffset, so we need to adjust for columns array access
      const colOffset = props.rowSelectable ? 1 : 0;
      let prevColIdx = colIndex - colOffset - 1;
      while (prevColIdx >= 0) {
        if (props.columns[prevColIdx].sortable) {
          focusItem(`header-${props.columns[prevColIdx].id}`);
          event.preventDefault();
          event.stopPropagation();
          return;
        }
        prevColIdx--;
      }
      // No more sortable headers to the left, try header checkbox
      if (props.rowMultiselectable) {
        focusItem('header-checkbox');
        break;
      }
      handled = false;
      break;
    }
    case 'ArrowDown': {
      // colIndex includes colOffset, but rows[].cells[] doesn't include checkbox column
      const colOffset = props.rowSelectable ? 1 : 0;
      const cellColIndex = colIndex - colOffset;
      const firstRowCell = props.rows[0]?.cells[cellColIndex];
      if (firstRowCell) {
        focusItem(firstRowCell.id);
      }
      break;
    }
    case 'Home': {
      if (event.ctrlKey) {
        const firstSortable = props.columns.find((col) => col.sortable);
        if (firstSortable) {
          focusItem(`header-${firstSortable.id}`);
        } else {
          const firstCell = props.rows[0]?.cells[0];
          if (firstCell) focusItem(firstCell.id);
        }
      } else {
        const firstSortable = props.columns.find((col) => col.sortable);
        if (firstSortable) {
          focusItem(`header-${firstSortable.id}`);
        }
      }
      break;
    }
    case 'End': {
      if (event.ctrlKey) {
        const lastRow = props.rows[props.rows.length - 1];
        const lastCell = lastRow?.cells[lastRow.cells.length - 1];
        if (lastCell) focusItem(lastCell.id);
      } else {
        const lastSortable = [...props.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();
  }
}

function handleHeaderCheckboxKeyDown(event: KeyboardEvent) {
  const { key, ctrlKey } = event;
  let handled = true;

  switch (key) {
    case 'ArrowRight': {
      // Move to first sortable header if exists
      const firstSortable = props.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 (props.rows[0]) {
        focusItem(`checkbox-${props.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 = props.rows[props.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 = [...props.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();
  }
}

function handleCellKeyDown(
  event: KeyboardEvent,
  cell: DataGridCellData,
  rowId: string,
  colId: string
) {
  if (editingCellId.value === cell.id) {
    if (event.key === 'Escape') {
      event.preventDefault();
      event.stopPropagation();
      endEdit(cell.id, true);
    }
    return;
  }

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

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

  switch (event.key) {
    case 'ArrowRight': {
      if (event.shiftKey && props.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);
          anchorCellId.value = null;
        }
      }
      break;
    }
    case 'ArrowLeft': {
      if (event.shiftKey && props.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);
          anchorCellId.value = null;
        }
      }
      break;
    }
    case 'ArrowDown': {
      if (event.shiftKey && props.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);
          anchorCellId.value = null;
        }
      }
      break;
    }
    case 'ArrowUp': {
      if (event.shiftKey && props.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);
          anchorCellId.value = null;
        }
      }
      break;
    }
    case 'Home': {
      if (event.ctrlKey && event.shiftKey && props.enableRangeSelection) {
        const firstCell = props.rows[0]?.cells[0];
        if (firstCell) {
          focusItem(firstCell.id);
          extendRangeSelection(cell.id, firstCell.id);
        }
      } else if (event.ctrlKey) {
        const firstCell = props.rows[0]?.cells[0];
        if (firstCell) {
          focusItem(firstCell.id);
          anchorCellId.value = null;
        }
      } else if (event.shiftKey && props.enableRangeSelection) {
        const firstCellInRow = props.rows[rowIndex]?.cells[0];
        if (firstCellInRow) {
          focusItem(firstCellInRow.id);
          extendRangeSelection(cell.id, firstCellInRow.id);
        }
      } else {
        const firstCellInRow = props.rows[rowIndex]?.cells[0];
        if (firstCellInRow) {
          focusItem(firstCellInRow.id);
          anchorCellId.value = null;
        }
      }
      break;
    }
    case 'End': {
      const currentRow = props.rows[rowIndex];
      const lastRow = props.rows[props.rows.length - 1];

      if (event.ctrlKey && event.shiftKey && props.enableRangeSelection) {
        const lastCell = lastRow?.cells[lastRow.cells.length - 1];
        if (lastCell) {
          focusItem(lastCell.id);
          extendRangeSelection(cell.id, lastCell.id);
        }
      } else if (event.ctrlKey) {
        const lastCell = lastRow?.cells[lastRow.cells.length - 1];
        if (lastCell) {
          focusItem(lastCell.id);
          anchorCellId.value = null;
        }
      } else if (event.shiftKey && props.enableRangeSelection) {
        const lastCellInRow = currentRow?.cells[currentRow.cells.length - 1];
        if (lastCellInRow) {
          focusItem(lastCellInRow.id);
          extendRangeSelection(cell.id, lastCellInRow.id);
        }
      } else {
        const lastCellInRow = currentRow?.cells[currentRow.cells.length - 1];
        if (lastCellInRow) {
          focusItem(lastCellInRow.id);
          anchorCellId.value = null;
        }
      }
      break;
    }
    case 'PageDown': {
      if (props.enablePageNavigation) {
        const targetRowIndex = Math.min(rowIndex + props.pageSize, props.rows.length - 1);
        const targetCell = props.rows[targetRowIndex]?.cells[colIndex];
        if (targetCell) {
          focusItem(targetCell.id);
          anchorCellId.value = null;
        }
      } else {
        handled = false;
      }
      break;
    }
    case 'PageUp': {
      if (props.enablePageNavigation) {
        const targetRowIndex = Math.max(rowIndex - props.pageSize, 0);
        const targetCell = props.rows[targetRowIndex]?.cells[colIndex];
        if (targetCell) {
          focusItem(targetCell.id);
          anchorCellId.value = null;
        }
      } else {
        handled = false;
      }
      break;
    }
    case ' ': {
      if (props.selectable) {
        toggleSelection(cell.id, cell);
      }
      break;
    }
    case 'Enter': {
      if (props.editable && isCellEditable(cell, colId) && !cell.disabled) {
        startEdit(cell.id, rowId, colId);
      } else if (!cell.disabled) {
        emit('cellActivate', cell.id, rowId, colId);
      }
      break;
    }
    case 'F2': {
      if (props.editable && isCellEditable(cell, colId) && !cell.disabled) {
        startEdit(cell.id, rowId, colId);
      }
      break;
    }
    case 'a': {
      if (event.ctrlKey) {
        selectAll();
      } else {
        handled = false;
      }
      break;
    }
    default:
      handled = false;
  }

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

function handleCheckboxCellClick(checkboxId: string) {
  // Set focused ID first, then after Vue re-renders, focus the cell
  setFocusedId(checkboxId);
  nextTick(() => {
    const cellEl = cellRefs.value.get(checkboxId);
    if (cellEl) {
      cellEl.focus();
    }
  });
}

function handleCheckboxCellKeyDown(event: 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-${props.rows[0]?.id}`;
        if (firstCheckboxId) {
          focusItem(firstCheckboxId);
        }
      }
      // Home without Ctrl: stay on checkbox (it's the first cell in the row)
      break;
    }
    case 'End': {
      const currentRow = props.rows[rowIndex];
      const lastRow = props.rows[props.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();
  }
}

function handleInputKeyDown(event: KeyboardEvent, cellId: string) {
  if (event.key === 'Escape') {
    event.preventDefault();
    event.stopPropagation();
    endEdit(cellId, true);
  } else if (event.key === 'Enter') {
    event.preventDefault();
    event.stopPropagation();
    endEdit(cellId, false);
  }
}

function handleSelectKeyDown(event: KeyboardEvent, cellId: string) {
  if (event.key === 'Escape') {
    event.preventDefault();
    event.stopPropagation();
    endEdit(cellId, true);
  } else if (event.key === 'Enter') {
    event.preventDefault();
    event.stopPropagation();
    endEdit(cellId, false);
  }
}

function handleComboboxKeyDown(event: KeyboardEvent, cellId: string, colId: string) {
  const columnOptions = getColumnOptions(colId);

  if (event.key === 'Escape') {
    event.preventDefault();
    event.stopPropagation();
    comboboxExpanded.value = false;
    endEdit(cellId, true);
  } else if (event.key === 'Enter') {
    event.preventDefault();
    event.stopPropagation();
    const selectedOption =
      comboboxActiveIndex.value >= 0 ? filteredOptions.value[comboboxActiveIndex.value] : undefined;
    if (selectedOption) {
      editValue.value = selectedOption;
      emit('cellValueChange', cellId, selectedOption);
    }
    comboboxExpanded.value = false;
    endEdit(cellId, false, selectedOption);
  } else if (event.key === 'ArrowDown') {
    event.preventDefault();
    if (!comboboxExpanded.value) {
      comboboxExpanded.value = true;
    } else {
      comboboxActiveIndex.value = Math.min(
        comboboxActiveIndex.value + 1,
        filteredOptions.value.length - 1
      );
    }
  } else if (event.key === 'ArrowUp') {
    event.preventDefault();
    comboboxActiveIndex.value = Math.max(comboboxActiveIndex.value - 1, -1);
  }
}

function handleComboboxInput(cellId: string, colId: string) {
  const columnOptions = getColumnOptions(colId);
  const filtered = columnOptions.filter((opt) =>
    opt.toLowerCase().includes(editValue.value.toLowerCase())
  );
  filteredOptions.value = filtered;
  comboboxExpanded.value = true;
  comboboxActiveIndex.value = -1;
  emit('cellValueChange', cellId, editValue.value);
}

function handleComboboxBlur(event: FocusEvent, cellId: string) {
  // Check if focus is moving to listbox
  if (listboxRef.value?.contains(event.relatedTarget as Node)) {
    return;
  }
  comboboxExpanded.value = false;
  endEdit(cellId, false);
}

function handleOptionClick(option: string, cellId: string) {
  editValue.value = option;
  emit('cellValueChange', cellId, option);
  comboboxExpanded.value = false;
  endEdit(cellId, false, option);
}

// =============================================================================
// Refs
// =============================================================================

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

function setHeaderRef(columnId: string, el: HTMLDivElement | null) {
  if (el) {
    headerRefs.value.set(columnId, el);
  } else {
    headerRefs.value.delete(columnId);
  }
}

// =============================================================================
// Watchers
// =============================================================================

watch(editingCellId, (newVal) => {
  if (newVal && editingColId.value) {
    const editType = getColumnEditType(editingColId.value);
    nextTick(() => {
      if (editType === 'select' && selectRef.value) {
        selectRef.value.focus();
      } else if (inputRef.value) {
        // inputRef might be an array if multiple elements use the same ref name
        const input = Array.isArray(inputRef.value) ? inputRef.value[0] : inputRef.value;
        if (input && typeof input.focus === 'function') {
          input.focus();
          if (typeof input.select === 'function') {
            input.select();
          }
        }
      }
    });
  }
});

// =============================================================================
// Lifecycle
// =============================================================================

onMounted(() => {
  nextTick(() => {
    if (gridRef.value) {
      const focusableElements = gridRef.value.querySelectorAll<HTMLElement>(
        '[role="gridcell"] a[href], [role="gridcell"] button, [role="rowheader"] a[href], [role="rowheader"] button'
      );
      focusableElements.forEach((el) => {
        el.setAttribute('tabindex', '-1');
      });
    }
  });
});
</script>

<template>
  <div
    ref="gridRef"
    role="grid"
    :aria-label="ariaLabel"
    :aria-labelledby="ariaLabelledby"
    :aria-multiselectable="showMultiselectable ? 'true' : undefined"
    :aria-readonly="readonly ? 'true' : undefined"
    :aria-rowcount="totalRows"
    :aria-colcount="totalColumns"
    class="apg-data-grid"
    :style="{ '--apg-data-grid-columns': columns.length }"
  >
    <!-- Header Row -->
    <div role="row" :aria-rowindex="totalRows ? 1 : undefined">
      <!-- Checkbox header -->
      <div
        v-if="rowSelectable"
        :ref="(el) => rowMultiselectable && setCellRef('header-checkbox', el as HTMLDivElement)"
        role="columnheader"
        :tabindex="rowMultiselectable ? (focusedId === 'header-checkbox' ? 0 : -1) : undefined"
        :aria-colindex="totalColumns ? startColIndex : undefined"
        :class="[
          'apg-data-grid-header apg-data-grid-checkbox-cell',
          { focused: focusedId === 'header-checkbox' },
        ]"
        @keydown="rowMultiselectable && handleHeaderCheckboxKeyDown($event)"
        @focusin="rowMultiselectable && setFocusedId('header-checkbox')"
      >
        <input
          v-if="rowMultiselectable"
          type="checkbox"
          tabindex="-1"
          :checked="getSelectAllState() === 'all'"
          :indeterminate="getSelectAllState() === 'some'"
          aria-label="Select all rows"
          @change.stop="toggleAllRowSelection"
        />
      </div>
      <!-- Column headers -->
      <div
        v-for="(col, colIndex) in columns"
        :key="col.id"
        :ref="(el) => col.sortable && setHeaderRef(col.id, el as HTMLDivElement)"
        role="columnheader"
        :tabindex="col.sortable ? (focusedId === `header-${col.id}` ? 0 : -1) : undefined"
        :aria-colindex="
          totalColumns ? startColIndex + colIndex + (rowSelectable ? 1 : 0) : undefined
        "
        :aria-colspan="col.colspan"
        :aria-sort="col.sortable ? col.sortDirection || 'none' : undefined"
        class="apg-data-grid-header"
        :class="{ sortable: col.sortable, focused: focusedId === `header-${col.id}` }"
        @keydown="col.sortable && handleHeaderKeyDown($event, col)"
        @focusin="col.sortable && setFocusedId(`header-${col.id}`)"
        @click="col.sortable && handleSort(col.id)"
      >
        {{ col.header }}
        <span
          v-if="col.sortable"
          aria-hidden="true"
          :class="[
            'sort-indicator',
            { unsorted: !col.sortDirection || col.sortDirection === 'none' },
          ]"
        >
          {{ getSortIndicator(col.sortDirection) }}
        </span>
      </div>
    </div>

    <!-- Data Rows -->
    <div
      v-for="(row, rowIndex) in rows"
      :key="row.id"
      role="row"
      :aria-rowindex="totalRows ? startRowIndex + rowIndex + 1 : undefined"
      :aria-selected="
        rowSelectable ? (selectedRowIds.includes(row.id) ? 'true' : 'false') : undefined
      "
      :aria-disabled="row.disabled ? 'true' : undefined"
    >
      <!-- Row selection checkbox -->
      <div
        v-if="rowSelectable"
        :ref="(el) => setCellRef(`checkbox-${row.id}`, el as HTMLDivElement)"
        role="gridcell"
        :tabindex="focusedId === `checkbox-${row.id}` ? 0 : -1"
        :aria-colindex="totalColumns ? startColIndex : undefined"
        :class="[
          'apg-data-grid-cell',
          'apg-data-grid-checkbox-cell',
          { focused: focusedId === `checkbox-${row.id}` },
        ]"
        @keydown="handleCheckboxCellKeyDown($event, row.id, row)"
        @focus="setFocusedId(`checkbox-${row.id}`)"
        @click="handleCheckboxCellClick(`checkbox-${row.id}`)"
      >
        <input
          type="checkbox"
          tabindex="-1"
          :checked="selectedRowIds.includes(row.id)"
          :disabled="row.disabled"
          :aria-labelledby="rowLabelColumn ? `cell-${row.id}-${rowLabelColumn.id}` : undefined"
          @change.stop="toggleRowSelection(row.id, row)"
        />
      </div>

      <!-- Data cells -->
      <div
        v-for="(cell, colIndex) in row.cells"
        :key="cell.id"
        :id="
          rowLabelColumn && columns[colIndex]?.id === rowLabelColumn.id
            ? `cell-${row.id}-${columns[colIndex].id}`
            : undefined
        "
        :ref="(el) => setCellRef(cell.id, el as HTMLDivElement)"
        :role="row.hasRowHeader && colIndex === 0 ? 'rowheader' : 'gridcell'"
        :tabindex="focusedId === cell.id && editingCellId !== cell.id ? 0 : -1"
        :aria-selected="getAriaSelected(selectable, rowSelectable, selectedIds.includes(cell.id))"
        :aria-disabled="cell.disabled || row.disabled ? 'true' : undefined"
        :aria-colindex="
          totalColumns ? startColIndex + colIndex + (rowSelectable ? 1 : 0) : undefined
        "
        :aria-colspan="cell.colspan"
        :aria-rowspan="cell.rowspan"
        :aria-readonly="
          getAriaReadonly(
            editable,
            cell.readonly,
            isCellEditable(cell, columns[colIndex]?.id ?? '')
          )
        "
        class="apg-data-grid-cell"
        :class="{
          focused: focusedId === cell.id,
          selected: selectedIds.includes(cell.id),
          disabled: cell.disabled || row.disabled,
          editing: editingCellId === cell.id,
          editable:
            editable &&
            isCellEditable(cell, columns[colIndex]?.id ?? '') &&
            !cell.disabled &&
            !row.disabled &&
            editingCellId !== cell.id,
        }"
        @keydown="handleCellKeyDown($event, cell, row.id, columns[colIndex]?.id ?? '')"
        @focusin="editingCellId !== cell.id && setFocusedId(cell.id)"
        @dblclick="
          isCellEditable(cell, columns[colIndex]?.id ?? '') &&
          startEdit(cell.id, row.id, columns[colIndex]?.id ?? '')
        "
      >
        <!-- Edit mode -->
        <template v-if="editingCellId === cell.id">
          <!-- Select -->
          <select
            v-if="getColumnEditType(columns[colIndex]?.id ?? '') === 'select'"
            ref="selectRef"
            v-model="editValue"
            class="apg-data-grid-select"
            @blur="endEdit(cell.id, false)"
            @keydown="handleSelectKeyDown($event, cell.id)"
            @change="
              emit('cellValueChange', cell.id, editValue);
              endEdit(cell.id, false, editValue);
            "
          >
            <option
              v-for="option in getColumnOptions(columns[colIndex]?.id ?? '')"
              :key="option"
              :value="option"
            >
              {{ option }}
            </option>
          </select>
          <!-- Combobox -->
          <div
            v-else-if="getColumnEditType(columns[colIndex]?.id ?? '') === 'combobox'"
            class="apg-data-grid-combobox"
          >
            <input
              ref="inputRef"
              v-model="editValue"
              type="text"
              role="combobox"
              :aria-expanded="comboboxExpanded"
              :aria-controls="`${cell.id}-listbox`"
              aria-autocomplete="list"
              :aria-activedescendant="
                comboboxActiveIndex >= 0 ? `${cell.id}-option-${comboboxActiveIndex}` : undefined
              "
              class="apg-data-grid-input"
              @blur="handleComboboxBlur($event, cell.id)"
              @keydown="handleComboboxKeyDown($event, cell.id, columns[colIndex]?.id ?? '')"
              @input="handleComboboxInput(cell.id, columns[colIndex]?.id ?? '')"
            />
            <ul
              v-if="comboboxExpanded && filteredOptions.length > 0"
              :id="`${cell.id}-listbox`"
              ref="listboxRef"
              role="listbox"
              class="apg-data-grid-listbox"
            >
              <li
                v-for="(option, optIndex) in filteredOptions"
                :id="`${cell.id}-option-${optIndex}`"
                :key="option"
                role="option"
                :aria-selected="optIndex === comboboxActiveIndex"
                class="apg-data-grid-option"
                :class="{ active: optIndex === comboboxActiveIndex }"
                @mousedown.prevent="handleOptionClick(option, cell.id)"
              >
                {{ option }}
              </li>
            </ul>
          </div>
          <!-- Text input (default) -->
          <input
            v-else
            ref="inputRef"
            v-model="editValue"
            type="text"
            class="apg-data-grid-input"
            @blur="endEdit(cell.id, false)"
            @keydown="handleInputKeyDown($event, cell.id)"
            @input="emit('cellValueChange', cell.id, editValue)"
          />
        </template>
        <!-- Display mode -->
        <template v-else>
          <slot name="cell" :cell="cell" :row-id="row.id" :col-id="columns[colIndex]?.id ?? ''">
            {{ cell.value }}
          </slot>
        </template>
      </div>
    </div>
  </div>
</template>

使い方

Example
<script setup lang="ts">
import { ref } from 'vue';
import DataGrid from './DataGrid.vue';
import type { DataGridColumnDef, DataGridRowData, SortDirection } from './DataGrid.vue';

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

const rows = ref<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' },
    ],
  },
]);

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

function handleSort(columnId: string, direction: SortDirection) {
  columns.value = columns.value.map(col => ({
    ...col,
    sortDirection: col.id === columnId ? direction : 'none'
  }));
}
</script>

<template>
  <DataGrid
    :columns="columns"
    :rows="rows"
    aria-label="User list"
    row-selectable
    row-multiselectable
    :selected-row-ids="selectedRowIds"
    @sort="handleSort"
    @row-selection-change="(ids) => selectedRowIds = ids"
    @edit-end="(cellId, value, cancelled) => console.log({ cellId, value, cancelled })"
  />
</template>

API

プロパティデフォルト説明
columnsDataGridColumnDef[]required列定義
rowsDataGridRowData[]required行データ
row-selectablebooleanfalse行選択を有効化
enable-range-selectionbooleanfalse範囲選択を有効化
editablebooleanfalseセル編集を有効化

Custom Events

イベントDetail説明
sort(columnId, direction)列がソートされた時に発火
row-selection-changestring[]行選択が変更された時に発火
edit-end(cellId, value, cancelled)セル編集が終了した時に発火

テスト

テストは、キーボード操作、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 rowsrowSelectableの場合、行にaria-selectedがある
aria-readonly on gridreadonlyプロップの場合、グリッドに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 firesonRowSelectionChangeコールバックが発火
Select all全選択チェックボックスがすべての行を選択/解除
Indeterminate一部選択時に全選択が不確定状態を表示

高優先度: セル編集

テスト説明
Enter starts edit編集可能なセルでEnterが編集モードに入る
F2 starts edit編集可能なセルでF2が編集モードに入る
Escape cancelsEscapeが編集をキャンセルして元の値を復元
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-rowcounttotalRows提供時に存在(1ベース)
aria-colcounttotalColumns提供時に存在(1ベース)
aria-rowindex仮想化時に行に存在(1ベース)
aria-colindex仮想化時にセルに存在(1ベース)

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

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

テストツール

詳細はテスト戦略ガイドを参照してください。

リソース