APG Patterns
日本語
日本語

Data Grid

An advanced interactive data grid with sorting, row selection, range selection, and cell editing capabilities.

Demo

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.

Open demo only →

Keyboard Navigation
Navigation
Move between cells
Home / End
First / last cell in row
Ctrl + Home
First cell in grid
Ctrl + End
Last cell in grid
Page Up / Page Down
Jump by page
Sorting
Enter / Space
Sort column (on header)
Selection
Space
Toggle row selection (on checkbox)
Shift + Arrow
Extend cell selection
Ctrl + A
Select all cells
Editing
Enter / F2
Start editing cell
Escape
Cancel editing
Tab
Move within cell widgets

Data Grid vs Grid

Data Grid extends the basic Grid pattern with additional features for data manipulation.

Feature Grid Data Grid
2D Navigation Yes Yes
Cell Selection Yes Yes
Column Sorting No Yes (aria-sort)
Row Selection No Yes (checkbox)
Range Selection No Yes (Shift+Arrow)
Cell Editing No Yes (Enter/F2)

Accessibility Features

When to Use Data Grid

Data Grid extends the basic grid role with spreadsheet-like features. Use Data Grid when you need:

  • Sorting: Column headers that sort data on click/keyboard
  • Row Selection: Checkbox-based selection of entire rows
  • Range Selection: Shift+Arrow to select multiple cells
  • Cell Editing: In-place editing with Enter/F2 to start, Escape to cancel

For simple static tables, use native

elements. For interactive grids without these features, use the basic grid pattern.

WAI-ARIA Roles

Role Target Element Description
grid Container Identifies the element as a grid. The grid contains rows of cells.
row Each row Identifies a row of cells
gridcell Each cell Identifies an interactive cell in the grid
rowheader Row header cell Identifies a cell as a header for its row
columnheader Column header cell Identifies a cell as a header for its column

APG Data Grids Examples (opens in new tab)

WAI-ARIA Properties (Grid Container)

Attribute Values Required Description
role="grid" - Yes Identifies the container as a grid
aria-label String Yes* Accessible name for the grid
aria-labelledby ID reference Yes* Alternative to aria-label
aria-multiselectable true No Present when multi-select (row or cell) is enabled
aria-readonly true No Present when entire grid is read-only
aria-rowcount Number (1-based) No Total rows for virtualization
aria-colcount Number (1-based) No Total columns for virtualization

* Either aria-label or aria-labelledby is required.

WAI-ARIA States (Column Headers)

Attribute Values Required Description
aria-sort ascending | descending | none | other Yes* Current sort direction (only on sortable headers)
tabindex 0 | -1 Yes* Roving tabindex (only on sortable headers)

* Only required for sortable column headers.

WAI-ARIA States (Rows)

Attribute Values Required Description
aria-selected true | false Yes* Row selection state (when rowSelectable)
aria-disabled true No Indicates the row is disabled
aria-rowindex Number (1-based) No Row position for virtualization

* Only required when row selection is enabled.

WAI-ARIA States (Grid Cells)

Attribute Values Required Description
tabindex 0 | -1 Yes Roving tabindex for focus management
aria-selected true | false No* Cell selection state (when cell selectable)
aria-readonly true | false No* Cell editability (when grid is editable)
aria-disabled true No Indicates the cell is disabled
aria-colindex Number (1-based) No Column position for virtualization

* When selection/editing is supported, ALL gridcells should have the corresponding attribute.

Keyboard Support

2D Navigation

Key Action
Move focus one cell right
Move focus one cell left
Move focus one row down (from header to first data row)
Move focus one row up (from first row to header if sortable)
Home Move focus to first cell in row
End Move focus to last cell in row
Ctrl + Home Move focus to first cell in grid
Ctrl + End Move focus to last cell in grid
PageDown Move focus down by page size
PageUp Move focus up by page size

Sorting (Column Headers)

Key Action
Enter Cycle sort direction (none → ascending → descending → ascending)
Space Cycle sort direction

Range Selection

Key Action
Shift + Extend selection downward
Shift + Extend selection upward
Shift + Extend selection rightward
Shift + Extend selection leftward
Shift + Home Extend selection to row start
Shift + End Extend selection to row end
Ctrl + Shift + Home Extend selection to grid start
Ctrl + Shift + End Extend selection to grid end

Cell Editing

Key Action
Enter Enter edit mode (on editable cell) or commit edit (in edit mode)
F2 Enter edit mode
Escape Cancel edit and restore original value
Tab Commit edit (in edit mode)

Selection & Activation

Key Action
Space Select/deselect focused cell or toggle row checkbox
Ctrl + A Select all cells (when multiselectable)

Focus Management

This component uses roving tabindex for focus management:

  • Only one element has tabindex="0" (current focus position)
  • All other focusable elements have tabindex="-1"
  • Grid is a single Tab stop (Tab enters grid, Shift+Tab exits)
  • Sortable column headers participate in roving tabindex (non-sortable headers are not focusable)
  • Arrow Up from first data row moves to sortable header at same column
  • Arrow Down from header moves to first data row at same column
  • Edit mode: Focus moves to input field, grid navigation is disabled until edit ends
  • Focus memory: last focused element is remembered when leaving and re-entering the grid

Row Selection Behavior

  • Row selection checkboxes are placed in a dedicated column (gridcell)
  • Each row checkbox has an accessible label (e.g., "Select row user1")
  • When rowMultiselectable is true, a "Select all" checkbox appears in the header
  • Select all checkbox shows indeterminate state when some (but not all) rows are selected
  • Row selection state is reflected via aria-selected on the row element

Edit Mode Behavior

  • Only cells with editable: true can enter edit mode
  • Cells with readonly: true cannot be edited even if editable
  • Grid-level readonly prop disables all editing
  • aria-readonly on cells indicates editability to screen readers
  • When editing, focus is on the input field inside the cell
  • Grid keyboard navigation (arrows, Home, End) is suppressed during edit mode
  • On edit end (Enter, Tab, blur), focus returns to the cell
  • On cancel (Escape), original value is restored

Source Code

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>

Usage

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) {
  // Update column sort direction and re-sort rows
  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

Props

Prop Type Default Description
columns DataGridColumnDef[] required Column definitions
rows DataGridRowData[] required Row data
row-selectable boolean false Enable row selection
enable-range-selection boolean false Enable range selection
editable boolean false Enable cell editing

Events

Event Payload Description
sort (columnId, direction) Emitted when a column is sorted
row-selection-change string[] Emitted when row selection changes
edit-end (cellId, value, cancelled) Emitted when cell editing ends

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The Data Grid component extends the basic Grid testing strategy with additional tests for sorting, row selection, range selection, and cell editing.

Testing Strategy

Unit Tests (Testing Library)

Verify component rendering and interactions using framework-specific Testing Library utilities. These tests ensure correct component behavior in isolation.

  • HTML structure and element hierarchy (grid, row, gridcell)
  • Initial attribute values (role, aria-label, tabindex, aria-sort)
  • Selection state changes (aria-selected on rows and cells)
  • Edit mode state (aria-readonly)
  • Sort direction updates (aria-sort)
  • CSS class application

E2E Tests (Playwright)

Verify component behavior in a real browser environment across all four frameworks. These tests cover interactions that require full browser context.

  • 2D keyboard navigation (Arrow keys)
  • Header navigation and sorting
  • Range selection with Shift+Arrow
  • Cell editing workflow (Enter, F2, Escape)
  • Row selection with checkboxes
  • Focus management between headers and cells
  • Cross-framework consistency

Test Categories

High Priority: APG ARIA Attributes

Test Description
role="grid" Container has grid role
role="row" All rows have row role
role="gridcell" Data cells have gridcell role
role="columnheader" Header cells have columnheader role
aria-sort Sortable headers have aria-sort
aria-sort updates aria-sort updates on sort action
aria-selected on rows Rows have aria-selected when rowSelectable
aria-readonly on grid Grid has aria-readonly when readonly prop
aria-readonly on cells Cells have aria-readonly based on editability
aria-multiselectable Present when row or cell multi-select enabled

High Priority: Sorting

Test Description
Enter on header Enter on sortable header triggers sort
Space on header Space on sortable header triggers sort
Sort cycle Sort cycles: none → ascending → descending → ascending
Non-sortable headers Non-sortable headers do not respond to Enter/Space

High Priority: Range Selection

Test Description
Shift+ArrowDown Extends selection downward
Shift+ArrowUp Extends selection upward
Shift+Home Extends selection to row start
Shift+End Extends selection to row end
Ctrl+Shift+Home Extends selection to grid start
Ctrl+Shift+End Extends selection to grid end
Selection anchor Selection anchor is set on first selection

High Priority: Row Selection

Test Description
Checkbox toggle Checkbox click toggles row selection
aria-selected aria-selected updates on row element
Callback fires onRowSelectionChange callback fires
Select all Select all checkbox selects/deselects all rows
Indeterminate Select all shows indeterminate when some selected

High Priority: Cell Editing

Test Description
Enter starts edit Enter on editable cell enters edit mode
F2 starts edit F2 on editable cell enters edit mode
Escape cancels Escape cancels edit and restores original value
Navigation disabled Grid navigation disabled during edit mode
Focus on input Focus moves to input field on edit start
Focus returns Focus returns to cell on edit end
onEditStart onEditStart callback fires when entering edit mode
onEditEnd onEditEnd callback fires when exiting edit mode
Readonly cell Readonly cell does not enter edit mode

High Priority: Focus Management

Test Description
Sortable headers focusable Sortable headers have tabindex
Non-sortable not focusable Non-sortable headers have no tabindex
First has tabindex=0 First focusable element has tabindex="0"
Header to data ArrowDown from header enters first data row
Data to header ArrowUp from first row enters sortable header
Roving tabindex Roving tabindex updates correctly

Medium Priority: Virtualization Support

Test Description
aria-rowcount Present when totalRows provided (1-based)
aria-colcount Present when totalColumns provided (1-based)
aria-rowindex Present on rows when virtualizing (1-based)
aria-colindex Present on cells when virtualizing (1-based)

Medium Priority: Accessibility

Test Description
axe-core No accessibility violations
Sort indicators Sort indicators have accessible names
Checkbox labels Checkboxes have accessible labels

Testing Tools

See testing-strategy.md (opens in new tab) for full documentation.

Resources