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 →

Accessibility Features

WAI-ARIA Roles

RoleTarget ElementDescription
treegridContainerThe treegrid container (composite widget)
rowRow containerGroups cells horizontally, may have children
columnheaderHeader cellsColumn headers (not focusable)
rowheaderFirst column cellRow header where tree operations occur
gridcellData cellsInteractive cells (focusable)

WAI-ARIA Properties

role="treegrid"

Identifies the container as a treegrid

Values
-
Required
Yes

aria-label

Accessible name for the treegrid

Values
String
Required
Yes (either aria-label or aria-labelledby)

aria-labelledby

Alternative to aria-label

Values
ID reference
Required
Yes (either aria-label or aria-labelledby)

aria-multiselectable

Only present for multi-select mode

Values
true
Required
No

aria-rowcount

Total rows (for virtualization)

Values
Number
Required
No

aria-colcount

Total columns (for virtualization)

Values
Number
Required
No

WAI-ARIA States

aria-level

Target Element
row
Values
Number (1-based)
Required
Yes
Change Trigger

Static per row (determined by hierarchy)

aria-expanded

Target Element
row (parent only)
Values
true | false
Required
Yes
Change Trigger

ArrowRight/Left at rowheader, click on expand icon

aria-selected

Target Element
row
Values
true | false
Required
No
Change Trigger
Space key, click (NOT on gridcell)

aria-disabled

Target Element
row
Values
true
Required
No
Change Trigger
Only when row is disabled

aria-rowindex

Target Element
row
Values
Number
Required
No
Change Trigger
Static (for virtualization)

Keyboard Support

2D Navigation

KeyAction
Arrow DownMove focus to same column in next visible row
Arrow UpMove focus to same column in previous visible row
Arrow RightMove focus one cell right (at non-rowheader cells)
Arrow LeftMove focus one cell left (at non-rowheader cells)
HomeMove focus to first cell in row
EndMove focus to last cell in row
Ctrl + HomeMove focus to first cell in treegrid
Ctrl + EndMove focus to last cell in treegrid

Tree Operations (at rowheader only)

KeyAction
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

KeyAction
SpaceToggle row selection (NOT cell selection)
EnterActivate focused cell (does NOT expand/collapse)
Ctrl + ASelect all visible rows (when multiselectable)
  • 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

Focus Management

EventBehavior
Focus modelRoving tabindex - only one cell has tabindex="0"
Other cellstabindex="-1"
TreeGridSingle Tab stop (Tab enters/exits the grid)
Column headersNOT focusable (no tabindex)
Collapsed childrenNOT in keyboard navigation
Parent collapsesIf focus was on child, move focus to parent

References

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

Prop Type Default Description
columns TreeGridColumnDef[] required Column definitions
nodes TreeGridNodeData[] required Hierarchical node data
ariaLabel string - Accessible name
defaultExpandedIds string[] [] Initially expanded row IDs
selectable boolean false Enable row selection
multiselectable boolean false Enable multi-row selection
Note: Astro implementation uses Web Components for client-side interactivity.

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The TreeGrid component combines Grid and TreeView testing strategies.

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 (treegrid, row, rowheader, gridcell)
  • Initial attribute values (role, aria-label, aria-level, aria-expanded)
  • Selection state changes (aria-selected on rows)
  • Hierarchy depth indicators (aria-level)
  • 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)
  • Tree operations at rowheader (ArrowRight/Left for expand/collapse)
  • Extended navigation (Home, End, Ctrl+Home, Ctrl+End)
  • Row selection with Space
  • Focus management and roving tabindex
  • Hidden row handling (collapsed children)
  • Cross-framework consistency

Test Categories

High Priority: APG ARIA Attributes ( Unit + E2E )

Test Description
role="treegrid" Container has treegrid role
role="row" All rows have row role
role="rowheader" First cell in row has rowheader role
role="gridcell" Other cells have gridcell role
role="columnheader" Header cells have columnheader role
aria-label TreeGrid has accessible name via aria-label
aria-level All rows have aria-level (1-based depth)
aria-expanded Parent rows have aria-expanded (true/false)
aria-selected on row Selection on row element (not cell)
aria-multiselectable Present when multi-selection is enabled

High Priority: Tree Operations (at Rowheader) ( E2E )

Test Description
ArrowRight expands Expands collapsed parent row
ArrowRight to child Moves to first child when already expanded
ArrowLeft collapses Collapses expanded parent row
ArrowLeft to parent Moves to parent row when collapsed or leaf
Enter activates only Enter does NOT expand/collapse (unlike Tree)
Children hidden Child rows hidden when parent collapsed

High Priority: 2D Keyboard Navigation ( E2E )

Test Description
ArrowRight (non-rowheader) Moves focus one cell right
ArrowLeft (non-rowheader) Moves focus one cell left
ArrowDown Moves focus to next visible row
ArrowUp Moves focus to previous visible row
Skip hidden rows ArrowDown/Up skips collapsed children

High Priority: Extended Navigation ( E2E )

Test Description
Home Moves focus to first cell in row
End Moves focus to last cell in row
Ctrl+Home Moves focus to first cell in first visible row
Ctrl+End Moves focus to last cell in last visible row

High Priority: Focus Management (Roving Tabindex) ( Unit + E2E )

Test Description
tabindex="0" First focusable cell has tabindex="0"
tabindex="-1" Other cells have tabindex="-1"
Headers not focusable columnheader cells have no tabindex
Tab exits treegrid Tab moves focus out of treegrid
Focus update Focused cell updates tabindex on navigation

High Priority: Row Selection ( E2E )

Test Description
Space toggles row Space toggles row selection (not cell)
Single select Single selection clears previous on Space
Multi select Multi-selection allows multiple rows
Enter activates cell Enter triggers cell activation

Medium Priority: Accessibility ( E2E )

Test Description
axe-core No accessibility violations

Example Test Code

The following is the actual E2E test file (e2e/treegrid.spec.ts).

e2e/treegrid.spec.ts
import { expect, test, type Locator, type Page } from '@playwright/test';

/**
 * E2E Tests for TreeGrid Pattern
 *
 * Tests verify the TreeGrid component behavior in a real browser,
 * including 2D keyboard navigation, tree operations (expand/collapse),
 * row selection, and focus management.
 *
 * Test coverage:
 * - ARIA structure and attributes (treegrid, aria-level, aria-expanded)
 * - 2D keyboard navigation (Arrow keys, Home, End, Ctrl+Home, Ctrl+End)
 * - Tree operations at rowheader (ArrowRight/Left for expand/collapse)
 * - Row selection (Space toggles row selection, not cell)
 * - Focus management (roving tabindex)
 *
 * Key differences from Grid:
 * - Tree operations only at rowheader cells
 * - Row selection (aria-selected on row, not cell)
 * - aria-level and aria-expanded on rows
 */

/**
 * Helper to check if a cell or a focusable element within it is focused.
 */
async function expectCellOrChildFocused(_page: Page, cell: Locator): Promise<void> {
  const cellIsFocused = await cell.evaluate((el) => document.activeElement === el);
  if (cellIsFocused) {
    await expect(cell).toBeFocused();
    return;
  }

  const focusedChild = cell.locator(
    'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
  );
  const childCount = await focusedChild.count();
  if (childCount > 0) {
    for (let i = 0; i < childCount; i++) {
      const child = focusedChild.nth(i);
      const childIsFocused = await child.evaluate((el) => document.activeElement === el);
      if (childIsFocused) {
        await expect(child).toBeFocused();
        return;
      }
    }
  }

  await expect(cell).toBeFocused();
}

/**
 * Helper to focus a cell, handling cells that contain links/buttons.
 * Returns the focused element (either the cell or a focusable child).
 */
async function focusCell(_page: Page, cell: Locator): Promise<Locator> {
  await cell.click({ position: { x: 5, y: 5 } });

  // Check if focus is on the cell or a child element
  const cellIsFocused = await cell.evaluate((el) => document.activeElement === el);
  if (cellIsFocused) {
    return cell;
  }

  // Find and return the focused child
  const focusedChild = cell.locator(
    'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
  );
  const childCount = await focusedChild.count();
  for (let i = 0; i < childCount; i++) {
    const child = focusedChild.nth(i);
    const childIsFocused = await child.evaluate((el) => document.activeElement === el);
    if (childIsFocused) {
      return child;
    }
  }

  return cell;
}

/**
 * Helper to get the row containing a cell.
 */
async function getRowForCell(cell: Locator): Promise<Locator> {
  return cell.locator('xpath=ancestor::*[@role="row"]').first();
}

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

for (const framework of frameworks) {
  test.describe(`TreeGrid (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/treegrid/${framework}/demo/`);
      await page.waitForLoadState('networkidle');
      await page.waitForSelector('[role="treegrid"]');
    });

    // 🔴 High Priority: ARIA Attributes
    test.describe('ARIA Attributes', () => {
      test('has role="treegrid" on container', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        await expect(treegrid).toBeVisible();
      });

      test('has role="row" on rows', async ({ page }) => {
        const rows = page.getByRole('row');
        await expect(rows.first()).toBeVisible();
        expect(await rows.count()).toBeGreaterThan(1);
      });

      test('has role="gridcell" on data cells', async ({ page }) => {
        const cells = page.getByRole('gridcell');
        await expect(cells.first()).toBeVisible();
      });

      test('has role="columnheader" on header cells', async ({ page }) => {
        const headers = page.getByRole('columnheader');
        await expect(headers.first()).toBeVisible();
      });

      test('has role="rowheader" on row header cells', async ({ page }) => {
        const rowheaders = page.getByRole('rowheader');
        await expect(rowheaders.first()).toBeVisible();
      });

      test('has accessible name', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const label = await treegrid.getAttribute('aria-label');
        const labelledby = await treegrid.getAttribute('aria-labelledby');
        expect(label || labelledby).toBeTruthy();
      });

      test('parent rows have aria-expanded', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        let foundParentRow = false;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded !== null) {
            foundParentRow = true;
            expect(['true', 'false']).toContain(ariaExpanded);
          }
        }
        expect(foundParentRow).toBe(true);
      });

      test('all data rows have aria-level', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaLevel = await row.getAttribute('aria-level');
          // Skip header row (no aria-level)
          if (ariaLevel !== null) {
            const level = parseInt(ariaLevel, 10);
            expect(level).toBeGreaterThanOrEqual(1);
          }
        }
      });

      test('aria-level is 1-based and increments with depth', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        const levels: number[] = [];
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaLevel = await row.getAttribute('aria-level');
          if (ariaLevel !== null) {
            levels.push(parseInt(ariaLevel, 10));
          }
        }

        // Check that level 1 exists (root level)
        expect(levels).toContain(1);
      });

      test('has aria-selected on row (not gridcell) when selectable', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        let hasSelectableRow = false;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaSelected = await row.getAttribute('aria-selected');
          if (ariaSelected !== null) {
            hasSelectableRow = true;
            expect(['true', 'false']).toContain(ariaSelected);
          }
        }

        if (hasSelectableRow) {
          // Verify gridcells don't have aria-selected
          const cells = treegrid.getByRole('gridcell');
          const cellCount = await cells.count();
          for (let i = 0; i < cellCount; i++) {
            const cell = cells.nth(i);
            const ariaSelected = await cell.getAttribute('aria-selected');
            expect(ariaSelected).toBeNull();
          }
        }
      });
    });

    // 🔴 High Priority: Keyboard - Row Navigation
    test.describe('Keyboard - Row Navigation', () => {
      test('ArrowDown moves to same column in next visible row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const firstRowheader = rowheaders.first();
        const focusedElement = await focusCell(page, firstRowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowDown');

        const secondRowheader = rowheaders.nth(1);
        await expectCellOrChildFocused(page, secondRowheader);
      });

      test('ArrowUp moves to same column in previous visible row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const secondRowheader = rowheaders.nth(1);
        const focusedElement = await focusCell(page, secondRowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowUp');

        const firstRowheader = rowheaders.first();
        await expectCellOrChildFocused(page, firstRowheader);
      });

      test('ArrowUp stops at first visible row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const firstRowheader = rowheaders.first();
        const focusedElement = await focusCell(page, firstRowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowUp');

        // Should stay on first row
        await expectCellOrChildFocused(page, firstRowheader);
      });
    });

    // 🔴 High Priority: Keyboard - Cell Navigation
    test.describe('Keyboard - Cell Navigation', () => {
      test('ArrowRight at non-rowheader moves right', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const firstCell = cells.first();
        const focusedElement = await focusCell(page, firstCell);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowRight');

        const secondCell = cells.nth(1);
        await expectCellOrChildFocused(page, secondCell);
      });

      test('ArrowLeft at non-rowheader moves left', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const secondCell = cells.nth(1);
        const focusedElement = await focusCell(page, secondCell);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowLeft');

        const firstCell = cells.first();
        await expectCellOrChildFocused(page, firstCell);
      });

      test('Home moves to first cell in row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const rowheaders = treegrid.getByRole('rowheader');
        const secondCell = cells.nth(1);
        const focusedElement = await focusCell(page, secondCell);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('Home');

        // Should move to rowheader (first cell in row)
        const firstRowheader = rowheaders.first();
        await expectCellOrChildFocused(page, firstRowheader);
      });

      test('End moves to last cell in row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const firstRowheader = rowheaders.first();
        const focusedElement = await focusCell(page, firstRowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('End');

        // Should move to last cell in first data row
        // Get cells in the same row
        const row = await getRowForCell(firstRowheader);
        const cellsInRow = row.getByRole('gridcell');
        const lastCell = cellsInRow.last();
        await expectCellOrChildFocused(page, lastCell);
      });

      test('Ctrl+Home moves to first cell in grid', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const rowheaders = treegrid.getByRole('rowheader');
        const lastCell = cells.last();
        const focusedElement = await focusCell(page, lastCell);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('Control+Home');

        // Should move to first rowheader
        const firstRowheader = rowheaders.first();
        await expectCellOrChildFocused(page, firstRowheader);
      });

      test('Ctrl+End moves to last cell in grid', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const cells = treegrid.getByRole('gridcell');
        const firstRowheader = rowheaders.first();
        const focusedElement = await focusCell(page, firstRowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('Control+End');

        // Should move to last cell
        const lastCell = cells.last();
        await expectCellOrChildFocused(page, lastCell);
      });
    });

    // 🔴 High Priority: Keyboard - Tree Operations
    test.describe('Keyboard - Tree Operations', () => {
      test('ArrowRight at collapsed rowheader expands row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a collapsed parent row
        let collapsedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'false') {
            collapsedRowIndex = i;
            break;
          }
        }

        if (collapsedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(collapsedRowIndex);
        const rowheader = row.getByRole('rowheader');
        const focusedElement = await focusCell(page, rowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowRight');

        await expect(row).toHaveAttribute('aria-expanded', 'true');
      });

      test('expanding a row makes child rows visible', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a collapsed parent row
        let collapsedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'false') {
            collapsedRowIndex = i;
            break;
          }
        }

        if (collapsedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(collapsedRowIndex);
        const rowheader = row.getByRole('rowheader');

        // Get initial visible rowheader count
        const visibleRowheadersBefore = await treegrid.getByRole('rowheader').count();

        const focusedElement = await focusCell(page, rowheader);
        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowRight');

        await expect(row).toHaveAttribute('aria-expanded', 'true');

        // After expansion, there should be more visible rowheaders (child rows appeared)
        const visibleRowheadersAfter = await treegrid.getByRole('rowheader').count();
        expect(visibleRowheadersAfter).toBeGreaterThan(visibleRowheadersBefore);
      });

      test('ArrowLeft at expanded rowheader collapses row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find an expanded parent row
        let expandedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'true') {
            expandedRowIndex = i;
            break;
          }
        }

        if (expandedRowIndex === -1) {
          // Try to expand first, then collapse
          for (let i = 0; i < rowCount; i++) {
            const row = rows.nth(i);
            const ariaExpanded = await row.getAttribute('aria-expanded');
            if (ariaExpanded === 'false') {
              const rowheader = row.getByRole('rowheader');
              const focused = await focusCell(page, rowheader);
              await expect(focused).toBeFocused();
              await focused.press('ArrowRight');
              await expect(row).toHaveAttribute('aria-expanded', 'true');
              expandedRowIndex = i;
              break;
            }
          }
        }

        if (expandedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(expandedRowIndex);
        const rowheader = row.getByRole('rowheader');
        const focusedElement = await focusCell(page, rowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowLeft');

        await expect(row).toHaveAttribute('aria-expanded', 'false');
      });

      test('ArrowRight at expanded rowheader moves right to next cell', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find an expanded parent row
        let expandedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'true') {
            expandedRowIndex = i;
            break;
          }
        }

        if (expandedRowIndex === -1) {
          // Try to expand a collapsed row first
          for (let i = 0; i < rowCount; i++) {
            const row = rows.nth(i);
            const ariaExpanded = await row.getAttribute('aria-expanded');
            if (ariaExpanded === 'false') {
              const rowheader = row.getByRole('rowheader');
              const focused = await focusCell(page, rowheader);
              await expect(focused).toBeFocused();
              await focused.press('ArrowRight');
              await expect(row).toHaveAttribute('aria-expanded', 'true');
              expandedRowIndex = i;
              break;
            }
          }
        }

        if (expandedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(expandedRowIndex);
        const rowheader = row.getByRole('rowheader');
        const cells = row.getByRole('gridcell');
        const firstCell = cells.first();

        const focusedElement = await focusCell(page, rowheader);

        // ArrowRight at expanded rowheader should move to the next cell (not expand again)
        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowRight');

        // Row should still be expanded
        await expect(row).toHaveAttribute('aria-expanded', 'true');

        // Focus should move to first gridcell in same row
        await expectCellOrChildFocused(page, firstCell);
      });

      test('ArrowRight at non-rowheader does NOT expand', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a collapsed parent row
        let collapsedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'false') {
            collapsedRowIndex = i;
            break;
          }
        }

        if (collapsedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(collapsedRowIndex);
        const cells = row.getByRole('gridcell');
        const firstCell = cells.first();
        const focusedElement = await focusCell(page, firstCell);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowRight');

        // Should NOT expand - still collapsed
        await expect(row).toHaveAttribute('aria-expanded', 'false');
      });

      test('Enter does NOT expand/collapse', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a collapsed parent row
        let collapsedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'false') {
            collapsedRowIndex = i;
            break;
          }
        }

        if (collapsedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(collapsedRowIndex);
        const rowheader = row.getByRole('rowheader');
        const focusedElement = await focusCell(page, rowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('Enter');

        // Should still be collapsed
        await expect(row).toHaveAttribute('aria-expanded', 'false');
      });
    });

    // 🔴 High Priority: Row Selection
    test.describe('Row Selection', () => {
      test('Space toggles row selection', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a selectable row
        let selectableRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaSelected = await row.getAttribute('aria-selected');
          if (ariaSelected !== null) {
            selectableRowIndex = i;
            break;
          }
        }

        if (selectableRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(selectableRowIndex);
        const rowheader = row.getByRole('rowheader');
        const focusedElement = await focusCell(page, rowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('Space');

        await expect(row).toHaveAttribute('aria-selected', 'true');
      });

      test('Space toggles row selection off', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a selectable row
        let selectableRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaSelected = await row.getAttribute('aria-selected');
          if (ariaSelected !== null) {
            selectableRowIndex = i;
            break;
          }
        }

        if (selectableRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(selectableRowIndex);
        const rowheader = row.getByRole('rowheader');
        const focusedElement = await focusCell(page, rowheader);

        // Select
        await expect(focusedElement).toBeFocused();
        await focusedElement.press('Space');
        await expect(row).toHaveAttribute('aria-selected', 'true');

        // Deselect (focus should still be on the same element after Space)
        await focusedElement.press('Space');
        await expect(row).toHaveAttribute('aria-selected', 'false');
      });
    });

    // 🔴 High Priority: Focus Management
    test.describe('Focus Management', () => {
      test('first focusable cell has tabIndex="0"', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const firstRowheader = rowheaders.first();
        await expect(firstRowheader).toHaveAttribute('tabindex', '0');
      });

      test('other cells have tabIndex="-1"', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const firstCell = cells.first();
        await expect(firstCell).toHaveAttribute('tabindex', '-1');
      });

      test('columnheader cells are not focusable', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const headers = treegrid.getByRole('columnheader');
        const firstHeader = headers.first();
        const tabindex = await firstHeader.getAttribute('tabindex');
        expect(tabindex).toBeNull();
      });

      test('Tab exits treegrid', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const firstRowheader = rowheaders.first();
        await focusCell(page, firstRowheader);

        await page.keyboard.press('Tab');

        const treegridContainsFocus = await treegrid.evaluate((el) =>
          el.contains(document.activeElement)
        );
        expect(treegridContainsFocus).toBe(false);
      });

      test('roving tabindex updates on arrow navigation', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const firstCell = cells.first(); // First gridcell (not rowheader) = Size column of first row
        const secondCell = cells.nth(1); // Date column of first row

        // Initially first rowheader has tabindex="0", gridcells have "-1"
        await expect(firstCell).toHaveAttribute('tabindex', '-1');
        await expect(secondCell).toHaveAttribute('tabindex', '-1');

        // Focus first gridcell (Size column) and navigate right to Date column
        const focusedElement = await focusCell(page, firstCell);
        await expect(firstCell).toHaveAttribute('tabindex', '0');

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowRight');

        // After navigation, tabindex should update
        await expect(firstCell).toHaveAttribute('tabindex', '-1');
        await expect(secondCell).toHaveAttribute('tabindex', '0');
      });
    });
  });
}

Running Tests

          
            # Run unit tests for TreeGrid
npm run test -- treegrid

# Run E2E tests for TreeGrid (all frameworks)
npm run test:e2e:pattern --pattern=treegrid

# Run E2E tests for specific framework
npm run test:e2e:react:pattern --pattern=treegrid

npm run test:e2e:vue:pattern --pattern=treegrid

npm run test:e2e:svelte:pattern --pattern=treegrid

npm run test:e2e:astro:pattern --pattern=treegrid
          
        

Key Difference from Grid

  • Selection target: TreeGrid selects rows (aria-selected on row), Grid selects cells
  • Arrow behavior at rowheader: ArrowRight/Left do tree operations, not cell navigation
  • Hierarchy: Must test aria-level values and parent/child relationships
  • Hidden rows: Must skip collapsed children during navigation

Testing Tools

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

Resources