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

TreeGrid vs Grid

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

Feature TreeGrid Grid
Hierarchy Expandable/collapsible rows Flat structure
Selection Row selection (aria-selected on row) Cell selection (aria-selected on cell)
Arrow at rowheader Expand/collapse tree Move focus
Required ARIA aria-level, aria-expanded None (hierarchy-specific)
Use Case File browser, org chart, nested data Spreadsheet, flat data tables

Accessibility Features

TreeGrid vs Grid

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

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

WAI-ARIA Roles

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

W3C ARIA: treegrid role (opens in new tab)

WAI-ARIA Properties (TreeGrid Container)

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

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

WAI-ARIA States (Rows)

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

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

Keyboard Support

2D Navigation

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

Tree Operations (at rowheader only)

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

Row Selection & Cell Activation

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

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

Focus Management

This component uses roving tabindex for focus management:

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

Key Differences from Grid

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

Source Code

TreeGrid.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

TreeGrid Props

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

Type Definitions

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

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

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

Testing

TreeGrid tests focus on hierarchy navigation, expand/collapse, row selection, and ARIA attributes.

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