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 →

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)
Header Navigation No Yes (sortable headers)

Accessibility Features

WAI-ARIA Roles

RoleTarget ElementDescription
gridContainerIdentifies the element as a grid. The grid contains rows of cells.
rowEach rowIdentifies a row of cells
gridcellEach cellIdentifies an interactive cell in the grid
rowheaderRow header cellIdentifies a cell as a header for its row
columnheaderColumn header cellIdentifies a cell as a header for its column

WAI-ARIA Properties

aria-rowcount

Required when rows are virtualized

Values
Total number of rows
Required
No

aria-colcount

Required when columns are hidden or virtualized

Values
Total number of columns
Required
No

aria-rowindex

Required when rows are virtualized

Values
Row’s position in the grid
Required
No

aria-colindex

Required when columns are hidden or virtualized

Values
Column’s position in the grid
Required
No

aria-sort

Indicates the sorting state of a column

Values
ascending | descending | none | other
Required
No

aria-describedby

Provides additional context about the grid

Values
ID reference to description element
Required
No

WAI-ARIA States

aria-selected

Target Element
gridcell or row
Values
true | false
Required
No
Change Trigger
Click, Space, Ctrl/Cmd+Click

aria-readonly

Target Element
grid or gridcell
Values
true | false
Required
No
Change Trigger
Grid/cell configuration

aria-disabled

Target Element
grid, row, or gridcell
Values
true | false
Required
No
Change Trigger
Grid/row/cell state change

Keyboard Support

KeyAction
ArrowRightMove focus one cell to the right. Wraps to next row if at end.
ArrowLeftMove focus one cell to the left. Wraps to previous row if at start.
ArrowDownMove focus one cell down.
ArrowUpMove focus one cell up.
HomeMove focus to the first cell in the row.
EndMove focus to the last cell in the row.
Ctrl + HomeMove focus to the first cell in the grid.
Ctrl + EndMove focus to the last cell in the grid.
Page DownMove focus down by a page (implementation-defined).
Page UpMove focus up by a page (implementation-defined).
Space / EnterActivate the cell (e.g., edit, select).
EscapeCancel edit mode or deselect.
  • Use role=“grid” only when the table is interactive. For static data, use native <table> elements.
  • Roving tabindex is recommended for efficient keyboard navigation.
  • Consider providing a visible focus indicator for the focused cell.

Focus Management

EventBehavior
Gridtabindex="0" on container or first focusable cell
Focused celltabindex="0"
Other cellstabindex="-1"
Interactive content in cellsFocus moves into cell content on Enter, out on Escape

References

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

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