APG Patterns
日本語
日本語

Data Grid

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

Demo

Name
Email
Department
Alice Johnson
alice@example.com
Engineering
Bob Smith
bob@example.com
Marketing
Carol Williams
carol@example.com
Sales

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.astro
---
// =============================================================================
// 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 | undefined
): '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(
  isSelectable: boolean,
  isRowSelectable: boolean,
  isSelected: boolean
): 'true' | 'false' | undefined {
  if (!isSelectable) return undefined;
  if (isRowSelectable) 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;
  // Row Selection
  rowSelectable?: boolean;
  rowMultiselectable?: boolean;
  defaultSelectedRowIds?: string[];
  // Cell Editing
  editable?: boolean;
  readonly?: boolean;
  // Range Selection
  enableRangeSelection?: boolean;
  // Cell selection
  selectable?: boolean;
  multiselectable?: boolean;
  defaultSelectedIds?: string[];
  defaultFocusedId?: string;
  // Virtualization
  totalColumns?: number;
  totalRows?: number;
  startRowIndex?: number;
  startColIndex?: number;
  // Behavior
  wrapNavigation?: boolean;
  enablePageNavigation?: boolean;
  pageSize?: number;
  class?: string;
  renderCell?: (cell: DataGridCellData, rowId: string, colId: string) => string;
}

// =============================================================================
// Props
// =============================================================================

const {
  columns,
  rows,
  ariaLabel,
  ariaLabelledby,
  // Row Selection
  rowSelectable = false,
  rowMultiselectable = false,
  defaultSelectedRowIds = [],
  // Cell Editing
  editable = false,
  readonly = false,
  // Range Selection
  enableRangeSelection = false,
  // Cell selection
  selectable = false,
  multiselectable = false,
  defaultSelectedIds = [],
  defaultFocusedId,
  // Virtualization
  totalColumns,
  totalRows,
  startRowIndex = 1,
  startColIndex = 1,
  // Behavior
  wrapNavigation = false,
  enablePageNavigation = false,
  pageSize = 5,
  class: className,
  renderCell,
} = Astro.props;

// Determine aria-multiselectable
const ariaMultiselectable =
  (rowSelectable && rowMultiselectable) || (selectable && multiselectable);

// Determine if we need checkbox column
const hasCheckboxColumn = rowSelectable;

// 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 = columns.find((col) => col.isRowLabel) ?? columns[0];

// Calculate effective column count
const effectiveColCount = hasCheckboxColumn ? columns.length + 1 : columns.length;

// Determine initial focused cell 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
// Note: Sortable headers are focusable via arrow navigation but not the initial Tab stop
const getInitialFocusedId = () => {
  if (defaultFocusedId) return defaultFocusedId;
  if (rowSelectable && rowMultiselectable) return 'header-checkbox';
  if (rowSelectable) return rows[0] ? `checkbox-${rows[0].id}` : null;
  return rows[0]?.cells[0]?.id ?? null;
};
const initialFocusedId = getInitialFocusedId();
---

<apg-data-grid
  class={`apg-data-grid ${className ?? ''}`}
  style={`--apg-data-grid-columns: ${columns.length}`}
  data-wrap-navigation={wrapNavigation}
  data-enable-page-navigation={enablePageNavigation}
  data-page-size={pageSize}
  data-selectable={selectable}
  data-multiselectable={multiselectable}
  data-row-selectable={rowSelectable}
  data-row-multiselectable={rowMultiselectable}
  data-editable={editable}
  data-readonly={readonly}
  data-enable-range-selection={enableRangeSelection}
>
  <div
    role="grid"
    aria-label={ariaLabel}
    aria-labelledby={ariaLabelledby}
    aria-multiselectable={ariaMultiselectable ? 'true' : undefined}
    aria-readonly={readonly ? 'true' : undefined}
    aria-rowcount={totalRows}
    aria-colcount={totalColumns ?? effectiveColCount}
  >
    {/* Header Row */}
    <div role="row" aria-rowindex={totalRows ? 1 : undefined}>
      {
        hasCheckboxColumn &&
          (() => {
            const isHeaderCheckboxFocused = initialFocusedId === 'header-checkbox';
            return (
              <div
                role="columnheader"
                class={`apg-data-grid-header apg-data-grid-checkbox-cell ${isHeaderCheckboxFocused ? 'focused' : ''}`}
                tabindex={rowMultiselectable ? (isHeaderCheckboxFocused ? 0 : -1) : undefined}
                aria-colindex={totalColumns ? startColIndex : undefined}
                data-cell-id="header-checkbox"
                data-header-checkbox="true"
              >
                {rowMultiselectable && (
                  <input
                    type="checkbox"
                    tabindex={-1}
                    aria-label="Select all rows"
                    data-select-all="true"
                  />
                )}
              </div>
            );
          })()
      }
      {
        columns.map((col, colIndex) => {
          const headerId = `header-${col.id}`;
          const isFocused = initialFocusedId === headerId;
          const ariaColIndex = totalColumns
            ? startColIndex + colIndex + (hasCheckboxColumn ? 1 : 0)
            : undefined;

          return (
            <div
              role="columnheader"
              class={`apg-data-grid-header ${col.sortable ? 'sortable' : ''} ${isFocused ? 'focused' : ''}`}
              tabindex={col.sortable ? (isFocused ? 0 : -1) : undefined}
              aria-sort={col.sortable ? (col.sortDirection ?? 'none') : undefined}
              aria-colindex={ariaColIndex}
              aria-colspan={col.colspan}
              data-header-id={headerId}
              data-col-id={col.id}
              data-col-index={colIndex}
              data-sortable={col.sortable ? 'true' : undefined}
              data-sort-direction={col.sortDirection}
            >
              {col.header}
              {col.sortable && (
                <span
                  class={`sort-indicator ${!col.sortDirection || col.sortDirection === 'none' ? 'unsorted' : ''}`}
                  aria-hidden="true"
                >
                  {getSortIndicator(col.sortDirection)}
                </span>
              )}
            </div>
          );
        })
      }
    </div>

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

        return (
          <div
            role="row"
            aria-rowindex={totalRows ? startRowIndex + rowIndex + 1 : undefined}
            aria-selected={rowSelectable ? (isRowSelected ? 'true' : 'false') : undefined}
            aria-disabled={row.disabled ? 'true' : undefined}
            data-row-id={row.id}
            data-row-index={rowIndex}
          >
            {hasCheckboxColumn &&
              (() => {
                const checkboxCellId = `checkbox-${row.id}`;
                const isCheckboxFocused = initialFocusedId === checkboxCellId;
                return (
                  <div
                    role="gridcell"
                    class={`apg-data-grid-cell apg-data-grid-checkbox-cell ${isCheckboxFocused ? 'focused' : ''}`}
                    tabindex={isCheckboxFocused ? 0 : -1}
                    aria-colindex={totalColumns ? startColIndex : undefined}
                    data-checkbox-cell="true"
                    data-checkbox-id={checkboxCellId}
                    data-row-id={row.id}
                    data-row-index={rowIndex}
                  >
                    <input
                      type="checkbox"
                      tabindex={-1}
                      checked={isRowSelected}
                      disabled={row.disabled}
                      aria-labelledby={
                        rowLabelColumn ? `cell-${row.id}-${rowLabelColumn.id}` : undefined
                      }
                      data-row-checkbox="true"
                      data-row-id={row.id}
                    />
                  </div>
                );
              })()}
            {row.cells.map((cell, colIndex) => {
              const isRowHeader = row.hasRowHeader && colIndex === 0;
              const isFocused = cell.id === initialFocusedId;
              const isSelected = defaultSelectedIds.includes(cell.id);
              const colId = columns[colIndex]?.id ?? '';
              const isDisabled = cell.disabled || row.disabled;
              const cellEditable =
                editable && cell.editable && !cell.readonly && !readonly && !isDisabled;
              const ariaColIndex = totalColumns
                ? startColIndex + colIndex + (hasCheckboxColumn ? 1 : 0)
                : undefined;
              const isLabelColumn = rowLabelColumn && colId === rowLabelColumn.id;

              return (
                <div
                  id={isLabelColumn ? `cell-${row.id}-${colId}` : undefined}
                  role={isRowHeader ? 'rowheader' : 'gridcell'}
                  tabindex={isFocused ? 0 : -1}
                  aria-selected={getAriaSelected(selectable, rowSelectable, isSelected)}
                  aria-disabled={isDisabled ? 'true' : undefined}
                  aria-readonly={getAriaReadonly(editable, cell.readonly, cellEditable)}
                  aria-colindex={ariaColIndex}
                  aria-colspan={cell.colspan}
                  aria-rowspan={cell.rowspan}
                  data-cell-id={cell.id}
                  data-row-id={row.id}
                  data-col-id={colId}
                  data-row-index={rowIndex}
                  data-col-index={colIndex}
                  data-disabled={isDisabled ? 'true' : undefined}
                  data-editable={cellEditable ? 'true' : undefined}
                  class={`apg-data-grid-cell ${isFocused ? 'focused' : ''} ${isSelected ? 'selected' : ''} ${isDisabled ? 'disabled' : ''}`}
                >
                  {renderCell ? (
                    <Fragment set:html={renderCell(cell, row.id, colId)} />
                  ) : (
                    cell.value
                  )}
                </div>
              );
            })}
          </div>
        );
      })
    }
  </div>
</apg-data-grid>

<script>
  class ApgDataGrid extends HTMLElement {
    private focusedId: string | null = null;
    private selectedIds: Set<string> = new Set();
    private selectedRowIds: Set<string> = new Set();
    private anchorCellId: string | null = null;
    private isEditing = false;
    private editingCellId: string | null = null;
    private editValue = '';
    private originalEditValue = '';
    private isEndingEdit = false;

    // Options
    private wrapNavigation = false;
    private enablePageNavigation = false;
    private pageSize = 5;
    private selectable = false;
    private multiselectable = false;
    private rowSelectable = false;
    private rowMultiselectable = false;
    private editable = false;
    private readonly = false;
    private enableRangeSelection = false;

    connectedCallback() {
      // Load options from data attributes
      this.wrapNavigation = this.dataset.wrapNavigation === 'true';
      this.enablePageNavigation = this.dataset.enablePageNavigation === 'true';
      this.pageSize = parseInt(this.dataset.pageSize || '5', 10);
      this.selectable = this.dataset.selectable === 'true';
      this.multiselectable = this.dataset.multiselectable === 'true';
      this.rowSelectable = this.dataset.rowSelectable === 'true';
      this.rowMultiselectable = this.dataset.rowMultiselectable === 'true';
      this.editable = this.dataset.editable === 'true';
      this.readonly = this.dataset.readonly === 'true';
      this.enableRangeSelection = this.dataset.enableRangeSelection === 'true';

      // Find initial focused element (header or cell)
      const focusedEl = this.querySelector<HTMLElement>('[tabindex="0"]');
      this.focusedId = focusedEl?.dataset.headerId ?? focusedEl?.dataset.cellId ?? null;

      // Load initial selected ids
      this.querySelectorAll<HTMLElement>('[aria-selected="true"]').forEach((el) => {
        const cellId = el.dataset.cellId;
        if (cellId) this.selectedIds.add(cellId);
      });

      // Load initial selected row ids
      this.querySelectorAll<HTMLElement>('[role="row"][aria-selected="true"]').forEach((el) => {
        const rowId = el.dataset.rowId;
        if (rowId) this.selectedRowIds.add(rowId);
      });

      // Set tabindex="-1" on all focusable elements inside grid cells
      this.querySelectorAll<HTMLElement>(
        '[role="gridcell"] a[href], [role="gridcell"] button:not([data-row-checkbox]), [role="rowheader"] a[href], [role="rowheader"] button'
      ).forEach((el) => {
        el.setAttribute('tabindex', '-1');
      });

      // Add event listeners to sortable headers
      this.querySelectorAll<HTMLElement>('[role="columnheader"][data-sortable="true"]').forEach(
        (header) => {
          header.addEventListener('keydown', this.handleHeaderKeyDown.bind(this) as EventListener);
          header.addEventListener('click', this.handleHeaderClick.bind(this) as EventListener);
          header.addEventListener('focusin', this.handleHeaderFocus.bind(this) as EventListener);
        }
      );

      // Add event listeners to all cells
      this.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]').forEach(
        (cell) => {
          if (cell.dataset.checkboxCell !== 'true') {
            cell.addEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
            cell.addEventListener('focusin', this.handleFocus.bind(this) as EventListener);
            cell.addEventListener(
              'dblclick',
              this.handleCellDoubleClick.bind(this) as EventListener
            );
          }
        }
      );

      // Add event listeners to checkbox cells (for keyboard navigation)
      this.querySelectorAll<HTMLElement>('[data-checkbox-cell="true"]').forEach((cell) => {
        cell.addEventListener(
          'keydown',
          this.handleCheckboxCellKeyDown.bind(this) as EventListener
        );
        cell.addEventListener('focusin', this.handleCheckboxCellFocus.bind(this) as EventListener);
        cell.addEventListener('click', this.handleCheckboxCellClick.bind(this) as EventListener);
      });

      // Add event listeners to row checkboxes
      this.querySelectorAll<HTMLInputElement>('[data-row-checkbox="true"]').forEach((checkbox) => {
        checkbox.addEventListener(
          'change',
          this.handleRowCheckboxChange.bind(this) as EventListener
        );
      });

      // Add event listener to header checkbox cell (Select all)
      const headerCheckboxCell = this.querySelector<HTMLElement>('[data-header-checkbox="true"]');
      if (headerCheckboxCell) {
        headerCheckboxCell.addEventListener(
          'keydown',
          this.handleHeaderCheckboxKeyDown.bind(this) as EventListener
        );
        headerCheckboxCell.addEventListener(
          'focusin',
          this.handleHeaderCheckboxFocus.bind(this) as EventListener
        );
      }

      // Add event listener to select all checkbox
      const selectAllCheckbox = this.querySelector<HTMLInputElement>('[data-select-all="true"]');
      if (selectAllCheckbox) {
        selectAllCheckbox.addEventListener(
          'change',
          this.handleSelectAllChange.bind(this) as EventListener
        );
        this.updateSelectAllState();
      }
    }

    disconnectedCallback() {
      this.querySelectorAll<HTMLElement>('[role="columnheader"][data-sortable="true"]').forEach(
        (header) => {
          header.removeEventListener(
            'keydown',
            this.handleHeaderKeyDown.bind(this) as EventListener
          );
          header.removeEventListener('click', this.handleHeaderClick.bind(this) as EventListener);
          header.removeEventListener('focusin', this.handleHeaderFocus.bind(this) as EventListener);
        }
      );

      this.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]').forEach(
        (cell) => {
          cell.removeEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
          cell.removeEventListener('focusin', this.handleFocus.bind(this) as EventListener);
        }
      );

      this.querySelectorAll<HTMLElement>('[data-checkbox-cell="true"]').forEach((cell) => {
        cell.removeEventListener(
          'keydown',
          this.handleCheckboxCellKeyDown.bind(this) as EventListener
        );
        cell.removeEventListener(
          'focusin',
          this.handleCheckboxCellFocus.bind(this) as EventListener
        );
        cell.removeEventListener('click', this.handleCheckboxCellClick.bind(this) as EventListener);
      });

      this.querySelectorAll<HTMLInputElement>('[data-row-checkbox="true"]').forEach((checkbox) => {
        checkbox.removeEventListener(
          'change',
          this.handleRowCheckboxChange.bind(this) as EventListener
        );
      });

      // Remove header checkbox cell listeners
      const headerCheckboxCell = this.querySelector<HTMLElement>('[data-header-checkbox="true"]');
      if (headerCheckboxCell) {
        headerCheckboxCell.removeEventListener(
          'keydown',
          this.handleHeaderCheckboxKeyDown.bind(this) as EventListener
        );
        headerCheckboxCell.removeEventListener(
          'focusin',
          this.handleHeaderCheckboxFocus.bind(this) as EventListener
        );
      }

      const selectAllCheckbox = this.querySelector<HTMLInputElement>('[data-select-all="true"]');
      if (selectAllCheckbox) {
        selectAllCheckbox.removeEventListener(
          'change',
          this.handleSelectAllChange.bind(this) as EventListener
        );
      }
    }

    // =============================================================================
    // Helper methods
    // =============================================================================

    private getDataRows(): HTMLElement[] {
      return Array.from(this.querySelectorAll<HTMLElement>('[role="row"]')).slice(1);
    }

    private getColumnCount(): number {
      const headers = this.querySelectorAll('[role="columnheader"]');
      // Count data columns (exclude checkbox column header)
      const dataColumnCount = Array.from(headers).filter(
        (h) => !(h as HTMLElement).classList.contains('apg-data-grid-checkbox-cell')
      ).length;
      // Include checkbox column if rowSelectable
      return this.rowSelectable ? dataColumnCount + 1 : dataColumnCount;
    }

    private getDataColumnCount(): number {
      const headers = this.querySelectorAll('[role="columnheader"]');
      return Array.from(headers).filter(
        (h) => !(h as HTMLElement).classList.contains('apg-data-grid-checkbox-cell')
      ).length;
    }

    private getSortableHeaders(): HTMLElement[] {
      return Array.from(
        this.querySelectorAll<HTMLElement>('[role="columnheader"][data-sortable="true"]')
      );
    }

    private getCellAt(rowIndex: number, colIndex: number): HTMLElement | null {
      const rows = this.getDataRows();
      const row = rows[rowIndex];
      if (!row) return null;
      // Get only data cells (exclude checkbox cells)
      const cells = Array.from(
        row.querySelectorAll('[role="gridcell"], [role="rowheader"]')
      ).filter((c) => (c as HTMLElement).dataset.checkboxCell !== 'true');
      return cells[colIndex] as HTMLElement | null;
    }

    private focusElement(el: HTMLElement, id: string) {
      const currentFocused = this.querySelector('[tabindex="0"]');
      if (currentFocused && currentFocused !== el) {
        currentFocused.setAttribute('tabindex', '-1');
        currentFocused.classList.remove('focused');
      }
      el.setAttribute('tabindex', '0');
      el.classList.add('focused');
      el.focus();
      this.focusedId = id;
    }

    private focusCell(cell: HTMLElement) {
      const cellId = cell.dataset.cellId ?? null;
      if (!cellId) return;

      const focusableChild = cell.querySelector<HTMLElement>(
        'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
      );
      if (focusableChild) {
        focusableChild.setAttribute('tabindex', '-1');
        const currentFocused = this.querySelector('[tabindex="0"]');
        if (currentFocused && currentFocused !== cell) {
          currentFocused.setAttribute('tabindex', '-1');
          currentFocused.classList.remove('focused');
        }
        cell.setAttribute('tabindex', '0');
        cell.classList.add('focused');
        focusableChild.focus();
        this.focusedId = cellId;
      } else {
        this.focusElement(cell, cellId);
      }
    }

    private focusHeader(header: HTMLElement) {
      const headerId = header.dataset.headerId ?? null;
      if (!headerId) return;
      this.focusElement(header, headerId);
    }

    private focusCheckboxCell(cell: HTMLElement) {
      const checkboxId = cell.dataset.checkboxId ?? null;
      if (!checkboxId) return;
      this.focusElement(cell, checkboxId);
    }

    private getCheckboxCellAt(rowIndex: number): HTMLElement | null {
      const rows = this.getDataRows();
      const row = rows[rowIndex];
      if (!row) return null;
      return row.querySelector<HTMLElement>('[data-checkbox-cell="true"]');
    }

    private handleCheckboxCellFocus(event: Event) {
      const cell = event.currentTarget as HTMLElement;
      const checkboxId = cell.dataset.checkboxId;
      if (!checkboxId) return;

      const currentFocused = this.querySelector('[tabindex="0"]');
      if (currentFocused && currentFocused !== cell) {
        currentFocused.setAttribute('tabindex', '-1');
        currentFocused.classList.remove('focused');
      }
      cell.setAttribute('tabindex', '0');
      cell.classList.add('focused');
      this.focusedId = checkboxId;
    }

    private handleHeaderCheckboxFocus(event: Event) {
      const cell = event.currentTarget as HTMLElement;
      const currentFocused = this.querySelector('[tabindex="0"]');
      if (currentFocused && currentFocused !== cell) {
        currentFocused.setAttribute('tabindex', '-1');
        currentFocused.classList.remove('focused');
      }
      cell.setAttribute('tabindex', '0');
      cell.classList.add('focused');
      this.focusedId = 'header-checkbox';
    }

    private handleHeaderCheckboxKeyDown(event: KeyboardEvent) {
      const cell = event.currentTarget as HTMLElement;
      const { key, ctrlKey } = event;
      let handled = true;

      switch (key) {
        case 'ArrowRight': {
          // Move to first sortable header or first data cell
          const sortableHeaders = this.getSortableHeaders();
          if (sortableHeaders.length > 0) {
            this.focusHeader(sortableHeaders[0]);
          } else {
            const firstCell = this.getCellAt(0, 0);
            if (firstCell) {
              this.focusCell(firstCell);
            }
          }
          break;
        }
        case 'ArrowLeft':
          // Already at leftmost position, do nothing
          handled = false;
          break;
        case 'ArrowDown': {
          // Move to first row checkbox cell
          const firstCheckboxCell = this.getCheckboxCellAt(0);
          if (firstCheckboxCell) {
            this.focusCheckboxCell(firstCheckboxCell);
          }
          break;
        }
        case 'ArrowUp':
          // Already at topmost position, do nothing
          handled = false;
          break;
        case 'Home':
          if (ctrlKey) {
            // Already at first position
            handled = false;
          }
          break;
        case 'End': {
          if (ctrlKey) {
            // Move to last cell in grid
            const rowCount = this.getDataRows().length;
            const dataColCount = this.getDataColumnCount();
            const lastCell = this.getCellAt(rowCount - 1, dataColCount - 1);
            if (lastCell) {
              this.focusCell(lastCell);
            }
          } else {
            // Move to last header in row
            const sortableHeaders = this.getSortableHeaders();
            if (sortableHeaders.length > 0) {
              this.focusHeader(sortableHeaders[sortableHeaders.length - 1]);
            }
          }
          break;
        }
        case ' ':
        case 'Enter': {
          // Toggle select all checkbox
          const selectAllCheckbox = cell.querySelector<HTMLInputElement>(
            '[data-select-all="true"]'
          );
          if (selectAllCheckbox) {
            selectAllCheckbox.checked = !selectAllCheckbox.checked;
            selectAllCheckbox.dispatchEvent(new Event('change', { bubbles: true }));
          }
          break;
        }
        default:
          handled = false;
      }

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

    private handleCheckboxCellClick(event: MouseEvent) {
      const cell = event.currentTarget as HTMLElement;
      // Focus the cell after the checkbox change is processed
      requestAnimationFrame(() => {
        cell.focus();
        const checkboxId = cell.dataset.checkboxId;
        if (checkboxId) {
          this.focusedId = checkboxId;
        }
      });
    }

    private handleCheckboxCellKeyDown(event: KeyboardEvent) {
      const cell = event.currentTarget as HTMLElement;
      const { key, ctrlKey } = event;
      const rowIndex = parseInt(cell.dataset.rowIndex || '0', 10);
      const rowId = cell.dataset.rowId ?? '';
      let handled = true;

      switch (key) {
        case 'ArrowRight': {
          // Move to first data cell in the same row
          const firstDataCell = this.getCellAt(rowIndex, 0);
          if (firstDataCell) {
            this.focusCell(firstDataCell);
          }
          break;
        }
        case 'ArrowLeft': {
          // Already at leftmost position, do nothing
          handled = false;
          break;
        }
        case 'ArrowDown': {
          // Move to checkbox cell in next row
          const nextCheckboxCell = this.getCheckboxCellAt(rowIndex + 1);
          if (nextCheckboxCell) {
            this.focusCheckboxCell(nextCheckboxCell);
          }
          break;
        }
        case 'ArrowUp': {
          // Move to checkbox cell in previous row
          if (rowIndex > 0) {
            const prevCheckboxCell = this.getCheckboxCellAt(rowIndex - 1);
            if (prevCheckboxCell) {
              this.focusCheckboxCell(prevCheckboxCell);
            }
          }
          break;
        }
        case 'Home': {
          if (ctrlKey) {
            // Move to first checkbox cell
            const firstCheckboxCell = this.getCheckboxCellAt(0);
            if (firstCheckboxCell) {
              this.focusCheckboxCell(firstCheckboxCell);
            }
          }
          // Home without ctrl - already at start of row
          break;
        }
        case 'End': {
          if (ctrlKey) {
            // Move to last cell in grid
            const rowCount = this.getDataRows().length;
            const dataColCount = this.getDataColumnCount();
            const lastCell = this.getCellAt(rowCount - 1, dataColCount - 1);
            if (lastCell) {
              this.focusCell(lastCell);
            }
          } else {
            // Move to last cell in current row
            const dataColCount = this.getDataColumnCount();
            const lastCell = this.getCellAt(rowIndex, dataColCount - 1);
            if (lastCell) {
              this.focusCell(lastCell);
            }
          }
          break;
        }
        case ' ':
        case 'Enter': {
          // Toggle row selection
          const row = cell.closest('[role="row"]') as HTMLElement;
          if (row && rowId) {
            this.toggleRowSelection(rowId, row);
          }
          break;
        }
        default:
          handled = false;
      }

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

    private handleFocus(event: Event) {
      const cell = event.currentTarget as HTMLElement;
      const cellId = cell.dataset.cellId;
      if (!cellId) return;

      const currentFocused = this.querySelector('[tabindex="0"]');
      if (currentFocused && currentFocused !== cell) {
        currentFocused.setAttribute('tabindex', '-1');
        currentFocused.classList.remove('focused');
      }
      cell.setAttribute('tabindex', '0');
      cell.classList.add('focused');
      this.focusedId = cellId;
    }

    private handleHeaderFocus(event: Event) {
      const header = event.currentTarget as HTMLElement;
      const headerId = header.dataset.headerId;
      if (!headerId) return;

      const currentFocused = this.querySelector('[tabindex="0"]');
      if (currentFocused && currentFocused !== header) {
        currentFocused.setAttribute('tabindex', '-1');
        currentFocused.classList.remove('focused');
      }
      header.setAttribute('tabindex', '0');
      header.classList.add('focused');
      this.focusedId = headerId;
    }

    private findNextCell(
      rowIndex: number,
      colIndex: number,
      direction: 'right' | 'left' | 'up' | 'down'
    ): HTMLElement | null {
      const colCount = this.getColumnCount();
      const rowCount = this.getDataRows().length;

      let newRow = rowIndex;
      let newCol = colIndex;

      switch (direction) {
        case 'right':
          newCol++;
          if (newCol >= colCount) {
            if (this.wrapNavigation) {
              newCol = 0;
              newRow++;
            } else {
              return null;
            }
          }
          break;
        case 'left':
          newCol--;
          if (newCol < 0) {
            if (this.wrapNavigation) {
              newCol = colCount - 1;
              newRow--;
            } else {
              return null;
            }
          }
          break;
        case 'down':
          newRow++;
          break;
        case 'up':
          newRow--;
          break;
      }

      if (newRow < 0 || newRow >= rowCount) return null;

      const cell = this.getCellAt(newRow, newCol);
      if (!cell) return null;

      if (cell.dataset.disabled === 'true') {
        return this.findNextCell(newRow, newCol, direction);
      }

      return cell;
    }

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

    private cycleSort(header: HTMLElement) {
      const colId = header.dataset.colId;
      const currentDirection = header.dataset.sortDirection || 'none';

      let nextDirection: SortDirection;
      switch (currentDirection) {
        case 'none':
          nextDirection = 'ascending';
          break;
        case 'ascending':
          nextDirection = 'descending';
          break;
        case 'descending':
          nextDirection = 'ascending';
          break;
        default:
          nextDirection = 'ascending';
      }

      // Update all headers to none, then set this one
      this.getSortableHeaders().forEach((h) => {
        h.dataset.sortDirection = 'none';
        h.setAttribute('aria-sort', 'none');
        const indicator = h.querySelector('.sort-indicator');
        if (indicator) indicator.remove();
      });

      header.dataset.sortDirection = nextDirection;
      header.setAttribute('aria-sort', nextDirection);

      // Add sort indicator (always add since nextDirection is 'ascending' or 'descending')
      const indicator = document.createElement('span');
      indicator.className = 'sort-indicator';
      indicator.setAttribute('aria-hidden', 'true');
      indicator.textContent = nextDirection === 'ascending' ? '▲' : '▼';
      header.appendChild(indicator);

      this.dispatchEvent(
        new CustomEvent('sort', {
          detail: { columnId: colId, direction: nextDirection },
        })
      );
    }

    private handleHeaderClick(event: Event) {
      const header = event.currentTarget as HTMLElement;
      this.cycleSort(header);
    }

    private handleHeaderKeyDown(event: KeyboardEvent) {
      const header = event.currentTarget as HTMLElement;
      const { key } = event;
      const colIndex = parseInt(header.dataset.colIndex || '0', 10);
      let handled = true;

      switch (key) {
        case 'Enter':
        case ' ':
          this.cycleSort(header);
          break;
        case 'ArrowDown': {
          const firstCell = this.getCellAt(0, colIndex);
          if (firstCell) this.focusCell(firstCell);
          break;
        }
        case 'ArrowRight': {
          const headers = this.getSortableHeaders();
          const currentIndex = headers.indexOf(header);
          if (currentIndex < headers.length - 1) {
            this.focusHeader(headers[currentIndex + 1]);
          }
          break;
        }
        case 'ArrowLeft': {
          const headers = this.getSortableHeaders();
          const currentIndex = headers.indexOf(header);
          if (currentIndex > 0) {
            this.focusHeader(headers[currentIndex - 1]);
          }
          break;
        }
        case 'Home': {
          const headers = this.getSortableHeaders();
          if (headers.length > 0) {
            this.focusHeader(headers[0]);
          }
          break;
        }
        case 'End': {
          const headers = this.getSortableHeaders();
          if (headers.length > 0) {
            this.focusHeader(headers[headers.length - 1]);
          }
          break;
        }
        default:
          handled = false;
      }

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

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

    private toggleRowSelection(rowId: string, row: HTMLElement) {
      if (row.getAttribute('aria-disabled') === 'true') return;

      const checkbox = row.querySelector<HTMLInputElement>(
        `[data-row-checkbox="true"][data-row-id="${rowId}"]`
      );

      if (this.rowMultiselectable) {
        if (this.selectedRowIds.has(rowId)) {
          this.selectedRowIds.delete(rowId);
          row.setAttribute('aria-selected', 'false');
          if (checkbox) checkbox.checked = false;
        } else {
          this.selectedRowIds.add(rowId);
          row.setAttribute('aria-selected', 'true');
          if (checkbox) checkbox.checked = true;
        }
      } else {
        // Clear previous selection
        this.getDataRows().forEach((r) => {
          r.setAttribute('aria-selected', 'false');
          const cb = r.querySelector<HTMLInputElement>('[data-row-checkbox="true"]');
          if (cb) cb.checked = false;
        });
        this.selectedRowIds.clear();

        this.selectedRowIds.add(rowId);
        row.setAttribute('aria-selected', 'true');
        if (checkbox) checkbox.checked = true;
      }

      this.updateSelectAllState();

      this.dispatchEvent(
        new CustomEvent('row-selection-change', {
          detail: { selectedRowIds: Array.from(this.selectedRowIds) },
        })
      );
    }

    private handleRowCheckboxChange(event: Event) {
      const checkbox = event.target as HTMLInputElement;
      const rowId = checkbox.dataset.rowId;
      if (!rowId) return;

      const row = checkbox.closest('[role="row"]') as HTMLElement;
      if (!row) return;

      // Prevent default toggle since toggleRowSelection handles it
      checkbox.checked = this.selectedRowIds.has(rowId);
      this.toggleRowSelection(rowId, row);
    }

    private handleSelectAllChange(event: Event) {
      const checkbox = event.target as HTMLInputElement;
      const rows = this.getDataRows();

      if (checkbox.checked) {
        // Select all
        rows.forEach((row) => {
          if (row.getAttribute('aria-disabled') !== 'true') {
            const rowId = row.dataset.rowId;
            if (rowId) {
              this.selectedRowIds.add(rowId);
              row.setAttribute('aria-selected', 'true');
              const cb = row.querySelector<HTMLInputElement>('[data-row-checkbox="true"]');
              if (cb) cb.checked = true;
            }
          }
        });
      } else {
        // Deselect all
        rows.forEach((row) => {
          row.setAttribute('aria-selected', 'false');
          const cb = row.querySelector<HTMLInputElement>('[data-row-checkbox="true"]');
          if (cb) cb.checked = false;
        });
        this.selectedRowIds.clear();
      }

      this.dispatchEvent(
        new CustomEvent('row-selection-change', {
          detail: { selectedRowIds: Array.from(this.selectedRowIds) },
        })
      );
    }

    private updateSelectAllState() {
      const selectAllCheckbox = this.querySelector<HTMLInputElement>('[data-select-all="true"]');
      if (!selectAllCheckbox) return;

      const rows = this.getDataRows().filter((r) => r.getAttribute('aria-disabled') !== 'true');
      const selectedCount = rows.filter((r) =>
        this.selectedRowIds.has(r.dataset.rowId ?? '')
      ).length;

      if (selectedCount === 0) {
        selectAllCheckbox.checked = false;
        selectAllCheckbox.indeterminate = false;
      } else if (selectedCount === rows.length) {
        selectAllCheckbox.checked = true;
        selectAllCheckbox.indeterminate = false;
      } else {
        selectAllCheckbox.checked = false;
        selectAllCheckbox.indeterminate = true;
      }
    }

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

    private toggleCellSelection(cell: HTMLElement) {
      if (!this.selectable) return;
      if (cell.dataset.disabled === 'true') return;

      const cellId = cell.dataset.cellId;
      if (!cellId) return;

      if (this.multiselectable) {
        if (this.selectedIds.has(cellId)) {
          this.selectedIds.delete(cellId);
          cell.setAttribute('aria-selected', 'false');
          cell.classList.remove('selected');
        } else {
          this.selectedIds.add(cellId);
          cell.setAttribute('aria-selected', 'true');
          cell.classList.add('selected');
        }
      } else {
        this.querySelectorAll('[aria-selected="true"]').forEach((el) => {
          el.setAttribute('aria-selected', 'false');
          el.classList.remove('selected');
        });
        this.selectedIds.clear();

        this.selectedIds.add(cellId);
        cell.setAttribute('aria-selected', 'true');
        cell.classList.add('selected');
      }

      this.dispatchEvent(
        new CustomEvent('selection-change', {
          detail: { selectedIds: Array.from(this.selectedIds) },
        })
      );
    }

    private selectAllCells() {
      if (!this.selectable || !this.multiselectable) return;

      this.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]').forEach(
        (cell) => {
          if (cell.dataset.disabled !== 'true' && cell.dataset.checkboxCell !== 'true') {
            const cellId = cell.dataset.cellId;
            if (cellId) {
              this.selectedIds.add(cellId);
              cell.setAttribute('aria-selected', 'true');
              cell.classList.add('selected');
            }
          }
        }
      );

      this.dispatchEvent(
        new CustomEvent('selection-change', {
          detail: { selectedIds: Array.from(this.selectedIds) },
        })
      );
    }

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

    private getCellPosition(cellId: string): { rowIndex: number; colIndex: number } | null {
      const cell = this.querySelector<HTMLElement>(`[data-cell-id="${cellId}"]`);
      if (!cell) return null;
      return {
        rowIndex: parseInt(cell.dataset.rowIndex || '0', 10),
        colIndex: parseInt(cell.dataset.colIndex || '0', 10),
      };
    }

    private getCellsInRange(startCellId: string, endCellId: string): string[] {
      const startPos = this.getCellPosition(startCellId);
      const endPos = this.getCellPosition(endCellId);
      if (!startPos || !endPos) return [];

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

      const cellIds: string[] = [];
      for (let r = minRow; r <= maxRow; r++) {
        for (let c = minCol; c <= maxCol; c++) {
          const cell = this.getCellAt(r, c);
          if (cell && cell.dataset.cellId) {
            cellIds.push(cell.dataset.cellId);
          }
        }
      }
      return cellIds;
    }

    private extendRangeSelection(currentCellId: string, newFocusId: string) {
      if (!this.enableRangeSelection) return;

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

      const cellIds = this.getCellsInRange(anchor, newFocusId);
      this.dispatchEvent(
        new CustomEvent('range-select', {
          detail: { cellIds },
        })
      );
    }

    private clearRangeSelection() {
      this.anchorCellId = null;
      this.dispatchEvent(
        new CustomEvent('range-select', {
          detail: { cellIds: [] },
        })
      );
    }

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

    private handleCellDoubleClick(event: Event) {
      const cell = event.currentTarget as HTMLElement;
      if (cell.dataset.editable === 'true') {
        this.startEdit(cell);
      }
    }

    private startEdit(cell: HTMLElement) {
      if (!this.editable || this.readonly) return;
      if (cell.dataset.editable !== 'true') return;

      const cellId = cell.dataset.cellId;
      const rowId = cell.dataset.rowId;
      const colId = cell.dataset.colId;
      if (!cellId) return;

      const currentValue = cell.textContent?.trim() ?? '';
      this.editValue = currentValue;
      this.originalEditValue = currentValue;
      this.editingCellId = cellId;
      this.isEditing = true;

      // Create input
      const input = document.createElement('input');
      input.type = 'text';
      input.className = 'apg-data-grid-input';
      input.value = currentValue;

      input.addEventListener('keydown', (e) => this.handleEditKeyDown(e, cellId));
      input.addEventListener('blur', () => this.handleEditBlur(cellId));
      input.addEventListener('input', (e) => {
        this.editValue = (e.target as HTMLInputElement).value;
        this.dispatchEvent(
          new CustomEvent('cell-value-change', {
            detail: { cellId, value: this.editValue },
          })
        );
      });

      // Store original content and replace with input
      cell.dataset.originalContent = cell.innerHTML;
      cell.innerHTML = '';
      cell.appendChild(input);
      cell.classList.add('editing');

      input.focus();
      input.select();

      this.dispatchEvent(
        new CustomEvent('edit-start', {
          detail: { cellId, rowId, colId },
        })
      );
    }

    private endEdit(cellId: string, cancelled: boolean) {
      if (this.isEndingEdit) return;
      if (this.editingCellId !== cellId) return;

      this.isEndingEdit = true;

      const cell = this.querySelector<HTMLElement>(`[data-cell-id="${cellId}"]`);
      if (!cell) {
        this.isEndingEdit = false;
        return;
      }

      const finalValue = cancelled ? this.originalEditValue : this.editValue;

      // Restore cell content
      cell.innerHTML = finalValue;
      cell.classList.remove('editing');

      this.dispatchEvent(
        new CustomEvent('edit-end', {
          detail: { cellId, value: finalValue, cancelled },
        })
      );

      this.editingCellId = null;
      this.editValue = '';
      this.originalEditValue = '';
      this.isEditing = false;

      // Return focus to cell
      setTimeout(() => {
        this.focusCell(cell);
        this.isEndingEdit = false;
      }, 0);
    }

    private handleEditKeyDown(event: KeyboardEvent, cellId: string) {
      const { key } = event;

      if (key === 'Escape') {
        event.preventDefault();
        event.stopPropagation();
        this.endEdit(cellId, true);
      } else if (key === 'Enter') {
        event.preventDefault();
        event.stopPropagation();
        this.endEdit(cellId, false);
      } else if (key === 'Tab') {
        event.preventDefault();
        event.stopPropagation();
        this.endEdit(cellId, false);
      }
    }

    private handleEditBlur(cellId: string) {
      if (this.isEndingEdit) return;
      this.endEdit(cellId, false);
    }

    // =============================================================================
    // Cell KeyDown
    // =============================================================================

    private handleKeyDown(event: KeyboardEvent) {
      if (this.isEditing) return;

      const cell = event.currentTarget as HTMLElement;
      const { key, ctrlKey, shiftKey } = event;
      const rowIndex = parseInt(cell.dataset.rowIndex || '0', 10);
      const colIndex = parseInt(cell.dataset.colIndex || '0', 10);
      const cellId = cell.dataset.cellId ?? '';
      const rowId = cell.dataset.rowId ?? '';
      const colId = cell.dataset.colId ?? '';

      let handled = true;

      switch (key) {
        case 'ArrowRight': {
          const next = this.findNextCell(rowIndex, colIndex, 'right');
          if (next) {
            if (shiftKey && this.enableRangeSelection) {
              this.extendRangeSelection(cellId, next.dataset.cellId ?? '');
            } else {
              this.clearRangeSelection();
            }
            this.focusCell(next);
          }
          break;
        }
        case 'ArrowLeft': {
          // Check if at first data cell and should go to checkbox
          if (colIndex === 0 && this.rowSelectable) {
            this.clearRangeSelection();
            const checkboxCell = this.getCheckboxCellAt(rowIndex);
            if (checkboxCell) {
              this.focusCheckboxCell(checkboxCell);
            }
          } else {
            const next = this.findNextCell(rowIndex, colIndex, 'left');
            if (next) {
              if (shiftKey && this.enableRangeSelection) {
                this.extendRangeSelection(cellId, next.dataset.cellId ?? '');
              } else {
                this.clearRangeSelection();
              }
              this.focusCell(next);
            }
          }
          break;
        }
        case 'ArrowDown': {
          const next = this.findNextCell(rowIndex, colIndex, 'down');
          if (next) {
            if (shiftKey && this.enableRangeSelection) {
              this.extendRangeSelection(cellId, next.dataset.cellId ?? '');
            } else {
              this.clearRangeSelection();
            }
            this.focusCell(next);
          }
          break;
        }
        case 'ArrowUp': {
          if (rowIndex === 0) {
            // Try to move to sortable header
            const headers = this.getSortableHeaders();
            const header = headers.find(
              (h) => parseInt(h.dataset.colIndex || '0', 10) === colIndex
            );
            if (header) {
              this.clearRangeSelection();
              this.focusHeader(header);
              break;
            }
          }
          const next = this.findNextCell(rowIndex, colIndex, 'up');
          if (next) {
            if (shiftKey && this.enableRangeSelection) {
              this.extendRangeSelection(cellId, next.dataset.cellId ?? '');
            } else {
              this.clearRangeSelection();
            }
            this.focusCell(next);
          }
          break;
        }
        case 'Home': {
          if (ctrlKey && shiftKey && this.enableRangeSelection) {
            const firstCell = this.getCellAt(0, 0);
            if (firstCell) {
              this.extendRangeSelection(cellId, firstCell.dataset.cellId ?? '');
              this.focusCell(firstCell);
            }
          } else if (shiftKey && this.enableRangeSelection) {
            const firstInRow = this.getCellAt(rowIndex, 0);
            if (firstInRow) {
              this.extendRangeSelection(cellId, firstInRow.dataset.cellId ?? '');
              this.focusCell(firstInRow);
            }
          } else if (ctrlKey) {
            this.clearRangeSelection();
            // Go to first cell in grid (checkbox if rowSelectable)
            if (this.rowSelectable) {
              const firstCheckbox = this.getCheckboxCellAt(0);
              if (firstCheckbox) this.focusCheckboxCell(firstCheckbox);
            } else {
              const firstCell = this.getCellAt(0, 0);
              if (firstCell) this.focusCell(firstCell);
            }
          } else {
            this.clearRangeSelection();
            // Go to first cell in row (checkbox if rowSelectable)
            if (this.rowSelectable) {
              const checkboxCell = this.getCheckboxCellAt(rowIndex);
              if (checkboxCell) this.focusCheckboxCell(checkboxCell);
            } else {
              const firstInRow = this.getCellAt(rowIndex, 0);
              if (firstInRow) this.focusCell(firstInRow);
            }
          }
          break;
        }
        case 'End': {
          const dataColCount = this.getDataColumnCount();
          const rowCount = this.getDataRows().length;
          if (ctrlKey && shiftKey && this.enableRangeSelection) {
            const lastCell = this.getCellAt(rowCount - 1, dataColCount - 1);
            if (lastCell) {
              this.extendRangeSelection(cellId, lastCell.dataset.cellId ?? '');
              this.focusCell(lastCell);
            }
          } else if (shiftKey && this.enableRangeSelection) {
            const lastInRow = this.getCellAt(rowIndex, dataColCount - 1);
            if (lastInRow) {
              this.extendRangeSelection(cellId, lastInRow.dataset.cellId ?? '');
              this.focusCell(lastInRow);
            }
          } else if (ctrlKey) {
            this.clearRangeSelection();
            const lastCell = this.getCellAt(rowCount - 1, dataColCount - 1);
            if (lastCell) this.focusCell(lastCell);
          } else {
            this.clearRangeSelection();
            const lastInRow = this.getCellAt(rowIndex, dataColCount - 1);
            if (lastInRow) this.focusCell(lastInRow);
          }
          break;
        }
        case 'PageDown': {
          if (this.enablePageNavigation) {
            this.clearRangeSelection();
            const rowCount = this.getDataRows().length;
            const targetRow = Math.min(rowIndex + this.pageSize, rowCount - 1);
            const targetCell = this.getCellAt(targetRow, colIndex);
            if (targetCell) this.focusCell(targetCell);
          } else {
            handled = false;
          }
          break;
        }
        case 'PageUp': {
          if (this.enablePageNavigation) {
            this.clearRangeSelection();
            const targetRow = Math.max(rowIndex - this.pageSize, 0);
            const targetCell = this.getCellAt(targetRow, colIndex);
            if (targetCell) this.focusCell(targetCell);
          } else {
            handled = false;
          }
          break;
        }
        case ' ': {
          this.toggleCellSelection(cell);
          break;
        }
        case 'Enter': {
          if (this.editable && cell.dataset.editable === 'true' && !this.readonly) {
            this.startEdit(cell);
          } else if (cell.dataset.disabled !== 'true') {
            this.dispatchEvent(
              new CustomEvent('cell-activate', {
                detail: { cellId, rowId, colId },
              })
            );
          }
          break;
        }
        case 'F2': {
          if (this.editable && cell.dataset.editable === 'true' && !this.readonly) {
            this.startEdit(cell);
          }
          break;
        }
        case 'a': {
          if (ctrlKey) {
            this.selectAllCells();
          } else {
            handled = false;
          }
          break;
        }
        default:
          handled = false;
      }

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

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

  customElements.define('apg-data-grid', ApgDataGrid);
</script>

Usage

Example
---
import DataGrid from './DataGrid.astro';

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

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

<!-- Basic Data Grid -->
<DataGrid
  columns={columns}
  rows={rows}
  ariaLabel="User list"
/>

<!-- With row selection -->
<DataGrid
  columns={columns}
  rows={rows}
  ariaLabel="User list"
  rowSelectable
  rowMultiselectable
/>

<!-- With range selection and editing -->
<DataGrid
  columns={columns}
  rows={rows}
  ariaLabel="User list"
  enableRangeSelection
  editable
/>

<!-- Listen to events via custom events -->
<script>
  document.querySelector('apg-data-grid').addEventListener('datagrid:sort', (e) => {
    console.log('Sort:', e.detail);
  });
  document.querySelector('apg-data-grid').addEventListener('datagrid:rowselect', (e) => {
    console.log('Row selection:', e.detail);
  });
  document.querySelector('apg-data-grid').addEventListener('datagrid:editend', (e) => {
    console.log('Edit end:', e.detail);
  });
</script>

API

Props

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

Custom Events

Event Detail Description
datagrid:sort { columnId, direction } Fired when a column is sorted
datagrid:rowselect { rowIds } Fired when row selection changes
datagrid:editend { cellId, value, cancelled } Fired 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