APG Patterns
日本語
日本語

TreeGrid

A hierarchical data grid combining Grid's 2D navigation with TreeView's expandable rows.

Demo

Navigate with arrow keys. At rowheader, use ArrowRight/Left to expand/collapse. Press Space to select rows.

Name
Size
Date Modified
Documents
--
2024-01-15
Q4-Report.pdf
2.5 MB
2024-01-10
README.md
4 KB
2024-01-01

Open demo only

TreeGrid vs Grid

Use treegrid role for hierarchical data that can expand/collapse.

Feature TreeGrid Grid
Hierarchy Expandable/collapsible rows Flat structure
Selection Row selection (aria-selected on row) Cell selection (aria-selected on cell)
Arrow at rowheader Expand/collapse tree Move focus
Required ARIA aria-level, aria-expanded None (hierarchy-specific)

Accessibility Features

TreeGrid vs Grid

The treegrid role combines Grid's 2D keyboard navigation with TreeView's hierarchical expand/collapse functionality. Key differences from Grid:

  • Rows can be expanded/collapsed to show/hide child rows
  • Row selection instead of cell selection (aria-selected on row, not gridcell)
  • Tree operations (expand/collapse) only work at the rowheader column
  • Rows have aria-level to indicate hierarchy depth

WAI-ARIA Roles

Role Target Element Description
treegrid Container The treegrid container (composite widget)
row Row container Groups cells horizontally, may have children
columnheader Header cells Column headers (not focusable)
rowheader First column cell Row header where tree operations occur
gridcell Data cells Interactive cells (focusable)

W3C ARIA: treegrid role (opens in new tab)

WAI-ARIA Properties (TreeGrid Container)

Attribute Values Required Description
role="treegrid" - Yes Identifies the container as a treegrid
aria-label String Yes (either aria-label or aria-labelledby) Accessible name for the treegrid
aria-labelledby ID reference Yes (either aria-label or aria-labelledby) Alternative to aria-label
aria-multiselectable true No Only present for multi-select mode
aria-rowcount Number No Total rows (for virtualization)
aria-colcount Number No Total columns (for virtualization)

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

WAI-ARIA States (Rows)

Attribute Values Required Description
aria-level Number (1-based) Yes Static per row (determined by hierarchy)
aria-expanded true | false Yes* ArrowRight/Left at rowheader, click on expand icon
aria-selected true | false No** Space key, click (NOT on gridcell)
aria-disabled true No Only when row is disabled
aria-rowindex Number No Static (for virtualization)

* Only parent rows (with children) have aria-expanded. Leaf rows do NOT have this attribute.
** When selection is supported, ALL rows should have aria-selected.

Keyboard Support

2D Navigation

Key Action
Arrow Down Move focus to same column in next visible row
Arrow Up Move focus to same column in previous visible row
Arrow Right Move focus one cell right (at non-rowheader cells)
Arrow Left Move focus one cell left (at non-rowheader cells)
Home Move focus to first cell in row
End Move focus to last cell in row
Ctrl + Home Move focus to first cell in treegrid
Ctrl + End Move focus to last cell in treegrid

Tree Operations (at rowheader only)

Key Action
Arrow Right (at rowheader) If collapsed parent: expand row. If expanded parent: move to first child's rowheader. If leaf: do nothing
Arrow Left (at rowheader) If expanded parent: collapse row. If collapsed/leaf: move to parent's rowheader. If at root level collapsed: do nothing

Row Selection & Cell Activation

Key Action
Space Toggle row selection (NOT cell selection)
Enter Activate focused cell (does NOT expand/collapse)
Ctrl + A Select all visible rows (when multiselectable)

Important: Arrow keys at non-rowheader cells only move focus, they do NOT expand/collapse.

Focus Management

This component uses roving tabindex for focus management:

  • Roving tabindex - only one cell has tabindex="0"
  • tabindex="-1"
  • Single Tab stop (Tab enters/exits the grid)
  • NOT focusable (no tabindex)
  • NOT in keyboard navigation
  • If focus was on child, move focus to parent

Key Differences from Grid

  • Selection: Row selection (aria-selected on row) vs Cell selection in Grid
  • Arrow keys at rowheader: Expand/collapse tree vs Move focus in Grid
  • Enter key: Cell activation only (never expands/collapses)
  • Hierarchy: aria-level and aria-expanded required on rows
  • Navigation: Collapsed children are skipped in navigation

Source Code

TreeGrid.astro
---
// =============================================================================
// Types
// =============================================================================

export interface TreeGridCellData {
  id: string;
  value: string | number;
  disabled?: boolean;
  colspan?: number;
}

export interface TreeGridNodeData {
  id: string;
  cells: TreeGridCellData[];
  children?: TreeGridNodeData[];
  disabled?: boolean;
}

export interface TreeGridColumnDef {
  id: string;
  header: string;
  isRowHeader?: boolean;
}

interface FlatRow {
  node: TreeGridNodeData;
  level: number;
  parentId: string | null;
  hasChildren: boolean;
}

interface Props {
  columns: TreeGridColumnDef[];
  nodes: TreeGridNodeData[];
  ariaLabel?: string;
  ariaLabelledby?: string;
  defaultExpandedIds?: string[];
  selectable?: boolean;
  multiselectable?: boolean;
  defaultSelectedRowIds?: string[];
  defaultFocusedCellId?: string;
  totalRows?: number;
  totalColumns?: number;
  startRowIndex?: number;
  startColIndex?: number;
  enablePageNavigation?: boolean;
  pageSize?: number;
  class?: string;
  renderCell?: (cell: TreeGridCellData, rowId: string, colId: string) => string;
}

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

const {
  columns,
  nodes,
  ariaLabel,
  ariaLabelledby,
  defaultExpandedIds = [],
  selectable = false,
  multiselectable = false,
  defaultSelectedRowIds = [],
  defaultFocusedCellId,
  totalRows,
  totalColumns,
  startRowIndex = 2,
  startColIndex = 1,
  enablePageNavigation = false,
  pageSize = 5,
  class: className,
  renderCell,
} = Astro.props;

// =============================================================================
// Flatten Tree
// =============================================================================

function flattenTree(
  treeNodes: TreeGridNodeData[],
  level: number = 1,
  parentId: string | null = null
): FlatRow[] {
  const result: FlatRow[] = [];
  for (const node of treeNodes) {
    const hasChildren = Boolean(node.children && node.children.length > 0);
    result.push({ node, level, parentId, hasChildren });
    if (node.children) {
      result.push(...flattenTree(node.children, level + 1, node.id));
    }
  }
  return result;
}

const allRows = flattenTree(nodes);
const expandedIdsSet = new Set(defaultExpandedIds);

// Determine if a row should be initially hidden
function isRowInitiallyHidden(flatRow: FlatRow): boolean {
  let currentParentId = flatRow.parentId;
  while (currentParentId) {
    if (!expandedIdsSet.has(currentParentId)) {
      return true;
    }
    const parent = allRows.find((r) => r.node.id === currentParentId);
    currentParentId = parent?.parentId ?? null;
  }
  return false;
}

// Find first visible row for initial focus
const firstVisibleRow = allRows.find((row) => !isRowInitiallyHidden(row));
const initialFocusedId = defaultFocusedCellId ?? firstVisibleRow?.node.cells[0]?.id ?? null;

function getRowAriaExpanded(flatRow: FlatRow): 'true' | 'false' | undefined {
  if (!flatRow.hasChildren) return undefined;
  return expandedIdsSet.has(flatRow.node.id) ? 'true' : 'false';
}

function getRowAriaSelected(flatRow: FlatRow): 'true' | 'false' | undefined {
  if (!selectable) return undefined;
  return defaultSelectedRowIds.includes(flatRow.node.id) ? 'true' : 'false';
}
---

<apg-treegrid
  class={`apg-treegrid ${className ?? ''}`}
  data-enable-page-navigation={enablePageNavigation}
  data-page-size={pageSize}
  data-selectable={selectable}
  data-multiselectable={multiselectable}
  data-all-rows={JSON.stringify(allRows)}
  data-columns={JSON.stringify(columns)}
>
  <div
    role="treegrid"
    aria-label={ariaLabel}
    aria-labelledby={ariaLabelledby}
    aria-multiselectable={multiselectable ? 'true' : undefined}
    aria-rowcount={totalRows}
    aria-colcount={totalColumns}
  >
    {/* Header Row */}
    <div role="row" aria-rowindex={totalRows ? 1 : undefined}>
      {
        columns.map((col, colIndex) => (
          <div
            role="columnheader"
            aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
            data-col-id={col.id}
            data-is-row-header={col.isRowHeader ? 'true' : undefined}
          >
            {col.header}
          </div>
        ))
      }
    </div>

    {/* Data Rows - render ALL rows, hide children of collapsed parents */}
    {
      allRows.map((flatRow, rowIndex) => {
        const isHidden = isRowInitiallyHidden(flatRow);
        return (
          <div
            role="row"
            aria-level={flatRow.level}
            aria-expanded={getRowAriaExpanded(flatRow)}
            aria-selected={getRowAriaSelected(flatRow)}
            aria-disabled={flatRow.node.disabled ? 'true' : undefined}
            aria-rowindex={totalRows ? startRowIndex + rowIndex : undefined}
            data-row-id={flatRow.node.id}
            data-parent-id={flatRow.parentId}
            data-has-children={flatRow.hasChildren ? 'true' : undefined}
            data-level={flatRow.level}
            style={isHidden ? 'display: none' : undefined}
          >
            {flatRow.node.cells.map((cell, colIndex) => {
              const isRowHeader = columns[colIndex]?.isRowHeader ?? false;
              const isFocused = cell.id === initialFocusedId;
              const colId = columns[colIndex]?.id ?? '';
              const paddingLeft = isRowHeader ? `${(flatRow.level - 1) * 20 + 8}px` : undefined;

              return (
                <div
                  role={isRowHeader ? 'rowheader' : 'gridcell'}
                  tabindex={isFocused ? 0 : -1}
                  aria-disabled={cell.disabled || flatRow.node.disabled ? 'true' : undefined}
                  aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
                  aria-colspan={cell.colspan}
                  data-cell-id={cell.id}
                  data-row-id={flatRow.node.id}
                  data-col-id={colId}
                  data-row-index={rowIndex}
                  data-col-index={colIndex}
                  data-is-row-header={isRowHeader ? 'true' : undefined}
                  data-disabled={cell.disabled || flatRow.node.disabled ? 'true' : undefined}
                  class={`apg-treegrid-cell ${isFocused ? 'focused' : ''} ${defaultSelectedRowIds.includes(flatRow.node.id) ? 'selected' : ''} ${cell.disabled || flatRow.node.disabled ? 'disabled' : ''}`}
                  style={paddingLeft ? `padding-left: ${paddingLeft}` : undefined}
                >
                  {isRowHeader && flatRow.hasChildren && (
                    <span class="expand-icon" aria-hidden="true">
                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                        <polyline points="9 6 15 12 9 18" />
                      </svg>
                    </span>
                  )}
                  {renderCell ? (
                    <Fragment set:html={renderCell(cell, flatRow.node.id, colId)} />
                  ) : (
                    cell.value
                  )}
                </div>
              );
            })}
          </div>
        );
      })
    }
  </div>
</apg-treegrid>

<script>
  interface FlatRowData {
    node: {
      id: string;
      cells: { id: string; value: string | number; disabled?: boolean; colspan?: number }[];
      children?: FlatRowData['node'][];
      disabled?: boolean;
    };
    level: number;
    parentId: string | null;
    hasChildren: boolean;
  }

  interface ColumnDef {
    id: string;
    header: string;
    isRowHeader?: boolean;
  }

  class ApgTreeGrid extends HTMLElement {
    private focusedCellId: string | null = null;
    private selectedRowIds: Set<string> = new Set();
    private expandedIds: Set<string> = new Set();
    private enablePageNavigation = false;
    private pageSize = 5;
    private selectable = false;
    private multiselectable = false;
    private allRows: FlatRowData[] = [];
    private columns: ColumnDef[] = [];

    connectedCallback() {
      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';

      try {
        this.allRows = JSON.parse(this.dataset.allRows || '[]');
        this.columns = JSON.parse(this.dataset.columns || '[]');
      } catch {
        this.allRows = [];
        this.columns = [];
      }

      // Find initial focused cell
      const focusedCell = this.querySelector<HTMLElement>('[tabindex="0"]');
      this.focusedCellId = focusedCell?.dataset.cellId ?? null;

      // Load initial expanded ids from DOM
      this.querySelectorAll<HTMLElement>('[aria-expanded="true"]').forEach((el) => {
        const rowId = el.dataset.rowId;
        if (rowId) this.expandedIds.add(rowId);
      });

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

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

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

    disconnectedCallback() {
      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);
        }
      );
    }

    private getVisibleRows(): HTMLElement[] {
      return Array.from(this.querySelectorAll<HTMLElement>('[role="row"][aria-level]'));
    }

    private getColumnCount(): number {
      return this.querySelectorAll('[role="columnheader"]').length;
    }

    private getCellAt(rowIndex: number, colIndex: number): HTMLElement | null {
      const rows = this.getVisibleRows();
      const row = rows[rowIndex];
      if (!row) return null;
      const cells = row.querySelectorAll('[role="gridcell"], [role="rowheader"]');
      return cells[colIndex] as HTMLElement | null;
    }

    private focusCell(cell: HTMLElement) {
      const currentFocused = this.querySelector('[tabindex="0"]');
      if (currentFocused) {
        currentFocused.setAttribute('tabindex', '-1');
        currentFocused.classList.remove('focused');
      }
      cell.setAttribute('tabindex', '0');
      cell.classList.add('focused');

      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');
        focusableChild.focus();
      } else {
        cell.focus();
      }
      this.focusedCellId = cell.dataset.cellId ?? null;
    }

    private handleFocus(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.focusedCellId = cell.dataset.cellId ?? null;
    }

    private expandRow(rowId: string) {
      const row = this.querySelector<HTMLElement>(`[role="row"][data-row-id="${rowId}"]`);
      if (!row) return;
      if (row.dataset.hasChildren !== 'true') return;
      if (row.getAttribute('aria-disabled') === 'true') return;
      if (this.expandedIds.has(rowId)) return;

      this.expandedIds.add(rowId);
      row.setAttribute('aria-expanded', 'true');

      // Show child rows
      this.updateVisibleRows();

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

    private collapseRow(rowId: string, currentColIndex: number) {
      const row = this.querySelector<HTMLElement>(`[role="row"][data-row-id="${rowId}"]`);
      if (!row) return;
      if (row.dataset.hasChildren !== 'true') return;
      if (row.getAttribute('aria-disabled') === 'true') return;
      if (!this.expandedIds.has(rowId)) return;

      this.expandedIds.delete(rowId);
      row.setAttribute('aria-expanded', 'false');

      // Check if focus was on a descendant - if so, move focus to parent's cell
      if (this.focusedCellId) {
        const focusedCell = this.querySelector<HTMLElement>(
          `[data-cell-id="${this.focusedCellId}"]`
        );
        if (focusedCell) {
          const focusedRowId = focusedCell.dataset.rowId;
          if (focusedRowId && this.isDescendantOf(focusedRowId, rowId)) {
            // Move focus to parent row's same column
            const cells = row.querySelectorAll<HTMLElement>(
              '[role="gridcell"], [role="rowheader"]'
            );
            const targetCell = cells[currentColIndex] as HTMLElement | null;
            if (targetCell) {
              this.focusCell(targetCell);
            }
          }
        }
      }

      // Hide child rows
      this.updateVisibleRows();

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

    private isDescendantOf(childRowId: string, parentRowId: string): boolean {
      const childRow = this.allRows.find((r) => r.node.id === childRowId);
      if (!childRow) return false;

      let currentParentId = childRow.parentId;
      while (currentParentId) {
        if (currentParentId === parentRowId) return true;
        const parent = this.allRows.find((r) => r.node.id === currentParentId);
        currentParentId = parent?.parentId ?? null;
      }
      return false;
    }

    private updateVisibleRows() {
      // Show/hide rows based on expanded state
      const rows = this.querySelectorAll<HTMLElement>('[role="row"][aria-level]');
      rows.forEach((row) => {
        const rowId = row.dataset.rowId;
        if (!rowId) return;

        const flatRow = this.allRows.find((r) => r.node.id === rowId);
        if (!flatRow) return;

        let isHidden = false;
        let currentParentId = flatRow.parentId;
        while (currentParentId) {
          if (!this.expandedIds.has(currentParentId)) {
            isHidden = true;
            break;
          }
          const parent = this.allRows.find((r) => r.node.id === currentParentId);
          currentParentId = parent?.parentId ?? null;
        }

        row.style.display = isHidden ? 'none' : '';
      });
    }

    private toggleRowSelection(rowId: string, rowDisabled: boolean) {
      if (!this.selectable || rowDisabled) return;

      const row = this.querySelector<HTMLElement>(`[role="row"][data-row-id="${rowId}"]`);
      if (!row) return;

      if (this.multiselectable) {
        if (this.selectedRowIds.has(rowId)) {
          this.selectedRowIds.delete(rowId);
          row.setAttribute('aria-selected', 'false');
          row
            .querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]')
            .forEach((cell) => {
              cell.classList.remove('selected');
            });
        } else {
          this.selectedRowIds.add(rowId);
          row.setAttribute('aria-selected', 'true');
          row
            .querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]')
            .forEach((cell) => {
              cell.classList.add('selected');
            });
        }
      } else {
        // Clear previous selection
        this.querySelectorAll('[aria-selected="true"]').forEach((el) => {
          el.setAttribute('aria-selected', 'false');
          el.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]').forEach(
            (cell) => {
              cell.classList.remove('selected');
            }
          );
        });
        this.selectedRowIds.clear();

        if (!this.selectedRowIds.has(rowId)) {
          this.selectedRowIds.add(rowId);
          row.setAttribute('aria-selected', 'true');
          row
            .querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]')
            .forEach((cell) => {
              cell.classList.add('selected');
            });
        }
      }

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

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

      this.getVisibleRows().forEach((row) => {
        if (row.style.display === 'none') return;
        if (row.getAttribute('aria-disabled') === 'true') return;

        const rowId = row.dataset.rowId;
        if (rowId) {
          this.selectedRowIds.add(rowId);
          row.setAttribute('aria-selected', 'true');
          row
            .querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]')
            .forEach((cell) => {
              cell.classList.add('selected');
            });
        }
      });

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

    private handleKeyDown(event: KeyboardEvent) {
      const cell = event.currentTarget as HTMLElement;
      const { key, ctrlKey } = event;
      const { colIndex: colIndexStr, disabled, cellId, rowId, colId, isRowHeader } = cell.dataset;
      const colIndex = parseInt(colIndexStr || '0', 10);
      const colCount = this.getColumnCount();
      const visibleRows = this.getVisibleRows().filter((r) => r.style.display !== 'none');
      const currentRow = cell.closest<HTMLElement>('[role="row"]');

      let handled = true;

      switch (key) {
        case 'ArrowRight': {
          const hasChildren = currentRow?.dataset.hasChildren === 'true';
          const rowDisabled = currentRow?.getAttribute('aria-disabled') === 'true';
          const isExpanded = rowId ? this.expandedIds.has(rowId) : false;

          if (isRowHeader === 'true' && hasChildren && !rowDisabled && !isExpanded) {
            // Collapsed parent at rowheader: expand
            this.expandRow(rowId!);
          } else {
            // Expanded parent at rowheader, leaf row at rowheader, or non-rowheader: move right
            if (colIndex < colCount - 1) {
              const currentRowEl = cell.closest('[role="row"]');
              const cells = currentRowEl?.querySelectorAll('[role="gridcell"], [role="rowheader"]');
              const nextCell = cells?.[colIndex + 1] as HTMLElement | null;
              if (nextCell) this.focusCell(nextCell);
            }
          }
          break;
        }
        case 'ArrowLeft': {
          if (isRowHeader === 'true') {
            const rowDisabled = currentRow?.getAttribute('aria-disabled') === 'true';
            const isExpanded = rowId ? this.expandedIds.has(rowId) : false;
            const hasChildren = currentRow?.dataset.hasChildren === 'true';

            if (hasChildren && isExpanded && !rowDisabled) {
              this.collapseRow(rowId!, colIndex);
            } else if (currentRow?.dataset.parentId) {
              // Move to parent row's same column
              const parentRow = this.querySelector<HTMLElement>(
                `[role="row"][data-row-id="${currentRow.dataset.parentId}"]`
              );
              if (parentRow && parentRow.style.display !== 'none') {
                const parentCells = parentRow.querySelectorAll<HTMLElement>(
                  '[role="gridcell"], [role="rowheader"]'
                );
                const parentCell = parentCells[colIndex];
                if (parentCell) this.focusCell(parentCell);
              }
            }
          } else {
            // Move left
            if (colIndex > 0) {
              const currentRowEl = cell.closest('[role="row"]');
              const cells = currentRowEl?.querySelectorAll('[role="gridcell"], [role="rowheader"]');
              const prevCell = cells?.[colIndex - 1] as HTMLElement | null;
              if (prevCell) this.focusCell(prevCell);
            }
          }
          break;
        }
        case 'ArrowDown': {
          const visibleOnly = visibleRows.filter((r) => r.style.display !== 'none');
          const actualIndex = visibleOnly.indexOf(currentRow!);
          if (actualIndex < visibleOnly.length - 1) {
            const nextRow = visibleOnly[actualIndex + 1];
            const cells = nextRow.querySelectorAll('[role="gridcell"], [role="rowheader"]');
            const nextCell = cells[colIndex] as HTMLElement | null;
            if (nextCell) this.focusCell(nextCell);
          }
          break;
        }
        case 'ArrowUp': {
          const visibleOnly = visibleRows.filter((r) => r.style.display !== 'none');
          const actualIndex = visibleOnly.indexOf(currentRow!);
          if (actualIndex > 0) {
            const prevRow = visibleOnly[actualIndex - 1];
            const cells = prevRow.querySelectorAll('[role="gridcell"], [role="rowheader"]');
            const prevCell = cells[colIndex] as HTMLElement | null;
            if (prevCell) this.focusCell(prevCell);
          }
          break;
        }
        case 'Home': {
          if (ctrlKey) {
            const firstCell = this.getCellAt(0, 0);
            if (firstCell) this.focusCell(firstCell);
          } else {
            const currentRowEl = cell.closest('[role="row"]');
            const cells = currentRowEl?.querySelectorAll('[role="gridcell"], [role="rowheader"]');
            const firstCell = cells?.[0] as HTMLElement | null;
            if (firstCell) this.focusCell(firstCell);
          }
          break;
        }
        case 'End': {
          if (ctrlKey) {
            const visibleOnly = visibleRows.filter((r) => r.style.display !== 'none');
            const lastRow = visibleOnly[visibleOnly.length - 1];
            const cells = lastRow?.querySelectorAll('[role="gridcell"], [role="rowheader"]');
            const lastCell = cells?.[cells.length - 1] as HTMLElement | null;
            if (lastCell) this.focusCell(lastCell);
          } else {
            const currentRowEl = cell.closest('[role="row"]');
            const cells = currentRowEl?.querySelectorAll('[role="gridcell"], [role="rowheader"]');
            const lastCell = cells?.[cells.length - 1] as HTMLElement | null;
            if (lastCell) this.focusCell(lastCell);
          }
          break;
        }
        case 'PageDown': {
          if (this.enablePageNavigation) {
            const visibleOnly = visibleRows.filter((r) => r.style.display !== 'none');
            const actualIndex = visibleOnly.indexOf(currentRow!);
            const targetIndex = Math.min(actualIndex + this.pageSize, visibleOnly.length - 1);
            const targetRow = visibleOnly[targetIndex];
            const cells = targetRow?.querySelectorAll('[role="gridcell"], [role="rowheader"]');
            const targetCell = cells?.[colIndex] as HTMLElement | null;
            if (targetCell) this.focusCell(targetCell);
          } else {
            handled = false;
          }
          break;
        }
        case 'PageUp': {
          if (this.enablePageNavigation) {
            const visibleOnly = visibleRows.filter((r) => r.style.display !== 'none');
            const actualIndex = visibleOnly.indexOf(currentRow!);
            const targetIndex = Math.max(actualIndex - this.pageSize, 0);
            const targetRow = visibleOnly[targetIndex];
            const cells = targetRow?.querySelectorAll('[role="gridcell"], [role="rowheader"]');
            const targetCell = cells?.[colIndex] as HTMLElement | null;
            if (targetCell) this.focusCell(targetCell);
          } else {
            handled = false;
          }
          break;
        }
        case ' ': {
          const rowDisabled = currentRow?.getAttribute('aria-disabled') === 'true';
          this.toggleRowSelection(rowId!, rowDisabled);
          break;
        }
        case 'Enter': {
          // Enter activates cell, does NOT expand/collapse
          if (disabled !== 'true') {
            this.dispatchEvent(
              new CustomEvent('cell-activate', {
                detail: { cellId, rowId, colId },
              })
            );
          }
          break;
        }
        case 'a': {
          if (ctrlKey) {
            this.selectAllVisibleRows();
          } else {
            handled = false;
          }
          break;
        }
        default:
          handled = false;
      }

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

  customElements.define('apg-treegrid', ApgTreeGrid);
</script>

Usage

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

const columns = [
  { id: 'name', header: 'Name', isRowHeader: true },
  { id: 'size', header: 'Size' },
];

const nodes = [
  {
    id: 'folder1',
    cells: [
      { id: 'folder1-name', value: 'Documents' },
      { id: 'folder1-size', value: '--' },
    ],
    children: [
      {
        id: 'file1',
        cells: [
          { id: 'file1-name', value: 'Report.pdf' },
          { id: 'file1-size', value: '2.5 MB' },
        ],
      },
    ],
  },
];
---

<TreeGrid
  columns={columns}
  nodes={nodes}
  ariaLabel="File browser"
  selectable
  multiselectable
  defaultExpandedIds={['folder1']}
/>

API

Props

Prop Type Description
columns TreeGridColumnDef[] Column definitions
nodes TreeGridNodeData[] Hierarchical node data
ariaLabel string Accessible name
defaultExpandedIds string[] Initially expanded row IDs
selectable boolean Enable row selection
multiselectable boolean Enable multi-row selection

Note: Astro implementation uses Web Components for client-side interactivity.

Resources