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
<script setup lang="ts">
import { computed, ref, onMounted, nextTick } from 'vue';
// =============================================================================
// Types
// =============================================================================
export interface TreeGridCellData {
id: string;
value: string | number;
disabled?: boolean;
colspan?: number;
}
export interface TreeGridNodeData {
id: string;
cells: TreeGridCellData[];
children?: TreeGridNodeData[];
disabled?: boolean;
}
export interface TreeGridColumnDef {
id: string;
header: string;
isRowHeader?: boolean;
}
interface FlatRow {
node: TreeGridNodeData;
level: number;
parentId: string | null;
hasChildren: boolean;
}
interface Props {
columns: TreeGridColumnDef[];
nodes: TreeGridNodeData[];
ariaLabel?: string;
ariaLabelledby?: string;
expandedIds?: string[];
defaultExpandedIds?: string[];
selectable?: boolean;
multiselectable?: boolean;
selectedRowIds?: string[];
defaultSelectedRowIds?: string[];
defaultFocusedCellId?: string;
totalRows?: number;
totalColumns?: number;
startRowIndex?: number;
startColIndex?: number;
enablePageNavigation?: boolean;
pageSize?: number;
}
// =============================================================================
// Props & Emits
// =============================================================================
const props = withDefaults(defineProps<Props>(), {
selectable: false,
multiselectable: false,
defaultSelectedRowIds: () => [],
defaultExpandedIds: () => [],
startRowIndex: 2,
startColIndex: 1,
enablePageNavigation: false,
pageSize: 5,
});
const emit = defineEmits<{
expandedChange: [expandedIds: string[]];
selectionChange: [selectedRowIds: string[]];
focusChange: [cellId: string | null];
cellActivate: [cellId: string, rowId: string, colId: string];
}>();
// =============================================================================
// State
// =============================================================================
const internalExpandedIds = ref<Set<string>>(new Set(props.defaultExpandedIds));
const expandedIds = computed(() => {
if (props.expandedIds) {
return new Set(props.expandedIds);
}
return internalExpandedIds.value;
});
const internalSelectedRowIds = ref<Set<string>>(new Set(props.defaultSelectedRowIds));
const selectedRowIds = computed(() => {
if (props.selectedRowIds) {
return new Set(props.selectedRowIds);
}
return internalSelectedRowIds.value;
});
const focusedCellId = ref<string | null>(null);
const treegridRef = ref<HTMLDivElement | null>(null);
const cellRefs = ref<Map<string, HTMLDivElement>>(new Map());
// =============================================================================
// Computed - Flatten Tree
// =============================================================================
const flattenTree = (
treeNodes: TreeGridNodeData[],
level: number = 1,
parentId: string | null = null
): FlatRow[] => {
const result: FlatRow[] = [];
for (const node of treeNodes) {
const hasChildren = Boolean(node.children && node.children.length > 0);
result.push({ node, level, parentId, hasChildren });
if (node.children) {
result.push(...flattenTree(node.children, level + 1, node.id));
}
}
return result;
};
const allRows = computed(() => flattenTree(props.nodes));
const rowMap = computed(() => {
const map = new Map<string, FlatRow>();
for (const flatRow of allRows.value) {
map.set(flatRow.node.id, flatRow);
}
return map;
});
const visibleRows = computed(() => {
const result: FlatRow[] = [];
const collapsedParents = new Set<string>();
for (const flatRow of allRows.value) {
let isHidden = false;
let currentParentId = flatRow.parentId;
while (currentParentId) {
if (collapsedParents.has(currentParentId) || !expandedIds.value.has(currentParentId)) {
isHidden = true;
break;
}
const parent = rowMap.value.get(currentParentId);
currentParentId = parent?.parentId ?? null;
}
if (!isHidden) {
result.push(flatRow);
if (flatRow.hasChildren && !expandedIds.value.has(flatRow.node.id)) {
collapsedParents.add(flatRow.node.id);
}
}
}
return result;
});
// =============================================================================
// Cell Position Tracking
// =============================================================================
interface CellPosition {
rowIndex: number;
colIndex: number;
cell: TreeGridCellData;
rowId: string;
isRowHeader: boolean;
}
const cellPositionMap = computed(() => {
const map = new Map<string, CellPosition>();
visibleRows.value.forEach((flatRow, rowIndex) => {
flatRow.node.cells.forEach((cell, colIndex) => {
map.set(cell.id, {
rowIndex,
colIndex,
cell,
rowId: flatRow.node.id,
isRowHeader: props.columns[colIndex]?.isRowHeader ?? false,
});
});
});
return map;
});
// =============================================================================
// Initialize Focus
// =============================================================================
onMounted(() => {
const initialFocusId =
props.defaultFocusedCellId ?? visibleRows.value[0]?.node.cells[0]?.id ?? null;
focusedCellId.value = initialFocusId;
nextTick(() => {
if (treegridRef.value) {
const focusableElements = treegridRef.value.querySelectorAll<HTMLElement>(
'[role="gridcell"] a[href], [role="gridcell"] button, [role="rowheader"] a[href], [role="rowheader"] button'
);
focusableElements.forEach((el) => {
el.setAttribute('tabindex', '-1');
});
}
});
});
// =============================================================================
// Methods
// =============================================================================
function setFocusedCellId(id: string | null) {
focusedCellId.value = id;
emit('focusChange', id);
}
function focusCell(cellId: string) {
const cellEl = cellRefs.value.get(cellId);
if (cellEl) {
const focusableChild = cellEl.querySelector<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusableChild) {
focusableChild.setAttribute('tabindex', '-1');
focusableChild.focus();
} else {
cellEl.focus();
}
setFocusedCellId(cellId);
}
}
function updateExpandedIds(newExpandedIds: Set<string>) {
if (!props.expandedIds) {
internalExpandedIds.value = newExpandedIds;
}
emit('expandedChange', [...newExpandedIds]);
}
function expandRow(rowId: string) {
const flatRow = rowMap.value.get(rowId);
if (!flatRow?.hasChildren || flatRow.node.disabled) return;
if (expandedIds.value.has(rowId)) return;
const newExpanded = new Set(expandedIds.value);
newExpanded.add(rowId);
updateExpandedIds(newExpanded);
}
function collapseRow(rowId: string, currentFocusCellId?: string) {
const flatRow = rowMap.value.get(rowId);
if (!flatRow?.hasChildren || flatRow.node.disabled) return;
if (!expandedIds.value.has(rowId)) return;
const newExpanded = new Set(expandedIds.value);
newExpanded.delete(rowId);
updateExpandedIds(newExpanded);
// If focus was on a child, move focus to the collapsed parent's first cell
if (currentFocusCellId) {
const focusPos = cellPositionMap.value.get(currentFocusCellId);
if (focusPos) {
const focusRowId = focusPos.rowId;
let currentRow = rowMap.value.get(focusRowId);
while (currentRow) {
if (currentRow.parentId === rowId) {
// Focus is on a descendant - move to parent's first cell
const parentRow = rowMap.value.get(rowId);
if (parentRow) {
const parentFirstCell = parentRow.node.cells[0];
if (parentFirstCell) {
nextTick(() => focusCell(parentFirstCell.id));
}
}
break;
}
currentRow = currentRow.parentId ? rowMap.value.get(currentRow.parentId) : undefined;
}
}
}
}
function updateSelectedRowIds(newSelectedIds: Set<string>) {
if (!props.selectedRowIds) {
internalSelectedRowIds.value = newSelectedIds;
}
emit('selectionChange', [...newSelectedIds]);
}
function toggleRowSelection(rowId: string, rowDisabled?: boolean) {
if (!props.selectable || rowDisabled) return;
if (props.multiselectable) {
const newIds = new Set(selectedRowIds.value);
if (newIds.has(rowId)) {
newIds.delete(rowId);
} else {
newIds.add(rowId);
}
updateSelectedRowIds(newIds);
} else {
const newIds = selectedRowIds.value.has(rowId) ? new Set<string>() : new Set([rowId]);
updateSelectedRowIds(newIds);
}
}
function selectAllVisibleRows() {
if (!props.selectable || !props.multiselectable) return;
const allIds = new Set<string>();
for (const flatRow of visibleRows.value) {
if (!flatRow.node.disabled) {
allIds.add(flatRow.node.id);
}
}
updateSelectedRowIds(allIds);
}
function findNextVisibleRow(
startRowIndex: number,
direction: 'up' | 'down',
pageSize = 1
): number | null {
if (direction === 'down') {
const targetIndex = Math.min(startRowIndex + pageSize, visibleRows.value.length - 1);
return targetIndex > startRowIndex ? targetIndex : null;
} else {
const targetIndex = Math.max(startRowIndex - pageSize, 0);
return targetIndex < startRowIndex ? targetIndex : null;
}
}
function handleKeyDown(event: KeyboardEvent, cell: TreeGridCellData, rowId: string) {
const pos = cellPositionMap.value.get(cell.id);
if (!pos) return;
const { rowIndex, colIndex, isRowHeader } = pos;
const flatRow = visibleRows.value[rowIndex];
const colCount = props.columns.length;
let handled = true;
switch (event.key) {
case 'ArrowRight': {
if (
isRowHeader &&
flatRow.hasChildren &&
!flatRow.node.disabled &&
!expandedIds.value.has(rowId)
) {
// Collapsed parent at rowheader: expand
expandRow(rowId);
} else {
// Expanded parent at rowheader, leaf row at rowheader, or non-rowheader: move right
if (colIndex < colCount - 1) {
const nextCell = flatRow.node.cells[colIndex + 1];
if (nextCell) focusCell(nextCell.id);
}
}
break;
}
case 'ArrowLeft': {
if (isRowHeader) {
// At rowheader: collapse if expanded, move to parent if collapsed
if (flatRow.hasChildren && expandedIds.value.has(rowId) && !flatRow.node.disabled) {
collapseRow(rowId, cell.id);
} else if (flatRow.parentId) {
// Move to parent row's rowheader
const parentRow = rowMap.value.get(flatRow.parentId);
if (parentRow) {
const parentVisibleIndex = visibleRows.value.findIndex(
(r) => r.node.id === flatRow.parentId
);
if (parentVisibleIndex !== -1) {
const parentCell = parentRow.node.cells[colIndex];
if (parentCell) focusCell(parentCell.id);
}
}
}
} else {
// At non-rowheader: move left
if (colIndex > 0) {
const prevCell = flatRow.node.cells[colIndex - 1];
if (prevCell) focusCell(prevCell.id);
}
}
break;
}
case 'ArrowDown': {
const nextRowIndex = findNextVisibleRow(rowIndex, 'down');
if (nextRowIndex !== null) {
const nextRow = visibleRows.value[nextRowIndex];
const nextCell = nextRow?.node.cells[colIndex];
if (nextCell) focusCell(nextCell.id);
}
break;
}
case 'ArrowUp': {
const prevRowIndex = findNextVisibleRow(rowIndex, 'up');
if (prevRowIndex !== null) {
const prevRow = visibleRows.value[prevRowIndex];
const prevCell = prevRow?.node.cells[colIndex];
if (prevCell) focusCell(prevCell.id);
}
break;
}
case 'Home': {
if (event.ctrlKey) {
const firstCell = visibleRows.value[0]?.node.cells[0];
if (firstCell) focusCell(firstCell.id);
} else {
const firstCellInRow = flatRow.node.cells[0];
if (firstCellInRow) focusCell(firstCellInRow.id);
}
break;
}
case 'End': {
if (event.ctrlKey) {
const lastRow = visibleRows.value[visibleRows.value.length - 1];
const lastCell = lastRow?.node.cells[lastRow.node.cells.length - 1];
if (lastCell) focusCell(lastCell.id);
} else {
const lastCellInRow = flatRow.node.cells[flatRow.node.cells.length - 1];
if (lastCellInRow) focusCell(lastCellInRow.id);
}
break;
}
case 'PageDown': {
if (props.enablePageNavigation) {
const targetRowIndex = Math.min(rowIndex + props.pageSize, visibleRows.value.length - 1);
const targetRow = visibleRows.value[targetRowIndex];
const targetCell = targetRow?.node.cells[colIndex];
if (targetCell) focusCell(targetCell.id);
} else {
handled = false;
}
break;
}
case 'PageUp': {
if (props.enablePageNavigation) {
const targetRowIndex = Math.max(rowIndex - props.pageSize, 0);
const targetRow = visibleRows.value[targetRowIndex];
const targetCell = targetRow?.node.cells[colIndex];
if (targetCell) focusCell(targetCell.id);
} else {
handled = false;
}
break;
}
case ' ': {
// Space toggles ROW selection
toggleRowSelection(rowId, flatRow.node.disabled);
break;
}
case 'Enter': {
// Enter activates cell, does NOT expand/collapse
if (!cell.disabled && !flatRow.node.disabled) {
emit('cellActivate', cell.id, rowId, props.columns[colIndex]?.id ?? '');
}
break;
}
case 'a': {
if (event.ctrlKey) {
selectAllVisibleRows();
} else {
handled = false;
}
break;
}
default:
handled = false;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}
function setCellRef(cellId: string, el: HTMLDivElement | null) {
if (el) {
cellRefs.value.set(cellId, el);
} else {
cellRefs.value.delete(cellId);
}
}
// =============================================================================
// Helper Functions for Template
// =============================================================================
function getRowAriaExpanded(flatRow: FlatRow): 'true' | 'false' | undefined {
if (!flatRow.hasChildren) return undefined;
return expandedIds.value.has(flatRow.node.id) ? 'true' : 'false';
}
function getRowAriaSelected(flatRow: FlatRow): 'true' | 'false' | undefined {
if (!props.selectable) return undefined;
return selectedRowIds.value.has(flatRow.node.id) ? 'true' : 'false';
}
function getCellRole(colIndex: number): 'rowheader' | 'gridcell' {
return props.columns[colIndex]?.isRowHeader ? 'rowheader' : 'gridcell';
}
</script>
<template>
<div
ref="treegridRef"
role="treegrid"
:aria-label="ariaLabel"
:aria-labelledby="ariaLabelledby"
:aria-multiselectable="multiselectable ? 'true' : undefined"
:aria-rowcount="totalRows"
:aria-colcount="totalColumns"
class="apg-treegrid"
>
<!-- Header Row -->
<div role="row" :aria-rowindex="totalRows ? 1 : undefined">
<div
v-for="(col, colIndex) in columns"
:key="col.id"
role="columnheader"
:aria-colindex="totalColumns ? startColIndex + colIndex : undefined"
>
{{ col.header }}
</div>
</div>
<!-- Data Rows -->
<div
v-for="(flatRow, rowIndex) in visibleRows"
:key="flatRow.node.id"
role="row"
:aria-level="flatRow.level"
:aria-expanded="getRowAriaExpanded(flatRow)"
:aria-selected="getRowAriaSelected(flatRow)"
:aria-disabled="flatRow.node.disabled ? 'true' : undefined"
:aria-rowindex="totalRows ? startRowIndex + rowIndex : undefined"
>
<div
v-for="(cell, colIndex) in flatRow.node.cells"
:key="cell.id"
:ref="(el) => setCellRef(cell.id, el as HTMLDivElement)"
:role="getCellRole(colIndex)"
:tabindex="cell.id === focusedCellId ? 0 : -1"
:aria-disabled="cell.disabled || flatRow.node.disabled ? 'true' : undefined"
:aria-colindex="totalColumns ? startColIndex + colIndex : undefined"
:aria-colspan="cell.colspan"
class="apg-treegrid-cell"
:class="{
focused: cell.id === focusedCellId,
selected: selectedRowIds.has(flatRow.node.id),
disabled: cell.disabled || flatRow.node.disabled,
}"
:style="{
paddingLeft: columns[colIndex]?.isRowHeader
? `${(flatRow.level - 1) * 20 + 8}px`
: undefined,
}"
@keydown="handleKeyDown($event, cell, flatRow.node.id)"
@focusin="setFocusedCellId(cell.id)"
>
<span
v-if="columns[colIndex]?.isRowHeader && flatRow.hasChildren"
class="expand-icon"
aria-hidden="true"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 6 15 12 9 18" />
</svg>
</span>
<slot
name="cell"
:cell="cell"
:row-id="flatRow.node.id"
:col-id="columns[colIndex]?.id ?? ''"
>
{{ cell.value }}
</slot>
</div>
</div>
</div>
</template> Usage
<script setup lang="ts">
import { ref } from 'vue';
import TreeGrid from './TreeGrid.vue';
const columns = [
{ id: 'name', header: 'Name', isRowHeader: true },
{ id: 'size', header: 'Size' },
];
const nodes = [
{
id: 'folder1',
cells: [
{ id: 'folder1-name', value: 'Documents' },
{ id: 'folder1-size', value: '--' },
],
children: [
{
id: 'file1',
cells: [
{ id: 'file1-name', value: 'Report.pdf' },
{ id: 'file1-size', value: '2.5 MB' },
],
},
],
},
];
const expandedIds = ref(['folder1']);
const selectedRowIds = ref([]);
</script>
<template>
<TreeGrid
:columns="columns"
:nodes="nodes"
aria-label="File browser"
selectable
:multiselectable="true"
:expanded-ids="expandedIds"
:selected-row-ids="selectedRowIds"
@expanded-change="(ids) => expandedIds = ids"
@selection-change="(ids) => selectedRowIds = ids"
/>
</template> API
| Prop | Type | Default | Description |
|---|---|---|---|
columns | TreeGridColumnDef[] | required | Column definitions |
nodes | TreeGridNodeData[] | required | Hierarchical node data |
expanded-ids | string[] | [] | Expanded row IDs |
selected-row-ids | string[] | [] | Selected row IDs |
Custom Events
| Event | Detail | Description |
|---|---|---|
expanded-change | string[] | Emitted when expand state changes |
selection-change | string[] | Emitted when selection changes |
cell-activate | cellId, rowId, colId | Emitted when cell is activated |
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.
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