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.
Accessibility Features
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) |
WAI-ARIA Properties
role="treegrid"
Identifies the container as a treegrid
- Values
- -
- Required
- Yes
aria-label
Accessible name for the treegrid
- Values
- String
- Required
- Yes (either aria-label or aria-labelledby)
aria-labelledby
Alternative to aria-label
- Values
- ID reference
- Required
- Yes (either aria-label or aria-labelledby)
aria-multiselectable
Only present for multi-select mode
- Values
- true
- Required
- No
aria-rowcount
Total rows (for virtualization)
- Values
- Number
- Required
- No
aria-colcount
Total columns (for virtualization)
- Values
- Number
- Required
- No
WAI-ARIA States
aria-level
- Target Element
- row
- Values
- Number (1-based)
- Required
- Yes
- Change Trigger
Static per row (determined by hierarchy)
aria-expanded
- Target Element
- row (parent only)
- Values
- true | false
- Required
- Yes
- Change Trigger
ArrowRight/Left at rowheader, click on expand icon
aria-selected
- Target Element
- row
- Values
- true | false
- Required
- No
- Change Trigger
- Space key, click (NOT on gridcell)
aria-disabled
- Target Element
- row
- Values
- true
- Required
- No
- Change Trigger
- Only when row is disabled
aria-rowindex
- Target Element
- row
- Values
- Number
- Required
- No
- Change Trigger
- Static (for virtualization)
Keyboard Support
2D Navigation
| 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) |
- Rows can be expanded/collapsed to show/hide child rows
- Row selection instead of cell selection (aria-selected on row, not gridcell)
- Tree operations (expand/collapse) only work at the rowheader column
- Rows have aria-level to indicate hierarchy depth
Focus Management
| Event | Behavior |
|---|---|
| Focus model | Roving tabindex - only one cell has tabindex="0" |
| Other cells | tabindex="-1" |
| TreeGrid | Single Tab stop (Tab enters/exits the grid) |
| Column headers | NOT focusable (no tabindex) |
| Collapsed children | NOT in keyboard navigation |
| Parent collapses | If focus was on child, move focus to parent |
References
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
| Prop | Type | Default | Description |
|---|---|---|---|
columns | TreeGridColumnDef[] | required | Column definitions |
nodes | TreeGridNodeData[] | required | Hierarchical node data |
ariaLabel | string | - | Accessible name |
expandedIds | string[] | [] | Expanded row IDs |
onExpandedChange | (ids: string[]) => void | - | Expand state change callback |
selectable | boolean | false | Enable row selection |
multiselectable | boolean | false | Enable multi-row selection |
selectedRowIds | string[] | [] | Selected row IDs |
onSelectionChange | (ids: string[]) => void | - | Selection change callback |
onCellActivate | (cellId, rowId, colId) => void | - | Cell activation callback |
TreeGridColumnDef Props
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique column identifier |
header | string | required | Column header text |
isRowHeader | boolean | false | Whether this column is a row header |
TreeGridCellData Props
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique cell identifier |
value | string | number | required | Cell value |
disabled | boolean | false | Whether the cell is disabled |
TreeGridNodeData Props
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique node identifier |
cells | TreeGridCellData[] | required | Array of cell data for this row |
children | TreeGridNodeData[] | - | Child nodes (makes this a parent row) |
disabled | boolean | false | Whether the node is disabled |
Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The TreeGrid component combines Grid and TreeView testing strategies.
Testing Strategy
Unit Tests (Testing Library)
Verify component rendering and interactions using framework-specific Testing Library utilities. These tests ensure correct component behavior in isolation.
- HTML structure and element hierarchy (treegrid, row, rowheader, gridcell)
- Initial attribute values (role, aria-label, aria-level, aria-expanded)
- Selection state changes (aria-selected on rows)
- Hierarchy depth indicators (aria-level)
- CSS class application
E2E Tests (Playwright)
Verify component behavior in a real browser environment across all four frameworks. These tests cover interactions that require full browser context.
- 2D keyboard navigation (Arrow keys)
- Tree operations at rowheader (ArrowRight/Left for expand/collapse)
- Extended navigation (Home, End, Ctrl+Home, Ctrl+End)
- Row selection with Space
- Focus management and roving tabindex
- Hidden row handling (collapsed children)
- Cross-framework consistency
Test Categories
High Priority: APG ARIA Attributes ( Unit + E2E )
| Test | Description |
|---|---|
role="treegrid" | Container has treegrid role |
role="row" | All rows have row role |
role="rowheader" | First cell in row has rowheader role |
role="gridcell" | Other cells have gridcell role |
role="columnheader" | Header cells have columnheader role |
aria-label | TreeGrid has accessible name via aria-label |
aria-level | All rows have aria-level (1-based depth) |
aria-expanded | Parent rows have aria-expanded (true/false) |
aria-selected on row | Selection on row element (not cell) |
aria-multiselectable | Present when multi-selection is enabled |
High Priority: Tree Operations (at Rowheader) ( E2E )
| Test | Description |
|---|---|
ArrowRight expands | Expands collapsed parent row |
ArrowRight to child | Moves to first child when already expanded |
ArrowLeft collapses | Collapses expanded parent row |
ArrowLeft to parent | Moves to parent row when collapsed or leaf |
Enter activates only | Enter does NOT expand/collapse (unlike Tree) |
Children hidden | Child rows hidden when parent collapsed |
High Priority: 2D Keyboard Navigation ( E2E )
| Test | Description |
|---|---|
ArrowRight (non-rowheader) | Moves focus one cell right |
ArrowLeft (non-rowheader) | Moves focus one cell left |
ArrowDown | Moves focus to next visible row |
ArrowUp | Moves focus to previous visible row |
Skip hidden rows | ArrowDown/Up skips collapsed children |
High Priority: Extended Navigation ( E2E )
| Test | Description |
|---|---|
Home | Moves focus to first cell in row |
End | Moves focus to last cell in row |
Ctrl+Home | Moves focus to first cell in first visible row |
Ctrl+End | Moves focus to last cell in last visible row |
High Priority: Focus Management (Roving Tabindex) ( Unit + E2E )
| Test | Description |
|---|---|
tabindex="0" | First focusable cell has tabindex="0" |
tabindex="-1" | Other cells have tabindex="-1" |
Headers not focusable | columnheader cells have no tabindex |
Tab exits treegrid | Tab moves focus out of treegrid |
Focus update | Focused cell updates tabindex on navigation |
High Priority: Row Selection ( E2E )
| Test | Description |
|---|---|
Space toggles row | Space toggles row selection (not cell) |
Single select | Single selection clears previous on Space |
Multi select | Multi-selection allows multiple rows |
Enter activates cell | Enter triggers cell activation |
Medium Priority: Accessibility ( E2E )
| Test | Description |
|---|---|
axe-core | No accessibility violations |
Example Test Code
The following is the actual E2E test file (e2e/treegrid.spec.ts).
import { expect, test, type Locator, type Page } from '@playwright/test';
/**
* E2E Tests for TreeGrid Pattern
*
* Tests verify the TreeGrid component behavior in a real browser,
* including 2D keyboard navigation, tree operations (expand/collapse),
* row selection, and focus management.
*
* Test coverage:
* - ARIA structure and attributes (treegrid, aria-level, aria-expanded)
* - 2D keyboard navigation (Arrow keys, Home, End, Ctrl+Home, Ctrl+End)
* - Tree operations at rowheader (ArrowRight/Left for expand/collapse)
* - Row selection (Space toggles row selection, not cell)
* - Focus management (roving tabindex)
*
* Key differences from Grid:
* - Tree operations only at rowheader cells
* - Row selection (aria-selected on row, not cell)
* - aria-level and aria-expanded on rows
*/
/**
* Helper to check if a cell or a focusable element within it is focused.
*/
async function expectCellOrChildFocused(_page: Page, cell: Locator): Promise<void> {
const cellIsFocused = await cell.evaluate((el) => document.activeElement === el);
if (cellIsFocused) {
await expect(cell).toBeFocused();
return;
}
const focusedChild = cell.locator(
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
const childCount = await focusedChild.count();
if (childCount > 0) {
for (let i = 0; i < childCount; i++) {
const child = focusedChild.nth(i);
const childIsFocused = await child.evaluate((el) => document.activeElement === el);
if (childIsFocused) {
await expect(child).toBeFocused();
return;
}
}
}
await expect(cell).toBeFocused();
}
/**
* Helper to focus a cell, handling cells that contain links/buttons.
* Returns the focused element (either the cell or a focusable child).
*/
async function focusCell(_page: Page, cell: Locator): Promise<Locator> {
await cell.click({ position: { x: 5, y: 5 } });
// Check if focus is on the cell or a child element
const cellIsFocused = await cell.evaluate((el) => document.activeElement === el);
if (cellIsFocused) {
return cell;
}
// Find and return the focused child
const focusedChild = cell.locator(
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
const childCount = await focusedChild.count();
for (let i = 0; i < childCount; i++) {
const child = focusedChild.nth(i);
const childIsFocused = await child.evaluate((el) => document.activeElement === el);
if (childIsFocused) {
return child;
}
}
return cell;
}
/**
* Helper to get the row containing a cell.
*/
async function getRowForCell(cell: Locator): Promise<Locator> {
return cell.locator('xpath=ancestor::*[@role="row"]').first();
}
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;
for (const framework of frameworks) {
test.describe(`TreeGrid (${framework})`, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`patterns/treegrid/${framework}/demo/`);
await page.waitForLoadState('networkidle');
await page.waitForSelector('[role="treegrid"]');
});
// 🔴 High Priority: ARIA Attributes
test.describe('ARIA Attributes', () => {
test('has role="treegrid" on container', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
await expect(treegrid).toBeVisible();
});
test('has role="row" on rows', async ({ page }) => {
const rows = page.getByRole('row');
await expect(rows.first()).toBeVisible();
expect(await rows.count()).toBeGreaterThan(1);
});
test('has role="gridcell" on data cells', async ({ page }) => {
const cells = page.getByRole('gridcell');
await expect(cells.first()).toBeVisible();
});
test('has role="columnheader" on header cells', async ({ page }) => {
const headers = page.getByRole('columnheader');
await expect(headers.first()).toBeVisible();
});
test('has role="rowheader" on row header cells', async ({ page }) => {
const rowheaders = page.getByRole('rowheader');
await expect(rowheaders.first()).toBeVisible();
});
test('has accessible name', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const label = await treegrid.getAttribute('aria-label');
const labelledby = await treegrid.getAttribute('aria-labelledby');
expect(label || labelledby).toBeTruthy();
});
test('parent rows have aria-expanded', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rows = treegrid.getByRole('row');
const rowCount = await rows.count();
let foundParentRow = false;
for (let i = 0; i < rowCount; i++) {
const row = rows.nth(i);
const ariaExpanded = await row.getAttribute('aria-expanded');
if (ariaExpanded !== null) {
foundParentRow = true;
expect(['true', 'false']).toContain(ariaExpanded);
}
}
expect(foundParentRow).toBe(true);
});
test('all data rows have aria-level', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rows = treegrid.getByRole('row');
const rowCount = await rows.count();
for (let i = 0; i < rowCount; i++) {
const row = rows.nth(i);
const ariaLevel = await row.getAttribute('aria-level');
// Skip header row (no aria-level)
if (ariaLevel !== null) {
const level = parseInt(ariaLevel, 10);
expect(level).toBeGreaterThanOrEqual(1);
}
}
});
test('aria-level is 1-based and increments with depth', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rows = treegrid.getByRole('row');
const rowCount = await rows.count();
const levels: number[] = [];
for (let i = 0; i < rowCount; i++) {
const row = rows.nth(i);
const ariaLevel = await row.getAttribute('aria-level');
if (ariaLevel !== null) {
levels.push(parseInt(ariaLevel, 10));
}
}
// Check that level 1 exists (root level)
expect(levels).toContain(1);
});
test('has aria-selected on row (not gridcell) when selectable', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rows = treegrid.getByRole('row');
const rowCount = await rows.count();
let hasSelectableRow = false;
for (let i = 0; i < rowCount; i++) {
const row = rows.nth(i);
const ariaSelected = await row.getAttribute('aria-selected');
if (ariaSelected !== null) {
hasSelectableRow = true;
expect(['true', 'false']).toContain(ariaSelected);
}
}
if (hasSelectableRow) {
// Verify gridcells don't have aria-selected
const cells = treegrid.getByRole('gridcell');
const cellCount = await cells.count();
for (let i = 0; i < cellCount; i++) {
const cell = cells.nth(i);
const ariaSelected = await cell.getAttribute('aria-selected');
expect(ariaSelected).toBeNull();
}
}
});
});
// 🔴 High Priority: Keyboard - Row Navigation
test.describe('Keyboard - Row Navigation', () => {
test('ArrowDown moves to same column in next visible row', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rowheaders = treegrid.getByRole('rowheader');
const firstRowheader = rowheaders.first();
const focusedElement = await focusCell(page, firstRowheader);
await expect(focusedElement).toBeFocused();
await focusedElement.press('ArrowDown');
const secondRowheader = rowheaders.nth(1);
await expectCellOrChildFocused(page, secondRowheader);
});
test('ArrowUp moves to same column in previous visible row', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rowheaders = treegrid.getByRole('rowheader');
const secondRowheader = rowheaders.nth(1);
const focusedElement = await focusCell(page, secondRowheader);
await expect(focusedElement).toBeFocused();
await focusedElement.press('ArrowUp');
const firstRowheader = rowheaders.first();
await expectCellOrChildFocused(page, firstRowheader);
});
test('ArrowUp stops at first visible row', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rowheaders = treegrid.getByRole('rowheader');
const firstRowheader = rowheaders.first();
const focusedElement = await focusCell(page, firstRowheader);
await expect(focusedElement).toBeFocused();
await focusedElement.press('ArrowUp');
// Should stay on first row
await expectCellOrChildFocused(page, firstRowheader);
});
});
// 🔴 High Priority: Keyboard - Cell Navigation
test.describe('Keyboard - Cell Navigation', () => {
test('ArrowRight at non-rowheader moves right', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const cells = treegrid.getByRole('gridcell');
const firstCell = cells.first();
const focusedElement = await focusCell(page, firstCell);
await expect(focusedElement).toBeFocused();
await focusedElement.press('ArrowRight');
const secondCell = cells.nth(1);
await expectCellOrChildFocused(page, secondCell);
});
test('ArrowLeft at non-rowheader moves left', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const cells = treegrid.getByRole('gridcell');
const secondCell = cells.nth(1);
const focusedElement = await focusCell(page, secondCell);
await expect(focusedElement).toBeFocused();
await focusedElement.press('ArrowLeft');
const firstCell = cells.first();
await expectCellOrChildFocused(page, firstCell);
});
test('Home moves to first cell in row', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const cells = treegrid.getByRole('gridcell');
const rowheaders = treegrid.getByRole('rowheader');
const secondCell = cells.nth(1);
const focusedElement = await focusCell(page, secondCell);
await expect(focusedElement).toBeFocused();
await focusedElement.press('Home');
// Should move to rowheader (first cell in row)
const firstRowheader = rowheaders.first();
await expectCellOrChildFocused(page, firstRowheader);
});
test('End moves to last cell in row', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rowheaders = treegrid.getByRole('rowheader');
const firstRowheader = rowheaders.first();
const focusedElement = await focusCell(page, firstRowheader);
await expect(focusedElement).toBeFocused();
await focusedElement.press('End');
// Should move to last cell in first data row
// Get cells in the same row
const row = await getRowForCell(firstRowheader);
const cellsInRow = row.getByRole('gridcell');
const lastCell = cellsInRow.last();
await expectCellOrChildFocused(page, lastCell);
});
test('Ctrl+Home moves to first cell in grid', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const cells = treegrid.getByRole('gridcell');
const rowheaders = treegrid.getByRole('rowheader');
const lastCell = cells.last();
const focusedElement = await focusCell(page, lastCell);
await expect(focusedElement).toBeFocused();
await focusedElement.press('Control+Home');
// Should move to first rowheader
const firstRowheader = rowheaders.first();
await expectCellOrChildFocused(page, firstRowheader);
});
test('Ctrl+End moves to last cell in grid', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rowheaders = treegrid.getByRole('rowheader');
const cells = treegrid.getByRole('gridcell');
const firstRowheader = rowheaders.first();
const focusedElement = await focusCell(page, firstRowheader);
await expect(focusedElement).toBeFocused();
await focusedElement.press('Control+End');
// Should move to last cell
const lastCell = cells.last();
await expectCellOrChildFocused(page, lastCell);
});
});
// 🔴 High Priority: Keyboard - Tree Operations
test.describe('Keyboard - Tree Operations', () => {
test('ArrowRight at collapsed rowheader expands row', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rows = treegrid.getByRole('row');
const rowCount = await rows.count();
// Find a collapsed parent row
let collapsedRowIndex = -1;
for (let i = 0; i < rowCount; i++) {
const row = rows.nth(i);
const ariaExpanded = await row.getAttribute('aria-expanded');
if (ariaExpanded === 'false') {
collapsedRowIndex = i;
break;
}
}
if (collapsedRowIndex === -1) {
test.skip();
return;
}
const row = rows.nth(collapsedRowIndex);
const rowheader = row.getByRole('rowheader');
const focusedElement = await focusCell(page, rowheader);
await expect(focusedElement).toBeFocused();
await focusedElement.press('ArrowRight');
await expect(row).toHaveAttribute('aria-expanded', 'true');
});
test('expanding a row makes child rows visible', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rows = treegrid.getByRole('row');
const rowCount = await rows.count();
// Find a collapsed parent row
let collapsedRowIndex = -1;
for (let i = 0; i < rowCount; i++) {
const row = rows.nth(i);
const ariaExpanded = await row.getAttribute('aria-expanded');
if (ariaExpanded === 'false') {
collapsedRowIndex = i;
break;
}
}
if (collapsedRowIndex === -1) {
test.skip();
return;
}
const row = rows.nth(collapsedRowIndex);
const rowheader = row.getByRole('rowheader');
// Get initial visible rowheader count
const visibleRowheadersBefore = await treegrid.getByRole('rowheader').count();
const focusedElement = await focusCell(page, rowheader);
await expect(focusedElement).toBeFocused();
await focusedElement.press('ArrowRight');
await expect(row).toHaveAttribute('aria-expanded', 'true');
// After expansion, there should be more visible rowheaders (child rows appeared)
const visibleRowheadersAfter = await treegrid.getByRole('rowheader').count();
expect(visibleRowheadersAfter).toBeGreaterThan(visibleRowheadersBefore);
});
test('ArrowLeft at expanded rowheader collapses row', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rows = treegrid.getByRole('row');
const rowCount = await rows.count();
// Find an expanded parent row
let expandedRowIndex = -1;
for (let i = 0; i < rowCount; i++) {
const row = rows.nth(i);
const ariaExpanded = await row.getAttribute('aria-expanded');
if (ariaExpanded === 'true') {
expandedRowIndex = i;
break;
}
}
if (expandedRowIndex === -1) {
// Try to expand first, then collapse
for (let i = 0; i < rowCount; i++) {
const row = rows.nth(i);
const ariaExpanded = await row.getAttribute('aria-expanded');
if (ariaExpanded === 'false') {
const rowheader = row.getByRole('rowheader');
const focused = await focusCell(page, rowheader);
await expect(focused).toBeFocused();
await focused.press('ArrowRight');
await expect(row).toHaveAttribute('aria-expanded', 'true');
expandedRowIndex = i;
break;
}
}
}
if (expandedRowIndex === -1) {
test.skip();
return;
}
const row = rows.nth(expandedRowIndex);
const rowheader = row.getByRole('rowheader');
const focusedElement = await focusCell(page, rowheader);
await expect(focusedElement).toBeFocused();
await focusedElement.press('ArrowLeft');
await expect(row).toHaveAttribute('aria-expanded', 'false');
});
test('ArrowRight at expanded rowheader moves right to next cell', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rows = treegrid.getByRole('row');
const rowCount = await rows.count();
// Find an expanded parent row
let expandedRowIndex = -1;
for (let i = 0; i < rowCount; i++) {
const row = rows.nth(i);
const ariaExpanded = await row.getAttribute('aria-expanded');
if (ariaExpanded === 'true') {
expandedRowIndex = i;
break;
}
}
if (expandedRowIndex === -1) {
// Try to expand a collapsed row first
for (let i = 0; i < rowCount; i++) {
const row = rows.nth(i);
const ariaExpanded = await row.getAttribute('aria-expanded');
if (ariaExpanded === 'false') {
const rowheader = row.getByRole('rowheader');
const focused = await focusCell(page, rowheader);
await expect(focused).toBeFocused();
await focused.press('ArrowRight');
await expect(row).toHaveAttribute('aria-expanded', 'true');
expandedRowIndex = i;
break;
}
}
}
if (expandedRowIndex === -1) {
test.skip();
return;
}
const row = rows.nth(expandedRowIndex);
const rowheader = row.getByRole('rowheader');
const cells = row.getByRole('gridcell');
const firstCell = cells.first();
const focusedElement = await focusCell(page, rowheader);
// ArrowRight at expanded rowheader should move to the next cell (not expand again)
await expect(focusedElement).toBeFocused();
await focusedElement.press('ArrowRight');
// Row should still be expanded
await expect(row).toHaveAttribute('aria-expanded', 'true');
// Focus should move to first gridcell in same row
await expectCellOrChildFocused(page, firstCell);
});
test('ArrowRight at non-rowheader does NOT expand', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rows = treegrid.getByRole('row');
const rowCount = await rows.count();
// Find a collapsed parent row
let collapsedRowIndex = -1;
for (let i = 0; i < rowCount; i++) {
const row = rows.nth(i);
const ariaExpanded = await row.getAttribute('aria-expanded');
if (ariaExpanded === 'false') {
collapsedRowIndex = i;
break;
}
}
if (collapsedRowIndex === -1) {
test.skip();
return;
}
const row = rows.nth(collapsedRowIndex);
const cells = row.getByRole('gridcell');
const firstCell = cells.first();
const focusedElement = await focusCell(page, firstCell);
await expect(focusedElement).toBeFocused();
await focusedElement.press('ArrowRight');
// Should NOT expand - still collapsed
await expect(row).toHaveAttribute('aria-expanded', 'false');
});
test('Enter does NOT expand/collapse', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rows = treegrid.getByRole('row');
const rowCount = await rows.count();
// Find a collapsed parent row
let collapsedRowIndex = -1;
for (let i = 0; i < rowCount; i++) {
const row = rows.nth(i);
const ariaExpanded = await row.getAttribute('aria-expanded');
if (ariaExpanded === 'false') {
collapsedRowIndex = i;
break;
}
}
if (collapsedRowIndex === -1) {
test.skip();
return;
}
const row = rows.nth(collapsedRowIndex);
const rowheader = row.getByRole('rowheader');
const focusedElement = await focusCell(page, rowheader);
await expect(focusedElement).toBeFocused();
await focusedElement.press('Enter');
// Should still be collapsed
await expect(row).toHaveAttribute('aria-expanded', 'false');
});
});
// 🔴 High Priority: Row Selection
test.describe('Row Selection', () => {
test('Space toggles row selection', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rows = treegrid.getByRole('row');
const rowCount = await rows.count();
// Find a selectable row
let selectableRowIndex = -1;
for (let i = 0; i < rowCount; i++) {
const row = rows.nth(i);
const ariaSelected = await row.getAttribute('aria-selected');
if (ariaSelected !== null) {
selectableRowIndex = i;
break;
}
}
if (selectableRowIndex === -1) {
test.skip();
return;
}
const row = rows.nth(selectableRowIndex);
const rowheader = row.getByRole('rowheader');
const focusedElement = await focusCell(page, rowheader);
await expect(focusedElement).toBeFocused();
await focusedElement.press('Space');
await expect(row).toHaveAttribute('aria-selected', 'true');
});
test('Space toggles row selection off', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rows = treegrid.getByRole('row');
const rowCount = await rows.count();
// Find a selectable row
let selectableRowIndex = -1;
for (let i = 0; i < rowCount; i++) {
const row = rows.nth(i);
const ariaSelected = await row.getAttribute('aria-selected');
if (ariaSelected !== null) {
selectableRowIndex = i;
break;
}
}
if (selectableRowIndex === -1) {
test.skip();
return;
}
const row = rows.nth(selectableRowIndex);
const rowheader = row.getByRole('rowheader');
const focusedElement = await focusCell(page, rowheader);
// Select
await expect(focusedElement).toBeFocused();
await focusedElement.press('Space');
await expect(row).toHaveAttribute('aria-selected', 'true');
// Deselect (focus should still be on the same element after Space)
await focusedElement.press('Space');
await expect(row).toHaveAttribute('aria-selected', 'false');
});
});
// 🔴 High Priority: Focus Management
test.describe('Focus Management', () => {
test('first focusable cell has tabIndex="0"', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rowheaders = treegrid.getByRole('rowheader');
const firstRowheader = rowheaders.first();
await expect(firstRowheader).toHaveAttribute('tabindex', '0');
});
test('other cells have tabIndex="-1"', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const cells = treegrid.getByRole('gridcell');
const firstCell = cells.first();
await expect(firstCell).toHaveAttribute('tabindex', '-1');
});
test('columnheader cells are not focusable', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const headers = treegrid.getByRole('columnheader');
const firstHeader = headers.first();
const tabindex = await firstHeader.getAttribute('tabindex');
expect(tabindex).toBeNull();
});
test('Tab exits treegrid', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const rowheaders = treegrid.getByRole('rowheader');
const firstRowheader = rowheaders.first();
await focusCell(page, firstRowheader);
await page.keyboard.press('Tab');
const treegridContainsFocus = await treegrid.evaluate((el) =>
el.contains(document.activeElement)
);
expect(treegridContainsFocus).toBe(false);
});
test('roving tabindex updates on arrow navigation', async ({ page }) => {
const treegrid = page.getByRole('treegrid');
const cells = treegrid.getByRole('gridcell');
const firstCell = cells.first(); // First gridcell (not rowheader) = Size column of first row
const secondCell = cells.nth(1); // Date column of first row
// Initially first rowheader has tabindex="0", gridcells have "-1"
await expect(firstCell).toHaveAttribute('tabindex', '-1');
await expect(secondCell).toHaveAttribute('tabindex', '-1');
// Focus first gridcell (Size column) and navigate right to Date column
const focusedElement = await focusCell(page, firstCell);
await expect(firstCell).toHaveAttribute('tabindex', '0');
await expect(focusedElement).toBeFocused();
await focusedElement.press('ArrowRight');
// After navigation, tabindex should update
await expect(firstCell).toHaveAttribute('tabindex', '-1');
await expect(secondCell).toHaveAttribute('tabindex', '0');
});
});
});
} Running Tests
# Run unit tests for TreeGrid
npm run test -- treegrid
# Run E2E tests for TreeGrid (all frameworks)
npm run test:e2e:pattern --pattern=treegrid
# Run E2E tests for specific framework
npm run test:e2e:react:pattern --pattern=treegrid
npm run test:e2e:vue:pattern --pattern=treegrid
npm run test:e2e:svelte:pattern --pattern=treegrid
npm run test:e2e:astro:pattern --pattern=treegrid
Key Difference from Grid
- Selection target: TreeGrid selects rows (aria-selected on row), Grid selects cells
- Arrow behavior at rowheader: ArrowRight/Left do tree operations, not cell navigation
- Hierarchy: Must test aria-level values and parent/child relationships
- Hidden rows: Must skip collapsed children during navigation
Testing Tools
- Vitest (opens in new tab) - Test runner for unit tests
- Testing Library (opens in new tab) - Framework-specific testing utilities (React, Vue, Svelte)
- Playwright (opens in new tab) - Browser automation for E2E tests
- axe-core/playwright (opens in new tab) - Automated accessibility testing in E2E
See testing-strategy.md (opens in new tab) for full documentation.
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