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.
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-selectedon row, not gridcell) - Tree operations (expand/collapse) only work at the rowheader column
- Rows have
aria-levelto 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
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
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
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.
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
- WAI-ARIA APG: TreeGrid Pattern (opens in new tab)
- WAI-ARIA APG: TreeGrid Example (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist