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.

Use arrow keys to navigate between cells. At the first column (rowheader), ArrowRight expands collapsed rows and ArrowLeft collapses expanded rows. Press Space to select/deselect rows. Press Enter to activate a cell.

Name
Size
Date Modified
Documents
--
2024-01-15
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.tsx
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

// =============================================================================
// 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;
}

export interface TreeGridProps {
  columns: TreeGridColumnDef[];
  nodes: TreeGridNodeData[];

  // Accessible name (one required)
  ariaLabel?: string;
  ariaLabelledby?: string;

  // Expansion
  expandedIds?: string[];
  defaultExpandedIds?: string[];
  onExpandedChange?: (ids: string[]) => void;

  // Selection (row-based)
  selectable?: boolean;
  multiselectable?: boolean;
  selectedRowIds?: string[];
  defaultSelectedRowIds?: string[];
  onSelectionChange?: (rowIds: string[]) => void;

  // Focus
  focusedCellId?: string | null;
  defaultFocusedCellId?: string;
  onFocusChange?: (cellId: string | null) => void;

  // Virtualization
  totalRows?: number;
  totalColumns?: number;
  startRowIndex?: number;
  startColIndex?: number;

  // Behavior
  enablePageNavigation?: boolean;
  pageSize?: number;

  // Callbacks
  onCellActivate?: (cellId: string, rowId: string, colId: string) => void;
  onRowActivate?: (rowId: string) => void;

  // Styling
  className?: string;
}

// Flattened node for easier navigation
interface FlatRow {
  node: TreeGridNodeData;
  level: number;
  parentId: string | null;
  hasChildren: boolean;
}

// =============================================================================
// Component
// =============================================================================

export function TreeGrid({
  columns,
  nodes,
  ariaLabel,
  ariaLabelledby,
  expandedIds: controlledExpandedIds,
  defaultExpandedIds = [],
  onExpandedChange,
  selectable = false,
  multiselectable = false,
  selectedRowIds: controlledSelectedRowIds,
  defaultSelectedRowIds = [],
  onSelectionChange,
  focusedCellId: controlledFocusedCellId,
  defaultFocusedCellId,
  onFocusChange,
  totalRows,
  totalColumns,
  startRowIndex = 1,
  startColIndex = 1,
  enablePageNavigation = false,
  pageSize = 5,
  onCellActivate,
  onRowActivate,
  className,
}: TreeGridProps) {
  // ==========================================================================
  // State
  // ==========================================================================

  const [internalExpandedIds, setInternalExpandedIds] = useState<string[]>(defaultExpandedIds);
  const expandedIds = controlledExpandedIds ?? internalExpandedIds;
  const expandedSet = useMemo(() => new Set(expandedIds), [expandedIds]);

  const [internalSelectedRowIds, setInternalSelectedRowIds] =
    useState<string[]>(defaultSelectedRowIds);
  const selectedRowIds = controlledSelectedRowIds ?? internalSelectedRowIds;
  const selectedRowSet = useMemo(() => new Set(selectedRowIds), [selectedRowIds]);

  const [internalFocusedCellId, setInternalFocusedCellId] = useState<string | null>(() => {
    if (defaultFocusedCellId) return defaultFocusedCellId;
    // Default to first cell of first node
    return nodes[0]?.cells[0]?.id ?? null;
  });
  const focusedCellId =
    controlledFocusedCellId !== undefined ? controlledFocusedCellId : internalFocusedCellId;

  const gridRef = useRef<HTMLDivElement>(null);
  const cellRefs = useRef<Map<string, HTMLDivElement>>(new Map());

  // ==========================================================================
  // Computed values - Flatten tree
  // ==========================================================================

  /* eslint-disable react-hooks/immutability -- Recursive function requires self-reference */
  const flattenTree = useCallback(
    (
      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;
    },
    []
  );
  /* eslint-enable react-hooks/immutability */

  const allRows = useMemo(() => flattenTree(nodes), [nodes, flattenTree]);

  const rowMap = useMemo(() => {
    const map = new Map<string, FlatRow>();
    for (const flatRow of allRows) {
      map.set(flatRow.node.id, flatRow);
    }
    return map;
  }, [allRows]);

  // Visible rows based on expansion state
  const visibleRows = useMemo(() => {
    const result: FlatRow[] = [];
    const collapsedParents = new Set<string>();

    for (const flatRow of allRows) {
      // Check if any ancestor is collapsed
      let isHidden = false;
      let currentParentId = flatRow.parentId;
      while (currentParentId) {
        if (collapsedParents.has(currentParentId) || !expandedSet.has(currentParentId)) {
          isHidden = true;
          break;
        }
        const parent = rowMap.get(currentParentId);
        currentParentId = parent?.parentId ?? null;
      }

      if (!isHidden) {
        result.push(flatRow);
        if (flatRow.hasChildren && !expandedSet.has(flatRow.node.id)) {
          collapsedParents.add(flatRow.node.id);
        }
      }
    }
    return result;
  }, [allRows, expandedSet, rowMap]);

  const visibleRowIndexMap = useMemo(() => {
    const map = new Map<string, number>();
    visibleRows.forEach((flatRow, index) => map.set(flatRow.node.id, index));
    return map;
  }, [visibleRows]);

  // Cell lookup
  const cellById = useMemo(() => {
    const map = new Map<
      string,
      { rowId: string; colIndex: number; cell: TreeGridCellData; flatRow: FlatRow }
    >();
    for (const flatRow of allRows) {
      flatRow.node.cells.forEach((cell, colIndex) => {
        map.set(cell.id, { rowId: flatRow.node.id, colIndex, cell, flatRow });
      });
    }
    return map;
  }, [allRows]);

  // ==========================================================================
  // Helpers
  // ==========================================================================

  const getRowHeaderColumnIndex = useCallback(() => {
    return columns.findIndex((col) => col.isRowHeader);
  }, [columns]);

  // ==========================================================================
  // Focus Management
  // ==========================================================================

  const setFocusedCellId = useCallback(
    (id: string | null) => {
      setInternalFocusedCellId(id);
      onFocusChange?.(id);
    },
    [onFocusChange]
  );

  const focusCell = useCallback(
    (cellId: string) => {
      const cellEl = cellRefs.current.get(cellId);
      if (cellEl) {
        cellEl.focus();
        setFocusedCellId(cellId);
      }
    },
    [setFocusedCellId]
  );

  // ==========================================================================
  // Expansion Management
  // ==========================================================================

  const setExpandedIds = useCallback(
    (ids: string[]) => {
      setInternalExpandedIds(ids);
      onExpandedChange?.(ids);
    },
    [onExpandedChange]
  );

  const expandNode = useCallback(
    (rowId: string) => {
      const flatRow = rowMap.get(rowId);
      if (!flatRow?.hasChildren || flatRow.node.disabled) return;
      if (expandedSet.has(rowId)) return;

      const newExpanded = [...expandedIds, rowId];
      setExpandedIds(newExpanded);
    },
    [rowMap, expandedSet, expandedIds, setExpandedIds]
  );

  const collapseNode = useCallback(
    (rowId: string) => {
      const flatRow = rowMap.get(rowId);
      if (!flatRow?.hasChildren || flatRow.node.disabled) return;
      if (!expandedSet.has(rowId)) return;

      const newExpanded = expandedIds.filter((id) => id !== rowId);
      setExpandedIds(newExpanded);

      // If focus is on a descendant, move focus to the collapsed row
      if (focusedCellId) {
        const focusedEntry = cellById.get(focusedCellId);
        if (focusedEntry) {
          let parentId = focusedEntry.flatRow.parentId;
          while (parentId) {
            if (parentId === rowId) {
              // Focus the rowheader of the collapsed row
              const rowHeaderColIndex = getRowHeaderColumnIndex();
              const collapsedRowCell = flatRow.node.cells[rowHeaderColIndex];
              if (collapsedRowCell) {
                focusCell(collapsedRowCell.id);
              }
              break;
            }
            const parent = rowMap.get(parentId);
            parentId = parent?.parentId ?? null;
          }
        }
      }
    },
    [
      rowMap,
      expandedSet,
      expandedIds,
      setExpandedIds,
      focusedCellId,
      cellById,
      getRowHeaderColumnIndex,
      focusCell,
    ]
  );

  // ==========================================================================
  // Selection Management
  // ==========================================================================

  const setSelectedRowIds = useCallback(
    (ids: string[]) => {
      setInternalSelectedRowIds(ids);
      onSelectionChange?.(ids);
    },
    [onSelectionChange]
  );

  const toggleRowSelection = useCallback(
    (rowId: string) => {
      const flatRow = rowMap.get(rowId);
      if (!selectable || flatRow?.node.disabled) return;

      if (multiselectable) {
        const newIds = selectedRowSet.has(rowId)
          ? selectedRowIds.filter((id) => id !== rowId)
          : [...selectedRowIds, rowId];
        setSelectedRowIds(newIds);
      } else {
        const newIds = selectedRowSet.has(rowId) ? [] : [rowId];
        setSelectedRowIds(newIds);
      }
    },
    [rowMap, selectable, multiselectable, selectedRowSet, selectedRowIds, setSelectedRowIds]
  );

  const selectAllVisibleRows = useCallback(() => {
    if (!selectable || !multiselectable) return;

    const allVisibleRowIds = visibleRows
      .filter((flatRow) => !flatRow.node.disabled)
      .map((flatRow) => flatRow.node.id);
    setSelectedRowIds(allVisibleRowIds);
  }, [selectable, multiselectable, visibleRows, setSelectedRowIds]);

  // ==========================================================================
  // Navigation
  // ==========================================================================

  const getVisibleRowByIndex = useCallback(
    (index: number) => {
      return visibleRows[index] ?? null;
    },
    [visibleRows]
  );

  const navigateToCell = useCallback(
    (rowId: string, colIndex: number) => {
      const flatRow = rowMap.get(rowId);
      if (!flatRow) return;

      const cell = flatRow.node.cells[colIndex];
      if (cell) {
        focusCell(cell.id);
      }
    },
    [rowMap, focusCell]
  );

  // ==========================================================================
  // Keyboard Handling
  // ==========================================================================

  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent, cell: TreeGridCellData, rowId: string, colIndex: number) => {
      const { key, ctrlKey } = event;
      const flatRow = rowMap.get(rowId);
      if (!flatRow) return;

      const visibleRowIndex = visibleRowIndexMap.get(rowId);
      if (visibleRowIndex === undefined) return;

      const rowHeaderColIndex = getRowHeaderColumnIndex();
      const isRowHeader = colIndex === rowHeaderColIndex;

      let handled = true;

      switch (key) {
        case 'ArrowDown': {
          const nextVisibleRow = getVisibleRowByIndex(visibleRowIndex + 1);
          if (nextVisibleRow) {
            navigateToCell(nextVisibleRow.node.id, colIndex);
          }
          break;
        }
        case 'ArrowUp': {
          if (visibleRowIndex > 0) {
            const prevVisibleRow = getVisibleRowByIndex(visibleRowIndex - 1);
            if (prevVisibleRow) {
              navigateToCell(prevVisibleRow.node.id, colIndex);
            }
          }
          break;
        }
        case 'ArrowRight': {
          if (
            isRowHeader &&
            flatRow.hasChildren &&
            !flatRow.node.disabled &&
            !expandedSet.has(rowId)
          ) {
            // Collapsed parent at rowheader: expand
            expandNode(rowId);
          } else {
            // Expanded parent at rowheader, leaf row at rowheader, or non-rowheader: move right
            if (colIndex < columns.length - 1) {
              const nextCell = flatRow.node.cells[colIndex + 1];
              if (nextCell) {
                focusCell(nextCell.id);
              }
            }
          }
          break;
        }
        case 'ArrowLeft': {
          if (isRowHeader) {
            if (flatRow.hasChildren && expandedSet.has(rowId) && !flatRow.node.disabled) {
              // Collapse expanded parent
              collapseNode(rowId);
            } else if (flatRow.parentId) {
              // Move to parent
              const parentRow = rowMap.get(flatRow.parentId);
              if (parentRow) {
                navigateToCell(parentRow.node.id, rowHeaderColIndex);
              }
            }
            // Root level collapsed: do nothing
          } else {
            // Non-rowheader: move left
            if (colIndex > 0) {
              const prevCell = flatRow.node.cells[colIndex - 1];
              if (prevCell) {
                focusCell(prevCell.id);
              }
            }
          }
          break;
        }
        case 'Home': {
          if (ctrlKey) {
            // Ctrl+Home: First cell in grid
            const firstRow = visibleRows[0];
            if (firstRow) {
              navigateToCell(firstRow.node.id, 0);
            }
          } else {
            // Home: First cell in current row
            const firstCell = flatRow.node.cells[0];
            if (firstCell) {
              focusCell(firstCell.id);
            }
          }
          break;
        }
        case 'End': {
          if (ctrlKey) {
            // Ctrl+End: Last cell in grid
            const lastRow = visibleRows[visibleRows.length - 1];
            if (lastRow) {
              navigateToCell(lastRow.node.id, columns.length - 1);
            }
          } else {
            // End: Last cell in current row
            const lastCell = flatRow.node.cells[flatRow.node.cells.length - 1];
            if (lastCell) {
              focusCell(lastCell.id);
            }
          }
          break;
        }
        case 'PageDown': {
          if (enablePageNavigation) {
            const targetIndex = Math.min(visibleRowIndex + pageSize, visibleRows.length - 1);
            const targetRow = visibleRows[targetIndex];
            if (targetRow) {
              navigateToCell(targetRow.node.id, colIndex);
            }
          } else {
            handled = false;
          }
          break;
        }
        case 'PageUp': {
          if (enablePageNavigation) {
            const targetIndex = Math.max(visibleRowIndex - pageSize, 0);
            const targetRow = visibleRows[targetIndex];
            if (targetRow) {
              navigateToCell(targetRow.node.id, colIndex);
            }
          } else {
            handled = false;
          }
          break;
        }
        case ' ': {
          toggleRowSelection(rowId);
          break;
        }
        case 'Enter': {
          if (!cell.disabled && !flatRow.node.disabled) {
            const colId = columns[colIndex]?.id ?? '';
            onCellActivate?.(cell.id, rowId, colId);
            onRowActivate?.(rowId);
          }
          break;
        }
        case 'a': {
          if (ctrlKey) {
            selectAllVisibleRows();
          } else {
            handled = false;
          }
          break;
        }
        default:
          handled = false;
      }

      if (handled) {
        event.preventDefault();
        event.stopPropagation();
      }
    },
    [
      rowMap,
      visibleRowIndexMap,
      getRowHeaderColumnIndex,
      getVisibleRowByIndex,
      navigateToCell,
      expandedSet,
      expandNode,
      collapseNode,
      columns,
      focusCell,
      visibleRows,
      enablePageNavigation,
      pageSize,
      toggleRowSelection,
      onCellActivate,
      onRowActivate,
      selectAllVisibleRows,
    ]
  );

  // ==========================================================================
  // Effects
  // ==========================================================================

  // Focus the focused cell when focusedCellId changes externally
  useEffect(() => {
    if (focusedCellId) {
      const cellEl = cellRefs.current.get(focusedCellId);
      if (cellEl && document.activeElement !== cellEl) {
        if (gridRef.current?.contains(document.activeElement)) {
          cellEl.focus();
        }
      }
    }
  }, [focusedCellId]);

  // ==========================================================================
  // Render
  // ==========================================================================

  return (
    <div
      ref={gridRef}
      role="treegrid"
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      aria-multiselectable={multiselectable ? 'true' : undefined}
      aria-rowcount={totalRows}
      aria-colcount={totalColumns}
      className={`apg-treegrid ${className ?? ''}`}
    >
      {/* Header Row */}
      <div role="row" aria-rowindex={totalRows ? 1 : undefined}>
        {columns.map((col, colIndex) => (
          <div
            key={col.id}
            role="columnheader"
            aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
          >
            {col.header}
          </div>
        ))}
      </div>

      {/* Data Rows */}
      {visibleRows.map((flatRow, visibleIndex) => {
        const { node, level, hasChildren } = flatRow;
        const isExpanded = expandedSet.has(node.id);
        const isSelected = selectedRowSet.has(node.id);

        return (
          <div
            key={node.id}
            role="row"
            aria-level={level}
            aria-expanded={hasChildren ? isExpanded : undefined}
            aria-selected={selectable ? isSelected : undefined}
            aria-disabled={node.disabled ? 'true' : undefined}
            aria-rowindex={totalRows ? startRowIndex + visibleIndex : undefined}
            className={`apg-treegrid-row ${isSelected ? 'selected' : ''} ${node.disabled ? 'disabled' : ''}`}
            style={{ '--level': level } satisfies React.CSSProperties}
          >
            {node.cells.map((cell, colIndex) => {
              const col = columns[colIndex];
              const isRowHeader = col?.isRowHeader ?? false;
              const isFocused = cell.id === focusedCellId;

              return (
                <div
                  key={cell.id}
                  ref={(el) => {
                    if (el) {
                      cellRefs.current.set(cell.id, el);
                    } else {
                      cellRefs.current.delete(cell.id);
                    }
                  }}
                  role={isRowHeader ? 'rowheader' : 'gridcell'}
                  tabIndex={isFocused ? 0 : -1}
                  aria-disabled={cell.disabled ? 'true' : undefined}
                  aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
                  aria-colspan={cell.colspan}
                  onKeyDown={(e) => handleKeyDown(e, cell, node.id, colIndex)}
                  onFocus={() => setFocusedCellId(cell.id)}
                  className={`apg-treegrid-cell ${isFocused ? 'focused' : ''} ${cell.disabled ? 'disabled' : ''}`}
                >
                  {isRowHeader && hasChildren && (
                    <span className="apg-treegrid-expand-icon" aria-hidden="true">
                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
                        <polyline points="9 6 15 12 9 18" />
                      </svg>
                    </span>
                  )}
                  {cell.value}
                </div>
              );
            })}
          </div>
        );
      })}
    </div>
  );
}

export default TreeGrid;

Usage

Example
import { TreeGrid } from './TreeGrid';
import type { TreeGridColumnDef, TreeGridNodeData } from './TreeGrid';

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

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

// Basic TreeGrid
<TreeGrid
  columns={columns}
  nodes={nodes}
  ariaLabel="File browser"
/>

// With selection and expand control
<TreeGrid
  columns={columns}
  nodes={nodes}
  ariaLabel="File browser"
  selectable
  multiselectable
  expandedIds={expandedIds}
  selectedRowIds={selectedRowIds}
  onExpandedChange={(ids) => setExpandedIds(ids)}
  onSelectionChange={(ids) => setSelectedRowIds(ids)}
/>

API

Prop Type Default Description
columns TreeGridColumnDef[] required Column definitions
nodes TreeGridNodeData[] required Hierarchical node data
ariaLabel string - Accessible name
expandedIds string[] [] Expanded row IDs
onExpandedChange (ids: string[]) => void - Expand state change callback
selectable boolean false Enable row selection
multiselectable boolean false Enable multi-row selection
selectedRowIds string[] [] Selected row IDs
onSelectionChange (ids: string[]) => void - Selection change callback
onCellActivate (cellId, rowId, colId) => void - Cell activation callback

TreeGridColumnDef Props

Prop Type Default Description
id string required Unique column identifier
header string required Column header text
isRowHeader boolean false Whether this column is a row header

TreeGridCellData Props

Prop Type Default Description
id string required Unique cell identifier
value string | number required Cell value
disabled boolean false Whether the cell is disabled

TreeGridNodeData Props

Prop Type Default Description
id string required Unique node identifier
cells TreeGridCellData[] required Array of cell data for this row
children TreeGridNodeData[] - Child nodes (makes this a parent row)
disabled boolean false Whether the node is disabled

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.

TreeGrid.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { TreeGrid, type TreeGridColumnDef, type TreeGridNodeData } from './TreeGrid';

// =============================================================================
// Test Data Helpers
// =============================================================================

const createBasicColumns = (): TreeGridColumnDef[] => [
  { id: 'name', header: 'Name', isRowHeader: true },
  { id: 'size', header: 'Size' },
  { id: 'date', header: 'Date Modified' },
];

const createBasicNodes = (): TreeGridNodeData[] => [
  {
    id: 'docs',
    cells: [
      { id: 'docs-name', value: 'Documents' },
      { id: 'docs-size', value: '--' },
      { id: 'docs-date', value: '2024-01-15' },
    ],
    children: [
      {
        id: 'report',
        cells: [
          { id: 'report-name', value: 'report.pdf' },
          { id: 'report-size', value: '2.5 MB' },
          { id: 'report-date', value: '2024-01-10' },
        ],
      },
      {
        id: 'notes',
        cells: [
          { id: 'notes-name', value: 'notes.txt' },
          { id: 'notes-size', value: '1 KB' },
          { id: 'notes-date', value: '2024-01-05' },
        ],
      },
    ],
  },
  {
    id: 'images',
    cells: [
      { id: 'images-name', value: 'Images' },
      { id: 'images-size', value: '--' },
      { id: 'images-date', value: '2024-01-20' },
    ],
    children: [
      {
        id: 'photo1',
        cells: [
          { id: 'photo1-name', value: 'photo1.jpg' },
          { id: 'photo1-size', value: '3 MB' },
          { id: 'photo1-date', value: '2024-01-18' },
        ],
      },
    ],
  },
  {
    id: 'readme',
    cells: [
      { id: 'readme-name', value: 'README.md' },
      { id: 'readme-size', value: '4 KB' },
      { id: 'readme-date', value: '2024-01-01' },
    ],
  },
];

const createNestedNodes = (): TreeGridNodeData[] => [
  {
    id: 'root',
    cells: [
      { id: 'root-name', value: 'Root' },
      { id: 'root-size', value: '--' },
    ],
    children: [
      {
        id: 'level2',
        cells: [
          { id: 'level2-name', value: 'Level 2' },
          { id: 'level2-size', value: '--' },
        ],
        children: [
          {
            id: 'level3',
            cells: [
              { id: 'level3-name', value: 'Level 3' },
              { id: 'level3-size', value: '1 KB' },
            ],
          },
        ],
      },
    ],
  },
];

const createNodesWithDisabled = (): TreeGridNodeData[] => [
  {
    id: 'docs',
    cells: [
      { id: 'docs-name', value: 'Documents' },
      { id: 'docs-size', value: '--' },
    ],
    disabled: true,
    children: [
      {
        id: 'report',
        cells: [
          { id: 'report-name', value: 'report.pdf' },
          { id: 'report-size', value: '2.5 MB' },
        ],
      },
    ],
  },
  {
    id: 'readme',
    cells: [
      { id: 'readme-name', value: 'README.md' },
      { id: 'readme-size', value: '4 KB' },
    ],
  },
];

// =============================================================================
// Tests
// =============================================================================

describe('TreeGrid', () => {
  // ===========================================================================
  // ARIA Attributes
  // ===========================================================================
  describe('ARIA Attributes', () => {
    it('has role="treegrid" on container', () => {
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );
      expect(screen.getByRole('treegrid')).toBeInTheDocument();
    });

    it('has role="row" on all rows', () => {
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          defaultExpandedIds={['docs', 'images']}
        />
      );
      // Header row + 3 root level + 2 docs children + 1 images child = 7 rows
      expect(screen.getAllByRole('row')).toHaveLength(7);
    });

    it('has role="gridcell" on data cells', () => {
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          defaultExpandedIds={['docs', 'images']}
        />
      );
      // 6 data rows * 2 gridcells per row (excluding rowheader) = 12 gridcells
      expect(screen.getAllByRole('gridcell')).toHaveLength(12);
    });

    it('has role="columnheader" on header cells', () => {
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );
      expect(screen.getAllByRole('columnheader')).toHaveLength(3);
    });

    it('has role="rowheader" on first column cells', () => {
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          defaultExpandedIds={['docs', 'images']}
        />
      );
      // 6 data rows * 1 rowheader = 6 rowheaders
      expect(screen.getAllByRole('rowheader')).toHaveLength(6);
    });

    it('has accessible name via aria-label', () => {
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );
      expect(screen.getByRole('treegrid', { name: 'Files' })).toBeInTheDocument();
    });

    it('has accessible name via aria-labelledby', () => {
      render(
        <div>
          <h2 id="grid-title">File Browser</h2>
          <TreeGrid
            columns={createBasicColumns()}
            nodes={createBasicNodes()}
            ariaLabelledby="grid-title"
          />
        </div>
      );
      const treegrid = screen.getByRole('treegrid');
      expect(treegrid).toHaveAttribute('aria-labelledby', 'grid-title');
    });

    it('parent rows have aria-expanded', () => {
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      const rows = screen.getAllByRole('row').filter((r) => r.hasAttribute('aria-level'));
      // docs and images are parent rows (rowheader includes expand icon, so use includes)
      const docsRow = rows.find((r) =>
        r.querySelector('[role="rowheader"]')?.textContent?.includes('Documents')
      );
      const imagesRow = rows.find((r) =>
        r.querySelector('[role="rowheader"]')?.textContent?.includes('Images')
      );

      expect(docsRow).toHaveAttribute('aria-expanded');
      expect(imagesRow).toHaveAttribute('aria-expanded');
    });

    it('leaf rows do NOT have aria-expanded', () => {
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      const rows = screen.getAllByRole('row').filter((r) => r.hasAttribute('aria-level'));
      // readme is a leaf row
      const readmeRow = rows.find((r) =>
        r.querySelector('[role="rowheader"]')?.textContent?.includes('README.md')
      );

      expect(readmeRow).not.toHaveAttribute('aria-expanded');
    });

    it('has aria-level on all data rows', () => {
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          defaultExpandedIds={['docs', 'images']}
        />
      );

      const dataRows = screen.getAllByRole('row').filter((r) => r.hasAttribute('aria-level'));
      expect(dataRows.length).toBeGreaterThan(0);
      dataRows.forEach((row) => {
        expect(row).toHaveAttribute('aria-level');
      });
    });

    it('aria-level increments with depth (1-based)', () => {
      const columns: TreeGridColumnDef[] = [
        { id: 'name', header: 'Name', isRowHeader: true },
        { id: 'size', header: 'Size' },
      ];

      render(
        <TreeGrid
          columns={columns}
          nodes={createNestedNodes()}
          ariaLabel="Files"
          defaultExpandedIds={['root', 'level2']}
        />
      );

      const rows = screen.getAllByRole('row').filter((r) => r.hasAttribute('aria-level'));

      const rootRow = rows.find((r) =>
        r.querySelector('[role="rowheader"]')?.textContent?.includes('Root')
      );
      const level2Row = rows.find((r) =>
        r.querySelector('[role="rowheader"]')?.textContent?.includes('Level 2')
      );
      const level3Row = rows.find((r) =>
        r.querySelector('[role="rowheader"]')?.textContent?.includes('Level 3')
      );

      expect(rootRow).toHaveAttribute('aria-level', '1');
      expect(level2Row).toHaveAttribute('aria-level', '2');
      expect(level3Row).toHaveAttribute('aria-level', '3');
    });

    it('has aria-selected on row (not gridcell) when selectable', () => {
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          selectable
        />
      );

      // Check that rows have aria-selected
      const dataRows = screen.getAllByRole('row').filter((r) => r.hasAttribute('aria-level'));
      dataRows.forEach((row) => {
        expect(row).toHaveAttribute('aria-selected', 'false');
      });

      // Check that gridcells do NOT have aria-selected
      const gridcells = screen.getAllByRole('gridcell');
      gridcells.forEach((cell) => {
        expect(cell).not.toHaveAttribute('aria-selected');
      });
    });

    it('has aria-multiselectable when multiselectable', () => {
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          selectable
          multiselectable
        />
      );

      expect(screen.getByRole('treegrid')).toHaveAttribute('aria-multiselectable', 'true');
    });

    it('has aria-disabled on disabled rows', () => {
      const columns: TreeGridColumnDef[] = [
        { id: 'name', header: 'Name', isRowHeader: true },
        { id: 'size', header: 'Size' },
      ];

      render(<TreeGrid columns={columns} nodes={createNodesWithDisabled()} ariaLabel="Files" />);

      const docsRow = screen
        .getAllByRole('row')
        .find((r) => r.querySelector('[role="rowheader"]')?.textContent?.includes('Documents'));
      expect(docsRow).toHaveAttribute('aria-disabled', 'true');
    });
  });

  // ===========================================================================
  // Keyboard - Row Navigation
  // ===========================================================================
  describe('Keyboard - Row Navigation', () => {
    it('ArrowDown moves to same column in next visible row', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          defaultExpandedIds={['docs']}
        />
      );

      // Focus first rowheader (Documents)
      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();

      await user.keyboard('{ArrowDown}');

      // Should move to report.pdf rowheader (first child)
      expect(screen.getByRole('rowheader', { name: 'report.pdf' })).toHaveFocus();
    });

    it('ArrowUp moves to same column in previous visible row', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          defaultExpandedIds={['docs']}
        />
      );

      // Focus report.pdf rowheader
      const reportRowheader = screen.getByRole('rowheader', { name: 'report.pdf' });
      reportRowheader.focus();

      await user.keyboard('{ArrowUp}');

      // Should move to Documents rowheader
      expect(screen.getByRole('rowheader', { name: 'Documents' })).toHaveFocus();
    });

    it('ArrowDown skips collapsed child rows', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      // Focus Documents rowheader (collapsed)
      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();

      await user.keyboard('{ArrowDown}');

      // Should skip children and move to Images
      expect(screen.getByRole('rowheader', { name: 'Images' })).toHaveFocus();
    });

    it('ArrowUp stops at first visible row', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      // Focus first data row
      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();

      await user.keyboard('{ArrowUp}');

      // Should stay at Documents (first data row)
      expect(docsRowheader).toHaveFocus();
    });

    it('ArrowDown stops at last visible row', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      // Focus last row (README.md)
      const readmeRowheader = screen.getByRole('rowheader', { name: 'README.md' });
      readmeRowheader.focus();

      await user.keyboard('{ArrowDown}');

      // Should stay at README.md
      expect(readmeRowheader).toHaveFocus();
    });

    it('maintains column position during row navigation', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          defaultExpandedIds={['docs']}
        />
      );

      // Focus size cell of Documents (first '--' cell)
      const docsSizeCell = screen.getAllByRole('gridcell', { name: '--' })[0];
      docsSizeCell.focus();

      await user.keyboard('{ArrowDown}');

      // Should move to size cell of report.pdf
      expect(screen.getByRole('gridcell', { name: '2.5 MB' })).toHaveFocus();
    });
  });

  // ===========================================================================
  // Keyboard - Cell Navigation
  // ===========================================================================
  describe('Keyboard - Cell Navigation', () => {
    it('ArrowRight moves right at non-rowheader cells', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      // Focus size cell
      const sizeCell = screen.getAllByRole('gridcell', { name: '--' })[0];
      sizeCell.focus();

      await user.keyboard('{ArrowRight}');

      // Should move to date cell
      expect(screen.getByRole('gridcell', { name: '2024-01-15' })).toHaveFocus();
    });

    it('ArrowLeft moves left at non-rowheader cells', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      // Focus date cell
      const dateCell = screen.getByRole('gridcell', { name: '2024-01-15' });
      dateCell.focus();

      await user.keyboard('{ArrowLeft}');

      // Should move to size cell
      expect(screen.getAllByRole('gridcell', { name: '--' })[0]).toHaveFocus();
    });

    it('ArrowRight at non-rowheader does NOT expand', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      // Focus size cell of Documents (collapsed parent)
      const sizeCell = screen.getAllByRole('gridcell', { name: '--' })[0];
      sizeCell.focus();

      const parentRow = sizeCell.closest('[role="row"]');
      expect(parentRow).toHaveAttribute('aria-expanded', 'false');

      await user.keyboard('{ArrowRight}');

      // Should NOT expand, just move right
      expect(parentRow).toHaveAttribute('aria-expanded', 'false');
    });

    it('ArrowLeft at non-rowheader does NOT collapse', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          defaultExpandedIds={['docs']}
        />
      );

      // Focus date cell of Documents (expanded parent)
      const dateCell = screen.getByRole('gridcell', { name: '2024-01-15' });
      dateCell.focus();

      const parentRow = dateCell.closest('[role="row"]');
      expect(parentRow).toHaveAttribute('aria-expanded', 'true');

      await user.keyboard('{ArrowLeft}');

      // Should NOT collapse, just move left
      expect(parentRow).toHaveAttribute('aria-expanded', 'true');
    });

    it('Home moves to first cell in row', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      // Focus last cell (date)
      const dateCell = screen.getByRole('gridcell', { name: '2024-01-15' });
      dateCell.focus();

      await user.keyboard('{Home}');

      // Should move to rowheader
      expect(screen.getByRole('rowheader', { name: 'Documents' })).toHaveFocus();
    });

    it('End moves to last cell in row', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      // Focus rowheader
      const rowheader = screen.getByRole('rowheader', { name: 'Documents' });
      rowheader.focus();

      await user.keyboard('{End}');

      // Should move to last cell (date)
      expect(screen.getByRole('gridcell', { name: '2024-01-15' })).toHaveFocus();
    });

    it('Ctrl+Home moves to first cell in grid', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      // Focus last row
      const readmeRowheader = screen.getByRole('rowheader', { name: 'README.md' });
      readmeRowheader.focus();

      await user.keyboard('{Control>}{Home}{/Control}');

      // Should move to first rowheader
      expect(screen.getByRole('rowheader', { name: 'Documents' })).toHaveFocus();
    });

    it('Ctrl+End moves to last cell in grid', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      // Focus first cell
      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();

      await user.keyboard('{Control>}{End}{/Control}');

      // Should move to last cell (date of README.md)
      expect(screen.getByRole('gridcell', { name: '2024-01-01' })).toHaveFocus();
    });
  });

  // ===========================================================================
  // Keyboard - Tree Operations (at rowheader only)
  // ===========================================================================
  describe('Keyboard - Tree Operations', () => {
    it('ArrowRight expands collapsed parent row at rowheader', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();

      const parentRow = docsRowheader.closest('[role="row"]');
      expect(parentRow).toHaveAttribute('aria-expanded', 'false');

      await user.keyboard('{ArrowRight}');

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

    it('ArrowRight moves to next cell when expanded at rowheader', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          defaultExpandedIds={['docs']}
        />
      );

      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();

      await user.keyboard('{ArrowRight}');

      // Should move to next cell (size cell: --)
      expect(screen.getAllByRole('gridcell', { name: '--' })[0]).toHaveFocus();
    });

    it('ArrowRight moves to next cell on leaf row at rowheader', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      const readmeRowheader = screen.getByRole('rowheader', { name: 'README.md' });
      readmeRowheader.focus();

      await user.keyboard('{ArrowRight}');

      // Should move to the next cell (size cell: 4 KB)
      expect(screen.getByRole('gridcell', { name: '4 KB' })).toHaveFocus();
    });

    it('ArrowLeft collapses expanded parent row at rowheader', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          defaultExpandedIds={['docs']}
        />
      );

      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();

      const parentRow = docsRowheader.closest('[role="row"]');
      expect(parentRow).toHaveAttribute('aria-expanded', 'true');

      await user.keyboard('{ArrowLeft}');

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

    it('ArrowLeft moves to parent when collapsed at rowheader', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          defaultExpandedIds={['docs']}
        />
      );

      // Focus on child row
      const reportRowheader = screen.getByRole('rowheader', { name: 'report.pdf' });
      reportRowheader.focus();

      await user.keyboard('{ArrowLeft}');

      // Should move to parent's rowheader
      expect(screen.getByRole('rowheader', { name: 'Documents' })).toHaveFocus();
    });

    it('ArrowLeft does nothing at root level collapsed row', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      // Documents is at root level and collapsed
      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();

      const parentRow = docsRowheader.closest('[role="row"]');
      expect(parentRow).toHaveAttribute('aria-expanded', 'false');

      await user.keyboard('{ArrowLeft}');

      // Should stay at Documents
      expect(docsRowheader).toHaveFocus();
    });
  });

  // ===========================================================================
  // Keyboard - Selection & Activation
  // ===========================================================================
  describe('Keyboard - Selection & Activation', () => {
    it('Enter activates cell (calls onCellActivate)', async () => {
      const user = userEvent.setup();
      const onCellActivate = vi.fn();
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          onCellActivate={onCellActivate}
        />
      );

      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();

      await user.keyboard('{Enter}');

      expect(onCellActivate).toHaveBeenCalledWith('docs-name', 'docs', 'name');
    });

    it('Enter does NOT expand/collapse', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();

      const parentRow = docsRowheader.closest('[role="row"]');
      expect(parentRow).toHaveAttribute('aria-expanded', 'false');

      await user.keyboard('{Enter}');

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

    it('Space toggles row selection', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          selectable
        />
      );

      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();

      const row = docsRowheader.closest('[role="row"]');
      expect(row).toHaveAttribute('aria-selected', 'false');

      await user.keyboard(' ');

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

      await user.keyboard(' ');

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

    it('Space does not select disabled row', async () => {
      const user = userEvent.setup();
      const columns: TreeGridColumnDef[] = [
        { id: 'name', header: 'Name', isRowHeader: true },
        { id: 'size', header: 'Size' },
      ];

      render(
        <TreeGrid
          columns={columns}
          nodes={createNodesWithDisabled()}
          ariaLabel="Files"
          selectable
        />
      );

      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();

      const row = docsRowheader.closest('[role="row"]');
      expect(row).toHaveAttribute('aria-disabled', 'true');
      expect(row).toHaveAttribute('aria-selected', 'false');

      await user.keyboard(' ');

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

    it('Ctrl+A selects all visible rows (multiselectable)', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          selectable
          multiselectable
        />
      );

      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();

      await user.keyboard('{Control>}a{/Control}');

      // All visible rows should be selected
      const dataRows = screen.getAllByRole('row').filter((r) => r.hasAttribute('aria-level'));
      dataRows.forEach((row) => {
        expect(row).toHaveAttribute('aria-selected', 'true');
      });
    });

    it('single selection clears previous on Space', async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          selectable
        />
      );

      // Select Documents
      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();
      await user.keyboard(' ');

      const docsRow = docsRowheader.closest('[role="row"]');
      expect(docsRow).toHaveAttribute('aria-selected', 'true');

      // Select Images
      await user.keyboard('{ArrowDown}');
      await user.keyboard(' ');

      const imagesRow = screen.getByRole('rowheader', { name: 'Images' }).closest('[role="row"]');
      expect(imagesRow).toHaveAttribute('aria-selected', 'true');

      // Documents should be deselected
      expect(docsRow).toHaveAttribute('aria-selected', 'false');
    });

    it('calls onSelectionChange callback', async () => {
      const user = userEvent.setup();
      const onSelectionChange = vi.fn();
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          selectable
          onSelectionChange={onSelectionChange}
        />
      );

      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();
      await user.keyboard(' ');

      expect(onSelectionChange).toHaveBeenCalledWith(['docs']);
    });
  });

  // ===========================================================================
  // Focus Management
  // ===========================================================================
  describe('Focus Management', () => {
    it('only one cell has tabIndex="0"', () => {
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          defaultExpandedIds={['docs']}
        />
      );

      const allFocusableCells = screen
        .getAllByRole('rowheader')
        .concat(screen.getAllByRole('gridcell'));
      const tabIndex0Cells = allFocusableCells.filter(
        (cell) => cell.getAttribute('tabindex') === '0'
      );

      expect(tabIndex0Cells).toHaveLength(1);
    });

    it('other cells have tabIndex="-1"', () => {
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      const cells = screen.getAllByRole('gridcell');
      const tabIndexMinus1Cells = cells.filter((cell) => cell.getAttribute('tabindex') === '-1');

      // All gridcells should have tabindex="-1" (rowheader has tabindex="0")
      expect(tabIndexMinus1Cells.length).toBe(cells.length);
    });

    it("focus moves to parent when child's parent collapses", async () => {
      const user = userEvent.setup();
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          defaultExpandedIds={['docs']}
        />
      );

      // Focus on child
      const reportRowheader = screen.getByRole('rowheader', { name: 'report.pdf' });
      reportRowheader.focus();

      // Collapse parent
      await user.keyboard('{ArrowLeft}'); // Move to parent
      await user.keyboard('{ArrowLeft}'); // Collapse parent

      // Focus should be on parent
      expect(screen.getByRole('rowheader', { name: 'Documents' })).toHaveFocus();
    });

    it('columnheader cells are not focusable', () => {
      render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );

      const headers = screen.getAllByRole('columnheader');
      headers.forEach((header) => {
        expect(header).not.toHaveAttribute('tabindex');
      });
    });

    it('disabled cells are focusable', () => {
      const columns: TreeGridColumnDef[] = [
        { id: 'name', header: 'Name', isRowHeader: true },
        { id: 'size', header: 'Size' },
      ];

      render(<TreeGrid columns={columns} nodes={createNodesWithDisabled()} ariaLabel="Files" />);

      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      expect(docsRowheader).toHaveAttribute('tabindex');
    });

    it('Tab exits grid', async () => {
      const user = userEvent.setup();
      render(
        <div>
          <button>Before</button>
          <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
          <button>After</button>
        </div>
      );

      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();

      await user.tab();

      expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
    });
  });

  // ===========================================================================
  // Virtualization Support
  // ===========================================================================
  describe('Virtualization Support', () => {
    it('has aria-rowcount when totalRows provided', () => {
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          totalRows={100}
        />
      );

      expect(screen.getByRole('treegrid')).toHaveAttribute('aria-rowcount', '100');
    });

    it('has aria-colcount when totalColumns provided', () => {
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          totalColumns={10}
        />
      );

      expect(screen.getByRole('treegrid')).toHaveAttribute('aria-colcount', '10');
    });

    it('has aria-rowindex on rows when virtualizing', () => {
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          totalRows={100}
          startRowIndex={10}
        />
      );

      const rows = screen.getAllByRole('row').filter((r) => r.hasAttribute('aria-level'));
      expect(rows[0]).toHaveAttribute('aria-rowindex', '10');
      expect(rows[1]).toHaveAttribute('aria-rowindex', '11');
      expect(rows[2]).toHaveAttribute('aria-rowindex', '12');
    });

    it('has aria-colindex on cells when virtualizing', () => {
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          totalColumns={10}
          startColIndex={5}
        />
      );

      const firstRowCells = [
        screen.getAllByRole('rowheader')[0],
        ...screen.getAllByRole('gridcell').slice(0, 2),
      ];
      expect(firstRowCells[0]).toHaveAttribute('aria-colindex', '5');
      expect(firstRowCells[1]).toHaveAttribute('aria-colindex', '6');
      expect(firstRowCells[2]).toHaveAttribute('aria-colindex', '7');
    });
  });

  // ===========================================================================
  // Accessibility
  // ===========================================================================
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const { container } = render(
        <TreeGrid columns={createBasicColumns()} nodes={createBasicNodes()} ariaLabel="Files" />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when expanded', async () => {
      const { container } = render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          defaultExpandedIds={['docs', 'images']}
        />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with selection enabled', async () => {
      const { container } = render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          selectable
          multiselectable
        />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // ===========================================================================
  // Callbacks
  // ===========================================================================
  describe('Callbacks', () => {
    it('calls onExpandedChange when expanding', async () => {
      const user = userEvent.setup();
      const onExpandedChange = vi.fn();
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          onExpandedChange={onExpandedChange}
        />
      );

      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();

      await user.keyboard('{ArrowRight}');

      expect(onExpandedChange).toHaveBeenCalledWith(['docs']);
    });

    it('calls onExpandedChange when collapsing', async () => {
      const user = userEvent.setup();
      const onExpandedChange = vi.fn();
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          defaultExpandedIds={['docs']}
          onExpandedChange={onExpandedChange}
        />
      );

      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();

      await user.keyboard('{ArrowLeft}');

      expect(onExpandedChange).toHaveBeenCalledWith([]);
    });

    it('calls onFocusChange when focus moves', async () => {
      const user = userEvent.setup();
      const onFocusChange = vi.fn();
      render(
        <TreeGrid
          columns={createBasicColumns()}
          nodes={createBasicNodes()}
          ariaLabel="Files"
          onFocusChange={onFocusChange}
        />
      );

      const docsRowheader = screen.getByRole('rowheader', { name: 'Documents' });
      docsRowheader.focus();

      await user.keyboard('{ArrowRight}');

      expect(onFocusChange).toHaveBeenCalled();
    });
  });
});

Resources