APG Patterns
日本語
日本語

TreeGrid

A hierarchical data grid combining Grid's 2D navigation with TreeView's expandable rows.

Demo

Navigate with arrow keys. At rowheader, use ArrowRight/Left to expand/collapse. Press Space to select rows.

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

Name
Size
Date Modified
Documents
--
2024-01-15
README.md
4 KB
2024-01-01

Open demo only →

Accessibility Features

WAI-ARIA Roles

RoleTarget ElementDescription
treegridContainerThe treegrid container (composite widget)
rowRow containerGroups cells horizontally, may have children
columnheaderHeader cellsColumn headers (not focusable)
rowheaderFirst column cellRow header where tree operations occur
gridcellData cellsInteractive cells (focusable)

WAI-ARIA Properties

role="treegrid"

Identifies the container as a treegrid

Values
-
Required
Yes

aria-label

Accessible name for the treegrid

Values
String
Required
Yes (either aria-label or aria-labelledby)

aria-labelledby

Alternative to aria-label

Values
ID reference
Required
Yes (either aria-label or aria-labelledby)

aria-multiselectable

Only present for multi-select mode

Values
true
Required
No

aria-rowcount

Total rows (for virtualization)

Values
Number
Required
No

aria-colcount

Total columns (for virtualization)

Values
Number
Required
No

WAI-ARIA States

aria-level

Target Element
row
Values
Number (1-based)
Required
Yes
Change Trigger

Static per row (determined by hierarchy)

aria-expanded

Target Element
row (parent only)
Values
true | false
Required
Yes
Change Trigger

ArrowRight/Left at rowheader, click on expand icon

aria-selected

Target Element
row
Values
true | false
Required
No
Change Trigger
Space key, click (NOT on gridcell)

aria-disabled

Target Element
row
Values
true
Required
No
Change Trigger
Only when row is disabled

aria-rowindex

Target Element
row
Values
Number
Required
No
Change Trigger
Static (for virtualization)

Keyboard Support

2D Navigation

KeyAction
Arrow DownMove focus to same column in next visible row
Arrow UpMove focus to same column in previous visible row
Arrow RightMove focus one cell right (at non-rowheader cells)
Arrow LeftMove focus one cell left (at non-rowheader cells)
HomeMove focus to first cell in row
EndMove focus to last cell in row
Ctrl + HomeMove focus to first cell in treegrid
Ctrl + EndMove focus to last cell in treegrid

Tree Operations (at rowheader only)

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

Row Selection & Cell Activation

KeyAction
SpaceToggle row selection (NOT cell selection)
EnterActivate focused cell (does NOT expand/collapse)
Ctrl + ASelect all visible rows (when multiselectable)
  • Rows can be expanded/collapsed to show/hide child rows
  • Row selection instead of cell selection (aria-selected on row, not gridcell)
  • Tree operations (expand/collapse) only work at the rowheader column
  • Rows have aria-level to indicate hierarchy depth

Focus Management

EventBehavior
Focus modelRoving tabindex - only one cell has tabindex="0"
Other cellstabindex="-1"
TreeGridSingle Tab stop (Tab enters/exits the grid)
Column headersNOT focusable (no tabindex)
Collapsed childrenNOT in keyboard navigation
Parent collapsesIf focus was on child, move focus to parent

References

Source Code

TreeGrid.svelte
<script lang="ts">
  import { untrack } from 'svelte';
  import { SvelteMap, SvelteSet } from 'svelte/reactivity';

  // =============================================================================
  // 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 CellPosition {
    rowIndex: number;
    colIndex: number;
    cell: TreeGridCellData;
    rowId: string;
    isRowHeader: 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;
    onExpandedChange?: (expandedIds: string[]) => void;
    onSelectionChange?: (selectedRowIds: string[]) => void;
    onFocusChange?: (cellId: string | null) => void;
    onCellActivate?: (cellId: string, rowId: string, colId: string) => void;
    renderCell?: (cell: TreeGridCellData, rowId: string, colId: string) => string | number;
  }

  // =============================================================================
  // Props
  // =============================================================================

  let {
    columns,
    nodes,
    ariaLabel,
    ariaLabelledby,
    expandedIds: controlledExpandedIds,
    defaultExpandedIds = [],
    selectable = false,
    multiselectable = false,
    selectedRowIds: controlledSelectedRowIds,
    defaultSelectedRowIds = [],
    defaultFocusedCellId,
    totalRows,
    totalColumns,
    startRowIndex = 2,
    startColIndex = 1,
    enablePageNavigation = false,
    pageSize = 5,
    onExpandedChange,
    onSelectionChange,
    onFocusChange,
    onCellActivate,
    renderCell,
  }: Props = $props();

  // =============================================================================
  // State
  // =============================================================================

  let internalExpandedIds = new SvelteSet<string>(untrack(() => defaultExpandedIds));
  let internalSelectedRowIds = new SvelteSet<string>(untrack(() => defaultSelectedRowIds));
  let focusedCellIdState = $state<string | null>(null);
  let initialized = $state(false);

  let treegridRef: HTMLDivElement | null = $state(null);
  let cellRefs: Map<string, HTMLDivElement> = new SvelteMap();

  // =============================================================================
  // Derived - Tree Flattening
  // =============================================================================

  function 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 = $derived(flattenTree(nodes));

  const rowMap = $derived.by(() => {
    const map = new SvelteMap<string, FlatRow>();
    for (const flatRow of allRows) {
      map.set(flatRow.node.id, flatRow);
    }
    return map;
  });

  const expandedIds = $derived(
    controlledExpandedIds ? new SvelteSet(controlledExpandedIds) : internalExpandedIds
  );
  const selectedRowIds = $derived(
    controlledSelectedRowIds ? new SvelteSet(controlledSelectedRowIds) : internalSelectedRowIds
  );

  const visibleRows = $derived.by(() => {
    const result: FlatRow[] = [];
    const collapsedParents = new SvelteSet<string>();

    for (const flatRow of allRows) {
      let isHidden = false;
      let currentParentId = flatRow.parentId;

      while (currentParentId) {
        if (collapsedParents.has(currentParentId) || !expandedIds.has(currentParentId)) {
          isHidden = true;
          break;
        }
        const parent = rowMap.get(currentParentId);
        currentParentId = parent?.parentId ?? null;
      }

      if (!isHidden) {
        result.push(flatRow);
        if (flatRow.hasChildren && !expandedIds.has(flatRow.node.id)) {
          collapsedParents.add(flatRow.node.id);
        }
      }
    }
    return result;
  });

  const cellPositionMap = $derived.by(() => {
    const map = new SvelteMap<string, CellPosition>();
    visibleRows.forEach((flatRow, rowIndex) => {
      flatRow.node.cells.forEach((cell, colIndex) => {
        map.set(cell.id, {
          rowIndex,
          colIndex,
          cell,
          rowId: flatRow.node.id,
          isRowHeader: columns[colIndex]?.isRowHeader ?? false,
        });
      });
    });
    return map;
  });

  const focusedCellId = $derived(
    focusedCellIdState ?? defaultFocusedCellId ?? visibleRows[0]?.node.cells[0]?.id ?? null
  );

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

  $effect(() => {
    if (!initialized && nodes.length > 0) {
      internalExpandedIds = new SvelteSet(defaultExpandedIds);
      internalSelectedRowIds = new SvelteSet(defaultSelectedRowIds);
      initialized = true;
    }
  });

  $effect(() => {
    if (treegridRef && nodes.length > 0) {
      const focusableElements = treegridRef.querySelectorAll<HTMLElement>(
        '[role="gridcell"] a[href], [role="gridcell"] button, [role="rowheader"] a[href], [role="rowheader"] button'
      );
      focusableElements.forEach((el) => {
        el.setAttribute('tabindex', '-1');
      });
    }
  });

  function registerCell(node: HTMLDivElement, cellId: string) {
    cellRefs.set(cellId, node);
    return {
      destroy() {
        cellRefs.delete(cellId);
      },
    };
  }

  // =============================================================================
  // Methods
  // =============================================================================

  function setFocusedCellId(id: string | null) {
    focusedCellIdState = id;
    onFocusChange?.(id);
  }

  function focusCell(cellId: string) {
    const cellEl = cellRefs.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 (!controlledExpandedIds) {
      internalExpandedIds = newExpandedIds;
    }
    onExpandedChange?.([...newExpandedIds]);
  }

  function expandRow(rowId: string) {
    const flatRow = rowMap.get(rowId);
    if (!flatRow?.hasChildren || flatRow.node.disabled) return;
    if (expandedIds.has(rowId)) return;

    const newExpanded = new SvelteSet(expandedIds);
    newExpanded.add(rowId);
    updateExpandedIds(newExpanded);
  }

  function collapseRow(rowId: string, currentFocusCellId?: string) {
    const flatRow = rowMap.get(rowId);
    if (!flatRow?.hasChildren || flatRow.node.disabled) return;
    if (!expandedIds.has(rowId)) return;

    const newExpanded = new SvelteSet(expandedIds);
    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.get(currentFocusCellId);
      if (focusPos) {
        const focusRowId = focusPos.rowId;
        let currentRow = rowMap.get(focusRowId);
        while (currentRow) {
          if (currentRow.parentId === rowId) {
            const parentRow = rowMap.get(rowId);
            if (parentRow) {
              const parentFirstCell = parentRow.node.cells[0];
              if (parentFirstCell) {
                // Use setTimeout to focus after Svelte updates the DOM
                setTimeout(() => focusCell(parentFirstCell.id), 0);
              }
            }
            break;
          }
          currentRow = currentRow.parentId ? rowMap.get(currentRow.parentId) : undefined;
        }
      }
    }
  }

  function updateSelectedRowIds(newSelectedIds: Set<string>) {
    if (!controlledSelectedRowIds) {
      internalSelectedRowIds = newSelectedIds;
    }
    onSelectionChange?.([...newSelectedIds]);
  }

  function toggleRowSelection(rowId: string, rowDisabled?: boolean) {
    if (!selectable || rowDisabled) return;

    if (multiselectable) {
      const newIds = new SvelteSet(selectedRowIds);
      if (newIds.has(rowId)) {
        newIds.delete(rowId);
      } else {
        newIds.add(rowId);
      }
      updateSelectedRowIds(newIds);
    } else {
      const newIds = selectedRowIds.has(rowId) ? new SvelteSet<string>() : new SvelteSet([rowId]);
      updateSelectedRowIds(newIds);
    }
  }

  function selectAllVisibleRows() {
    if (!selectable || !multiselectable) return;

    const allIds = new SvelteSet<string>();
    for (const flatRow of visibleRows) {
      if (!flatRow.node.disabled) {
        allIds.add(flatRow.node.id);
      }
    }
    updateSelectedRowIds(allIds);
  }

  function findNextVisibleRow(
    startRowIndex: number,
    direction: 'up' | 'down',
    size = 1
  ): number | null {
    if (direction === 'down') {
      const targetIndex = Math.min(startRowIndex + size, visibleRows.length - 1);
      return targetIndex > startRowIndex ? targetIndex : null;
    } else {
      const targetIndex = Math.max(startRowIndex - size, 0);
      return targetIndex < startRowIndex ? targetIndex : null;
    }
  }

  function handleKeyDown(event: KeyboardEvent, cell: TreeGridCellData, rowId: string) {
    const pos = cellPositionMap.get(cell.id);
    if (!pos) return;

    const { rowIndex, colIndex, isRowHeader } = pos;
    const flatRow = visibleRows[rowIndex];
    const colCount = columns.length;

    let handled = true;

    switch (event.key) {
      case 'ArrowRight': {
        if (
          isRowHeader &&
          flatRow.hasChildren &&
          !flatRow.node.disabled &&
          !expandedIds.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) {
          if (flatRow.hasChildren && expandedIds.has(rowId) && !flatRow.node.disabled) {
            collapseRow(rowId, cell.id);
          } else if (flatRow.parentId) {
            const parentRow = rowMap.get(flatRow.parentId);
            if (parentRow) {
              const parentVisibleIndex = visibleRows.findIndex(
                (r) => r.node.id === flatRow.parentId
              );
              if (parentVisibleIndex !== -1) {
                const parentCell = parentRow.node.cells[colIndex];
                if (parentCell) focusCell(parentCell.id);
              }
            }
          }
        } else {
          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[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[prevRowIndex];
          const prevCell = prevRow?.node.cells[colIndex];
          if (prevCell) focusCell(prevCell.id);
        }
        break;
      }
      case 'Home': {
        if (event.ctrlKey) {
          const firstCell = visibleRows[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[visibleRows.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 (enablePageNavigation) {
          const targetRowIndex = Math.min(rowIndex + pageSize, visibleRows.length - 1);
          const targetRow = visibleRows[targetRowIndex];
          const targetCell = targetRow?.node.cells[colIndex];
          if (targetCell) focusCell(targetCell.id);
        } else {
          handled = false;
        }
        break;
      }
      case 'PageUp': {
        if (enablePageNavigation) {
          const targetRowIndex = Math.max(rowIndex - pageSize, 0);
          const targetRow = visibleRows[targetRowIndex];
          const targetCell = targetRow?.node.cells[colIndex];
          if (targetCell) focusCell(targetCell.id);
        } else {
          handled = false;
        }
        break;
      }
      case ' ': {
        toggleRowSelection(rowId, flatRow.node.disabled);
        break;
      }
      case 'Enter': {
        if (!cell.disabled && !flatRow.node.disabled) {
          onCellActivate?.(cell.id, rowId, columns[colIndex]?.id ?? '');
        }
        break;
      }
      case 'a': {
        if (event.ctrlKey) {
          selectAllVisibleRows();
        } else {
          handled = false;
        }
        break;
      }
      default:
        handled = false;
    }

    if (handled) {
      event.preventDefault();
      event.stopPropagation();
    }
  }

  // =============================================================================
  // Helper Functions
  // =============================================================================

  function getRowAriaExpanded(flatRow: FlatRow): 'true' | 'false' | undefined {
    if (!flatRow.hasChildren) return undefined;
    return expandedIds.has(flatRow.node.id) ? 'true' : 'false';
  }

  function getRowAriaSelected(flatRow: FlatRow): 'true' | 'false' | undefined {
    if (!selectable) return undefined;
    return selectedRowIds.has(flatRow.node.id) ? 'true' : 'false';
  }

  function getCellRole(colIndex: number): 'rowheader' | 'gridcell' {
    return columns[colIndex]?.isRowHeader ? 'rowheader' : 'gridcell';
  }

  function getCellPaddingLeft(flatRow: FlatRow, colIndex: number): string | undefined {
    if (!columns[colIndex]?.isRowHeader) return undefined;
    return `${(flatRow.level - 1) * 20 + 8}px`;
  }
</script>

<div
  bind:this={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}>
    {#each columns as col, colIndex (col.id)}
      <div role="columnheader" aria-colindex={totalColumns ? startColIndex + colIndex : undefined}>
        {col.header}
      </div>
    {/each}
  </div>

  <!-- Data Rows -->
  {#each visibleRows as flatRow, rowIndex (flatRow.node.id)}
    <div
      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}
    >
      {#each flatRow.node.cells as cell, colIndex (cell.id)}
        {@const isFocused = cell.id === focusedCellId}
        {@const isSelected = selectedRowIds.has(flatRow.node.id)}
        {@const colId = columns[colIndex]?.id ?? ''}
        {@const isRowHeaderCell = columns[colIndex]?.isRowHeader}
        <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
        <div
          role={getCellRole(colIndex)}
          tabindex={isFocused ? 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={isFocused}
          class:selected={isSelected}
          class:disabled={cell.disabled || flatRow.node.disabled}
          style:padding-left={getCellPaddingLeft(flatRow, colIndex)}
          onkeydown={(e) => handleKeyDown(e, cell, flatRow.node.id)}
          onfocusin={() => setFocusedCellId(cell.id)}
          use:registerCell={cell.id}
        >
          {#if isRowHeaderCell && flatRow.hasChildren}
            <span 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>
          {/if}
          {#if renderCell}
            <!-- eslint-disable-next-line svelte/no-at-html-tags -- renderCell returns sanitized HTML from the consuming application -->
            {@html renderCell(cell, flatRow.node.id, colId)}
          {:else}
            {cell.value}
          {/if}
        </div>
      {/each}
    </div>
  {/each}
</div>

Usage

Example
<script lang="ts">
  import TreeGrid from './TreeGrid.svelte';

  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' },
          ],
        },
      ],
    },
  ];

  let expandedIds = $state(['folder1']);
  let selectedRowIds = $state([]);
</script>

<TreeGrid
  {columns}
  {nodes}
  ariaLabel="File browser"
  selectable
  multiselectable
  {expandedIds}
  {selectedRowIds}
  onExpandedChange={(ids) => expandedIds = ids}
  onSelectionChange={(ids) => selectedRowIds = ids}
/>

API

Prop Type Default Description
columns TreeGridColumnDef[] required Column definitions
nodes TreeGridNodeData[] required Hierarchical node data
expandedIds string[] [] Expanded row IDs
selectedRowIds string[] [] Selected row IDs
onExpandedChange (ids: string[]) => void - Expand state change callback
onSelectionChange (ids: string[]) => void - Selection change callback

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The TreeGrid component combines Grid and TreeView testing strategies.

Testing Strategy

Unit Tests (Testing Library)

Verify component rendering and interactions using framework-specific Testing Library utilities. These tests ensure correct component behavior in isolation.

  • HTML structure and element hierarchy (treegrid, row, rowheader, gridcell)
  • Initial attribute values (role, aria-label, aria-level, aria-expanded)
  • Selection state changes (aria-selected on rows)
  • Hierarchy depth indicators (aria-level)
  • CSS class application

E2E Tests (Playwright)

Verify component behavior in a real browser environment across all four frameworks. These tests cover interactions that require full browser context.

  • 2D keyboard navigation (Arrow keys)
  • Tree operations at rowheader (ArrowRight/Left for expand/collapse)
  • Extended navigation (Home, End, Ctrl+Home, Ctrl+End)
  • Row selection with Space
  • Focus management and roving tabindex
  • Hidden row handling (collapsed children)
  • Cross-framework consistency

Test Categories

High Priority: APG ARIA Attributes ( Unit + E2E )

Test Description
role="treegrid" Container has treegrid role
role="row" All rows have row role
role="rowheader" First cell in row has rowheader role
role="gridcell" Other cells have gridcell role
role="columnheader" Header cells have columnheader role
aria-label TreeGrid has accessible name via aria-label
aria-level All rows have aria-level (1-based depth)
aria-expanded Parent rows have aria-expanded (true/false)
aria-selected on row Selection on row element (not cell)
aria-multiselectable Present when multi-selection is enabled

High Priority: Tree Operations (at Rowheader) ( E2E )

Test Description
ArrowRight expands Expands collapsed parent row
ArrowRight to child Moves to first child when already expanded
ArrowLeft collapses Collapses expanded parent row
ArrowLeft to parent Moves to parent row when collapsed or leaf
Enter activates only Enter does NOT expand/collapse (unlike Tree)
Children hidden Child rows hidden when parent collapsed

High Priority: 2D Keyboard Navigation ( E2E )

Test Description
ArrowRight (non-rowheader) Moves focus one cell right
ArrowLeft (non-rowheader) Moves focus one cell left
ArrowDown Moves focus to next visible row
ArrowUp Moves focus to previous visible row
Skip hidden rows ArrowDown/Up skips collapsed children

High Priority: Extended Navigation ( E2E )

Test Description
Home Moves focus to first cell in row
End Moves focus to last cell in row
Ctrl+Home Moves focus to first cell in first visible row
Ctrl+End Moves focus to last cell in last visible row

High Priority: Focus Management (Roving Tabindex) ( Unit + E2E )

Test Description
tabindex="0" First focusable cell has tabindex="0"
tabindex="-1" Other cells have tabindex="-1"
Headers not focusable columnheader cells have no tabindex
Tab exits treegrid Tab moves focus out of treegrid
Focus update Focused cell updates tabindex on navigation

High Priority: Row Selection ( E2E )

Test Description
Space toggles row Space toggles row selection (not cell)
Single select Single selection clears previous on Space
Multi select Multi-selection allows multiple rows
Enter activates cell Enter triggers cell activation

Medium Priority: Accessibility ( E2E )

Test Description
axe-core No accessibility violations

Example Test Code

The following is the actual E2E test file (e2e/treegrid.spec.ts).

e2e/treegrid.spec.ts
import { expect, test, type Locator, type Page } from '@playwright/test';

/**
 * E2E Tests for TreeGrid Pattern
 *
 * Tests verify the TreeGrid component behavior in a real browser,
 * including 2D keyboard navigation, tree operations (expand/collapse),
 * row selection, and focus management.
 *
 * Test coverage:
 * - ARIA structure and attributes (treegrid, aria-level, aria-expanded)
 * - 2D keyboard navigation (Arrow keys, Home, End, Ctrl+Home, Ctrl+End)
 * - Tree operations at rowheader (ArrowRight/Left for expand/collapse)
 * - Row selection (Space toggles row selection, not cell)
 * - Focus management (roving tabindex)
 *
 * Key differences from Grid:
 * - Tree operations only at rowheader cells
 * - Row selection (aria-selected on row, not cell)
 * - aria-level and aria-expanded on rows
 */

/**
 * Helper to check if a cell or a focusable element within it is focused.
 */
async function expectCellOrChildFocused(_page: Page, cell: Locator): Promise<void> {
  const cellIsFocused = await cell.evaluate((el) => document.activeElement === el);
  if (cellIsFocused) {
    await expect(cell).toBeFocused();
    return;
  }

  const focusedChild = cell.locator(
    'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
  );
  const childCount = await focusedChild.count();
  if (childCount > 0) {
    for (let i = 0; i < childCount; i++) {
      const child = focusedChild.nth(i);
      const childIsFocused = await child.evaluate((el) => document.activeElement === el);
      if (childIsFocused) {
        await expect(child).toBeFocused();
        return;
      }
    }
  }

  await expect(cell).toBeFocused();
}

/**
 * Helper to focus a cell, handling cells that contain links/buttons.
 * Returns the focused element (either the cell or a focusable child).
 */
async function focusCell(_page: Page, cell: Locator): Promise<Locator> {
  await cell.click({ position: { x: 5, y: 5 } });

  // Check if focus is on the cell or a child element
  const cellIsFocused = await cell.evaluate((el) => document.activeElement === el);
  if (cellIsFocused) {
    return cell;
  }

  // Find and return the focused child
  const focusedChild = cell.locator(
    'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
  );
  const childCount = await focusedChild.count();
  for (let i = 0; i < childCount; i++) {
    const child = focusedChild.nth(i);
    const childIsFocused = await child.evaluate((el) => document.activeElement === el);
    if (childIsFocused) {
      return child;
    }
  }

  return cell;
}

/**
 * Helper to get the row containing a cell.
 */
async function getRowForCell(cell: Locator): Promise<Locator> {
  return cell.locator('xpath=ancestor::*[@role="row"]').first();
}

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

for (const framework of frameworks) {
  test.describe(`TreeGrid (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/treegrid/${framework}/demo/`);
      await page.waitForLoadState('networkidle');
      await page.waitForSelector('[role="treegrid"]');
    });

    // 🔴 High Priority: ARIA Attributes
    test.describe('ARIA Attributes', () => {
      test('has role="treegrid" on container', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        await expect(treegrid).toBeVisible();
      });

      test('has role="row" on rows', async ({ page }) => {
        const rows = page.getByRole('row');
        await expect(rows.first()).toBeVisible();
        expect(await rows.count()).toBeGreaterThan(1);
      });

      test('has role="gridcell" on data cells', async ({ page }) => {
        const cells = page.getByRole('gridcell');
        await expect(cells.first()).toBeVisible();
      });

      test('has role="columnheader" on header cells', async ({ page }) => {
        const headers = page.getByRole('columnheader');
        await expect(headers.first()).toBeVisible();
      });

      test('has role="rowheader" on row header cells', async ({ page }) => {
        const rowheaders = page.getByRole('rowheader');
        await expect(rowheaders.first()).toBeVisible();
      });

      test('has accessible name', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const label = await treegrid.getAttribute('aria-label');
        const labelledby = await treegrid.getAttribute('aria-labelledby');
        expect(label || labelledby).toBeTruthy();
      });

      test('parent rows have aria-expanded', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        let foundParentRow = false;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded !== null) {
            foundParentRow = true;
            expect(['true', 'false']).toContain(ariaExpanded);
          }
        }
        expect(foundParentRow).toBe(true);
      });

      test('all data rows have aria-level', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaLevel = await row.getAttribute('aria-level');
          // Skip header row (no aria-level)
          if (ariaLevel !== null) {
            const level = parseInt(ariaLevel, 10);
            expect(level).toBeGreaterThanOrEqual(1);
          }
        }
      });

      test('aria-level is 1-based and increments with depth', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        const levels: number[] = [];
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaLevel = await row.getAttribute('aria-level');
          if (ariaLevel !== null) {
            levels.push(parseInt(ariaLevel, 10));
          }
        }

        // Check that level 1 exists (root level)
        expect(levels).toContain(1);
      });

      test('has aria-selected on row (not gridcell) when selectable', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        let hasSelectableRow = false;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaSelected = await row.getAttribute('aria-selected');
          if (ariaSelected !== null) {
            hasSelectableRow = true;
            expect(['true', 'false']).toContain(ariaSelected);
          }
        }

        if (hasSelectableRow) {
          // Verify gridcells don't have aria-selected
          const cells = treegrid.getByRole('gridcell');
          const cellCount = await cells.count();
          for (let i = 0; i < cellCount; i++) {
            const cell = cells.nth(i);
            const ariaSelected = await cell.getAttribute('aria-selected');
            expect(ariaSelected).toBeNull();
          }
        }
      });
    });

    // 🔴 High Priority: Keyboard - Row Navigation
    test.describe('Keyboard - Row Navigation', () => {
      test('ArrowDown moves to same column in next visible row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const firstRowheader = rowheaders.first();
        const focusedElement = await focusCell(page, firstRowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowDown');

        const secondRowheader = rowheaders.nth(1);
        await expectCellOrChildFocused(page, secondRowheader);
      });

      test('ArrowUp moves to same column in previous visible row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const secondRowheader = rowheaders.nth(1);
        const focusedElement = await focusCell(page, secondRowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowUp');

        const firstRowheader = rowheaders.first();
        await expectCellOrChildFocused(page, firstRowheader);
      });

      test('ArrowUp stops at first visible row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const firstRowheader = rowheaders.first();
        const focusedElement = await focusCell(page, firstRowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowUp');

        // Should stay on first row
        await expectCellOrChildFocused(page, firstRowheader);
      });
    });

    // 🔴 High Priority: Keyboard - Cell Navigation
    test.describe('Keyboard - Cell Navigation', () => {
      test('ArrowRight at non-rowheader moves right', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const firstCell = cells.first();
        const focusedElement = await focusCell(page, firstCell);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowRight');

        const secondCell = cells.nth(1);
        await expectCellOrChildFocused(page, secondCell);
      });

      test('ArrowLeft at non-rowheader moves left', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const secondCell = cells.nth(1);
        const focusedElement = await focusCell(page, secondCell);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowLeft');

        const firstCell = cells.first();
        await expectCellOrChildFocused(page, firstCell);
      });

      test('Home moves to first cell in row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const rowheaders = treegrid.getByRole('rowheader');
        const secondCell = cells.nth(1);
        const focusedElement = await focusCell(page, secondCell);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('Home');

        // Should move to rowheader (first cell in row)
        const firstRowheader = rowheaders.first();
        await expectCellOrChildFocused(page, firstRowheader);
      });

      test('End moves to last cell in row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const firstRowheader = rowheaders.first();
        const focusedElement = await focusCell(page, firstRowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('End');

        // Should move to last cell in first data row
        // Get cells in the same row
        const row = await getRowForCell(firstRowheader);
        const cellsInRow = row.getByRole('gridcell');
        const lastCell = cellsInRow.last();
        await expectCellOrChildFocused(page, lastCell);
      });

      test('Ctrl+Home moves to first cell in grid', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const rowheaders = treegrid.getByRole('rowheader');
        const lastCell = cells.last();
        const focusedElement = await focusCell(page, lastCell);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('Control+Home');

        // Should move to first rowheader
        const firstRowheader = rowheaders.first();
        await expectCellOrChildFocused(page, firstRowheader);
      });

      test('Ctrl+End moves to last cell in grid', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const cells = treegrid.getByRole('gridcell');
        const firstRowheader = rowheaders.first();
        const focusedElement = await focusCell(page, firstRowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('Control+End');

        // Should move to last cell
        const lastCell = cells.last();
        await expectCellOrChildFocused(page, lastCell);
      });
    });

    // 🔴 High Priority: Keyboard - Tree Operations
    test.describe('Keyboard - Tree Operations', () => {
      test('ArrowRight at collapsed rowheader expands row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a collapsed parent row
        let collapsedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'false') {
            collapsedRowIndex = i;
            break;
          }
        }

        if (collapsedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(collapsedRowIndex);
        const rowheader = row.getByRole('rowheader');
        const focusedElement = await focusCell(page, rowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowRight');

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

      test('expanding a row makes child rows visible', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a collapsed parent row
        let collapsedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'false') {
            collapsedRowIndex = i;
            break;
          }
        }

        if (collapsedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(collapsedRowIndex);
        const rowheader = row.getByRole('rowheader');

        // Get initial visible rowheader count
        const visibleRowheadersBefore = await treegrid.getByRole('rowheader').count();

        const focusedElement = await focusCell(page, rowheader);
        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowRight');

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

        // After expansion, there should be more visible rowheaders (child rows appeared)
        const visibleRowheadersAfter = await treegrid.getByRole('rowheader').count();
        expect(visibleRowheadersAfter).toBeGreaterThan(visibleRowheadersBefore);
      });

      test('ArrowLeft at expanded rowheader collapses row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find an expanded parent row
        let expandedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'true') {
            expandedRowIndex = i;
            break;
          }
        }

        if (expandedRowIndex === -1) {
          // Try to expand first, then collapse
          for (let i = 0; i < rowCount; i++) {
            const row = rows.nth(i);
            const ariaExpanded = await row.getAttribute('aria-expanded');
            if (ariaExpanded === 'false') {
              const rowheader = row.getByRole('rowheader');
              const focused = await focusCell(page, rowheader);
              await expect(focused).toBeFocused();
              await focused.press('ArrowRight');
              await expect(row).toHaveAttribute('aria-expanded', 'true');
              expandedRowIndex = i;
              break;
            }
          }
        }

        if (expandedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(expandedRowIndex);
        const rowheader = row.getByRole('rowheader');
        const focusedElement = await focusCell(page, rowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowLeft');

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

      test('ArrowRight at expanded rowheader moves right to next cell', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find an expanded parent row
        let expandedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'true') {
            expandedRowIndex = i;
            break;
          }
        }

        if (expandedRowIndex === -1) {
          // Try to expand a collapsed row first
          for (let i = 0; i < rowCount; i++) {
            const row = rows.nth(i);
            const ariaExpanded = await row.getAttribute('aria-expanded');
            if (ariaExpanded === 'false') {
              const rowheader = row.getByRole('rowheader');
              const focused = await focusCell(page, rowheader);
              await expect(focused).toBeFocused();
              await focused.press('ArrowRight');
              await expect(row).toHaveAttribute('aria-expanded', 'true');
              expandedRowIndex = i;
              break;
            }
          }
        }

        if (expandedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(expandedRowIndex);
        const rowheader = row.getByRole('rowheader');
        const cells = row.getByRole('gridcell');
        const firstCell = cells.first();

        const focusedElement = await focusCell(page, rowheader);

        // ArrowRight at expanded rowheader should move to the next cell (not expand again)
        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowRight');

        // Row should still be expanded
        await expect(row).toHaveAttribute('aria-expanded', 'true');

        // Focus should move to first gridcell in same row
        await expectCellOrChildFocused(page, firstCell);
      });

      test('ArrowRight at non-rowheader does NOT expand', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a collapsed parent row
        let collapsedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'false') {
            collapsedRowIndex = i;
            break;
          }
        }

        if (collapsedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(collapsedRowIndex);
        const cells = row.getByRole('gridcell');
        const firstCell = cells.first();
        const focusedElement = await focusCell(page, firstCell);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowRight');

        // Should NOT expand - still collapsed
        await expect(row).toHaveAttribute('aria-expanded', 'false');
      });

      test('Enter does NOT expand/collapse', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a collapsed parent row
        let collapsedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'false') {
            collapsedRowIndex = i;
            break;
          }
        }

        if (collapsedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(collapsedRowIndex);
        const rowheader = row.getByRole('rowheader');
        const focusedElement = await focusCell(page, rowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('Enter');

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

    // 🔴 High Priority: Row Selection
    test.describe('Row Selection', () => {
      test('Space toggles row selection', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a selectable row
        let selectableRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaSelected = await row.getAttribute('aria-selected');
          if (ariaSelected !== null) {
            selectableRowIndex = i;
            break;
          }
        }

        if (selectableRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(selectableRowIndex);
        const rowheader = row.getByRole('rowheader');
        const focusedElement = await focusCell(page, rowheader);

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('Space');

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

      test('Space toggles row selection off', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a selectable row
        let selectableRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaSelected = await row.getAttribute('aria-selected');
          if (ariaSelected !== null) {
            selectableRowIndex = i;
            break;
          }
        }

        if (selectableRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(selectableRowIndex);
        const rowheader = row.getByRole('rowheader');
        const focusedElement = await focusCell(page, rowheader);

        // Select
        await expect(focusedElement).toBeFocused();
        await focusedElement.press('Space');
        await expect(row).toHaveAttribute('aria-selected', 'true');

        // Deselect (focus should still be on the same element after Space)
        await focusedElement.press('Space');
        await expect(row).toHaveAttribute('aria-selected', 'false');
      });
    });

    // 🔴 High Priority: Focus Management
    test.describe('Focus Management', () => {
      test('first focusable cell has tabIndex="0"', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const firstRowheader = rowheaders.first();
        await expect(firstRowheader).toHaveAttribute('tabindex', '0');
      });

      test('other cells have tabIndex="-1"', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const firstCell = cells.first();
        await expect(firstCell).toHaveAttribute('tabindex', '-1');
      });

      test('columnheader cells are not focusable', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const headers = treegrid.getByRole('columnheader');
        const firstHeader = headers.first();
        const tabindex = await firstHeader.getAttribute('tabindex');
        expect(tabindex).toBeNull();
      });

      test('Tab exits treegrid', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const firstRowheader = rowheaders.first();
        await focusCell(page, firstRowheader);

        await page.keyboard.press('Tab');

        const treegridContainsFocus = await treegrid.evaluate((el) =>
          el.contains(document.activeElement)
        );
        expect(treegridContainsFocus).toBe(false);
      });

      test('roving tabindex updates on arrow navigation', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const firstCell = cells.first(); // First gridcell (not rowheader) = Size column of first row
        const secondCell = cells.nth(1); // Date column of first row

        // Initially first rowheader has tabindex="0", gridcells have "-1"
        await expect(firstCell).toHaveAttribute('tabindex', '-1');
        await expect(secondCell).toHaveAttribute('tabindex', '-1');

        // Focus first gridcell (Size column) and navigate right to Date column
        const focusedElement = await focusCell(page, firstCell);
        await expect(firstCell).toHaveAttribute('tabindex', '0');

        await expect(focusedElement).toBeFocused();
        await focusedElement.press('ArrowRight');

        // After navigation, tabindex should update
        await expect(firstCell).toHaveAttribute('tabindex', '-1');
        await expect(secondCell).toHaveAttribute('tabindex', '0');
      });
    });
  });
}

Running Tests

          
            # Run unit tests for TreeGrid
npm run test -- treegrid

# Run E2E tests for TreeGrid (all frameworks)
npm run test:e2e:pattern --pattern=treegrid

# Run E2E tests for specific framework
npm run test:e2e:react:pattern --pattern=treegrid

npm run test:e2e:vue:pattern --pattern=treegrid

npm run test:e2e:svelte:pattern --pattern=treegrid

npm run test:e2e:astro:pattern --pattern=treegrid
          
        

Key Difference from Grid

  • Selection target: TreeGrid selects rows (aria-selected on row), Grid selects cells
  • Arrow behavior at rowheader: ArrowRight/Left do tree operations, not cell navigation
  • Hierarchy: Must test aria-level values and parent/child relationships
  • Hidden rows: Must skip collapsed children during navigation

Testing Tools

See testing-strategy.md (opens in new tab) for full documentation.

Resources