TreeGrid
Gridの2Dナビゲーションと、TreeViewの展開可能な行を組み合わせた階層データグリッド。
デモ
矢印キーで移動。rowheaderでArrowRight/Leftで展開/折りたたみ。Spaceで行を選択。
TreeGrid vs Grid
展開/折りたたみ可能な階層データにはtreegridロールを使用します。
| 機能 | TreeGrid | Grid |
|---|---|---|
| 階層 | 展開/折りたたみ可能な行 | フラット構造 |
| 選択 | 行選択(行のaria-selected) | セル選択(セルのaria-selected) |
| rowheaderでの矢印 | ツリーの展開/折りたたみ | フォーカス移動 |
| 必須ARIA | aria-level, aria-expanded | なし(階層固有) |
アクセシビリティ
TreeGrid vs Grid
treegridロールは、Gridの2Dキーボードナビゲーションと、TreeViewの階層展開/折りたたみ機能を組み合わせています。Gridとの主な違い:
- 行を展開/折りたたみして子行の表示/非表示を切り替えられます
- セル選択ではなく行選択(
aria-selectedはgridcellではなくrowに設定) - ツリー操作(展開/折りたたみ)はrowheader列でのみ機能します
- 行には階層の深さを示す
aria-levelがあります
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
treegrid | コンテナ | treegridコンテナ(複合ウィジェット) |
row | 行コンテナ | セルを水平方向にグループ化し、子を持つことができます |
columnheader | ヘッダーセル | 列ヘッダー(フォーカス不可) |
rowheader | 最初の列セル | ツリー操作が行われる行ヘッダー |
gridcell | データセル | インタラクティブなセル(フォーカス可能) |
W3C ARIA: treegrid role (opens in new tab)
WAI-ARIA プロパティ (TreeGrid Container)
| 属性 | 値 | 必須 | 説明 |
|---|---|---|---|
role="treegrid" | - | はい | コンテナをtreegridとして識別します |
aria-label | 文字列 | はい(aria-labelまたはaria-labelledbyのいずれか) | treegridのアクセシブルな名前 |
aria-labelledby | ID参照 | はい(aria-labelまたはaria-labelledbyのいずれか) | aria-labelの代替 |
aria-multiselectable | true | いいえ | 複数選択モードの場合のみ存在 |
aria-rowcount | 数値 | いいえ | 総行数(仮想化用) |
aria-colcount | 数値 | いいえ | 総列数(仮想化用) |
* aria-labelまたはaria-labelledbyのいずれかが必須です。
WAI-ARIA ステート (Rows)
| 属性 | 値 | 必須 | 説明 |
|---|---|---|---|
aria-level | 数値(1始まり) | はい | 行ごとに静的(階層構造により決定) |
aria-expanded | true | false | はい* | rowheaderでのArrowRight/Left、展開アイコンのクリック |
aria-selected | true | false | いいえ** | Spaceキー、クリック(gridcellではなく行に設定) |
aria-disabled | true | いいえ | 行が無効な場合のみ |
aria-rowindex | 数値 | いいえ | 静的(仮想化用) |
* 親行(子を持つ行)のみにaria-expandedがあります。リーフ行にはこの属性はありません。
** 選択がサポートされている場合、すべての行にaria-selectedが必要です。
キーボードサポート
2Dナビゲーション
| キー | アクション |
|---|---|
| Arrow Down | 次の表示行の同じ列にフォーカスを移動 |
| Arrow Up | 前の表示行の同じ列にフォーカスを移動 |
| Arrow Right | フォーカスを右に1セル移動(非rowheaderセルの場合) |
| Arrow Left | フォーカスを左に1セル移動(非rowheaderセルの場合) |
| Home | 行の最初のセルにフォーカスを移動 |
| End | 行の最後のセルにフォーカスを移動 |
| Ctrl + Home | treegridの最初のセルにフォーカスを移動 |
| Ctrl + End | treegridの最後のセルにフォーカスを移動 |
ツリー操作(rowheaderのみ)
| キー | アクション |
|---|---|
| Arrow Right (at rowheader) | 折りたたまれた親の場合: 行を展開。展開された親の場合: 最初の子のrowheaderに移動。リーフの場合: 何もしない |
| Arrow Left (at rowheader) | 展開された親の場合: 行を折りたたみ。折りたたみ済み/リーフの場合: 親のrowheaderに移動。ルートレベルで折りたたみ済みの場合: 何もしない |
行選択とセルアクティベーション
| キー | アクション |
|---|---|
| Space | 行の選択を切り替え(セル選択ではない) |
| Enter | フォーカスされたセルをアクティブ化(展開/折りたたみはしない) |
| Ctrl + A | すべての表示行を選択(複数選択可能な場合) |
重要: 非rowheaderセルでの矢印キーはフォーカスの移動のみで、展開/折りたたみは行いません。
フォーカス管理
このコンポーネントはフォーカス管理にローヴィングタブインデックスを使用します:
- ローヴィングタブインデックス - 1つのセルのみが
tabindex="0"を持つ -
tabindex="-1" - 単一のTabストップ(Tabでグリッドに入る/出る)
- フォーカス不可(tabindexなし)
- キーボードナビゲーションに含まれない
- 子にフォーカスがあった場合、親にフォーカスを移動
Gridとの主な違い
- 選択: 行選択(rowのaria-selected)vs Gridのセル選択
- rowheaderでの矢印キー: ツリーの展開/折りたたみ vs Gridのフォーカス移動
- Enterキー: セルのアクティベーションのみ(展開/折りたたみはしない)
- 階層: 行にaria-levelとaria-expandedが必須
- ナビゲーション: 折りたたまれた子はナビゲーションでスキップ
ソースコード
---
// =============================================================================
// 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;
defaultExpandedIds?: string[];
selectable?: boolean;
multiselectable?: boolean;
defaultSelectedRowIds?: string[];
defaultFocusedCellId?: string;
totalRows?: number;
totalColumns?: number;
startRowIndex?: number;
startColIndex?: number;
enablePageNavigation?: boolean;
pageSize?: number;
class?: string;
renderCell?: (cell: TreeGridCellData, rowId: string, colId: string) => string;
}
// =============================================================================
// Props
// =============================================================================
const {
columns,
nodes,
ariaLabel,
ariaLabelledby,
defaultExpandedIds = [],
selectable = false,
multiselectable = false,
defaultSelectedRowIds = [],
defaultFocusedCellId,
totalRows,
totalColumns,
startRowIndex = 2,
startColIndex = 1,
enablePageNavigation = false,
pageSize = 5,
class: className,
renderCell,
} = Astro.props;
// =============================================================================
// Flatten Tree
// =============================================================================
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 = flattenTree(nodes);
const expandedIdsSet = new Set(defaultExpandedIds);
// Determine if a row should be initially hidden
function isRowInitiallyHidden(flatRow: FlatRow): boolean {
let currentParentId = flatRow.parentId;
while (currentParentId) {
if (!expandedIdsSet.has(currentParentId)) {
return true;
}
const parent = allRows.find((r) => r.node.id === currentParentId);
currentParentId = parent?.parentId ?? null;
}
return false;
}
// Find first visible row for initial focus
const firstVisibleRow = allRows.find((row) => !isRowInitiallyHidden(row));
const initialFocusedId = defaultFocusedCellId ?? firstVisibleRow?.node.cells[0]?.id ?? null;
function getRowAriaExpanded(flatRow: FlatRow): 'true' | 'false' | undefined {
if (!flatRow.hasChildren) return undefined;
return expandedIdsSet.has(flatRow.node.id) ? 'true' : 'false';
}
function getRowAriaSelected(flatRow: FlatRow): 'true' | 'false' | undefined {
if (!selectable) return undefined;
return defaultSelectedRowIds.includes(flatRow.node.id) ? 'true' : 'false';
}
---
<apg-treegrid
class={`apg-treegrid ${className ?? ''}`}
data-enable-page-navigation={enablePageNavigation}
data-page-size={pageSize}
data-selectable={selectable}
data-multiselectable={multiselectable}
data-all-rows={JSON.stringify(allRows)}
data-columns={JSON.stringify(columns)}
>
<div
role="treegrid"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-multiselectable={multiselectable ? 'true' : undefined}
aria-rowcount={totalRows}
aria-colcount={totalColumns}
>
{/* Header Row */}
<div role="row" aria-rowindex={totalRows ? 1 : undefined}>
{
columns.map((col, colIndex) => (
<div
role="columnheader"
aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
data-col-id={col.id}
data-is-row-header={col.isRowHeader ? 'true' : undefined}
>
{col.header}
</div>
))
}
</div>
{/* Data Rows - render ALL rows, hide children of collapsed parents */}
{
allRows.map((flatRow, rowIndex) => {
const isHidden = isRowInitiallyHidden(flatRow);
return (
<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}
data-row-id={flatRow.node.id}
data-parent-id={flatRow.parentId}
data-has-children={flatRow.hasChildren ? 'true' : undefined}
data-level={flatRow.level}
style={isHidden ? 'display: none' : undefined}
>
{flatRow.node.cells.map((cell, colIndex) => {
const isRowHeader = columns[colIndex]?.isRowHeader ?? false;
const isFocused = cell.id === initialFocusedId;
const colId = columns[colIndex]?.id ?? '';
const paddingLeft = isRowHeader ? `${(flatRow.level - 1) * 20 + 8}px` : undefined;
return (
<div
role={isRowHeader ? 'rowheader' : 'gridcell'}
tabindex={isFocused ? 0 : -1}
aria-disabled={cell.disabled || flatRow.node.disabled ? 'true' : undefined}
aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
aria-colspan={cell.colspan}
data-cell-id={cell.id}
data-row-id={flatRow.node.id}
data-col-id={colId}
data-row-index={rowIndex}
data-col-index={colIndex}
data-is-row-header={isRowHeader ? 'true' : undefined}
data-disabled={cell.disabled || flatRow.node.disabled ? 'true' : undefined}
class={`apg-treegrid-cell ${isFocused ? 'focused' : ''} ${defaultSelectedRowIds.includes(flatRow.node.id) ? 'selected' : ''} ${cell.disabled || flatRow.node.disabled ? 'disabled' : ''}`}
style={paddingLeft ? `padding-left: ${paddingLeft}` : undefined}
>
{isRowHeader && 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>
)}
{renderCell ? (
<Fragment set:html={renderCell(cell, flatRow.node.id, colId)} />
) : (
cell.value
)}
</div>
);
})}
</div>
);
})
}
</div>
</apg-treegrid>
<script>
interface FlatRowData {
node: {
id: string;
cells: { id: string; value: string | number; disabled?: boolean; colspan?: number }[];
children?: FlatRowData['node'][];
disabled?: boolean;
};
level: number;
parentId: string | null;
hasChildren: boolean;
}
interface ColumnDef {
id: string;
header: string;
isRowHeader?: boolean;
}
class ApgTreeGrid extends HTMLElement {
private focusedCellId: string | null = null;
private selectedRowIds: Set<string> = new Set();
private expandedIds: Set<string> = new Set();
private enablePageNavigation = false;
private pageSize = 5;
private selectable = false;
private multiselectable = false;
private allRows: FlatRowData[] = [];
private columns: ColumnDef[] = [];
connectedCallback() {
this.enablePageNavigation = this.dataset.enablePageNavigation === 'true';
this.pageSize = parseInt(this.dataset.pageSize || '5', 10);
this.selectable = this.dataset.selectable === 'true';
this.multiselectable = this.dataset.multiselectable === 'true';
try {
this.allRows = JSON.parse(this.dataset.allRows || '[]');
this.columns = JSON.parse(this.dataset.columns || '[]');
} catch {
this.allRows = [];
this.columns = [];
}
// Find initial focused cell
const focusedCell = this.querySelector<HTMLElement>('[tabindex="0"]');
this.focusedCellId = focusedCell?.dataset.cellId ?? null;
// Load initial expanded ids from DOM
this.querySelectorAll<HTMLElement>('[aria-expanded="true"]').forEach((el) => {
const rowId = el.dataset.rowId;
if (rowId) this.expandedIds.add(rowId);
});
// Load initial selected row ids
this.querySelectorAll<HTMLElement>('[aria-selected="true"]').forEach((el) => {
const rowId = el.dataset.rowId;
if (rowId) this.selectedRowIds.add(rowId);
});
// Set tabindex="-1" on all focusable elements inside cells
this.querySelectorAll<HTMLElement>(
'[role="gridcell"] a[href], [role="gridcell"] button, [role="rowheader"] a[href], [role="rowheader"] button'
).forEach((el) => {
el.setAttribute('tabindex', '-1');
});
// Add event listeners
this.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]').forEach(
(cell) => {
cell.addEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
cell.addEventListener('focusin', this.handleFocus.bind(this) as EventListener);
}
);
}
disconnectedCallback() {
this.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]').forEach(
(cell) => {
cell.removeEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
cell.removeEventListener('focusin', this.handleFocus.bind(this) as EventListener);
}
);
}
private getVisibleRows(): HTMLElement[] {
return Array.from(this.querySelectorAll<HTMLElement>('[role="row"][aria-level]'));
}
private getColumnCount(): number {
return this.querySelectorAll('[role="columnheader"]').length;
}
private getCellAt(rowIndex: number, colIndex: number): HTMLElement | null {
const rows = this.getVisibleRows();
const row = rows[rowIndex];
if (!row) return null;
const cells = row.querySelectorAll('[role="gridcell"], [role="rowheader"]');
return cells[colIndex] as HTMLElement | null;
}
private focusCell(cell: HTMLElement) {
const currentFocused = this.querySelector('[tabindex="0"]');
if (currentFocused) {
currentFocused.setAttribute('tabindex', '-1');
currentFocused.classList.remove('focused');
}
cell.setAttribute('tabindex', '0');
cell.classList.add('focused');
const focusableChild = cell.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 {
cell.focus();
}
this.focusedCellId = cell.dataset.cellId ?? null;
}
private handleFocus(event: Event) {
const cell = event.currentTarget as HTMLElement;
const currentFocused = this.querySelector('[tabindex="0"]');
if (currentFocused && currentFocused !== cell) {
currentFocused.setAttribute('tabindex', '-1');
currentFocused.classList.remove('focused');
}
cell.setAttribute('tabindex', '0');
cell.classList.add('focused');
this.focusedCellId = cell.dataset.cellId ?? null;
}
private expandRow(rowId: string) {
const row = this.querySelector<HTMLElement>(`[role="row"][data-row-id="${rowId}"]`);
if (!row) return;
if (row.dataset.hasChildren !== 'true') return;
if (row.getAttribute('aria-disabled') === 'true') return;
if (this.expandedIds.has(rowId)) return;
this.expandedIds.add(rowId);
row.setAttribute('aria-expanded', 'true');
// Show child rows
this.updateVisibleRows();
this.dispatchEvent(
new CustomEvent('expanded-change', {
detail: { expandedIds: Array.from(this.expandedIds) },
})
);
}
private collapseRow(rowId: string, currentColIndex: number) {
const row = this.querySelector<HTMLElement>(`[role="row"][data-row-id="${rowId}"]`);
if (!row) return;
if (row.dataset.hasChildren !== 'true') return;
if (row.getAttribute('aria-disabled') === 'true') return;
if (!this.expandedIds.has(rowId)) return;
this.expandedIds.delete(rowId);
row.setAttribute('aria-expanded', 'false');
// Check if focus was on a descendant - if so, move focus to parent's cell
if (this.focusedCellId) {
const focusedCell = this.querySelector<HTMLElement>(
`[data-cell-id="${this.focusedCellId}"]`
);
if (focusedCell) {
const focusedRowId = focusedCell.dataset.rowId;
if (focusedRowId && this.isDescendantOf(focusedRowId, rowId)) {
// Move focus to parent row's same column
const cells = row.querySelectorAll<HTMLElement>(
'[role="gridcell"], [role="rowheader"]'
);
const targetCell = cells[currentColIndex] as HTMLElement | null;
if (targetCell) {
this.focusCell(targetCell);
}
}
}
}
// Hide child rows
this.updateVisibleRows();
this.dispatchEvent(
new CustomEvent('expanded-change', {
detail: { expandedIds: Array.from(this.expandedIds) },
})
);
}
private isDescendantOf(childRowId: string, parentRowId: string): boolean {
const childRow = this.allRows.find((r) => r.node.id === childRowId);
if (!childRow) return false;
let currentParentId = childRow.parentId;
while (currentParentId) {
if (currentParentId === parentRowId) return true;
const parent = this.allRows.find((r) => r.node.id === currentParentId);
currentParentId = parent?.parentId ?? null;
}
return false;
}
private updateVisibleRows() {
// Show/hide rows based on expanded state
const rows = this.querySelectorAll<HTMLElement>('[role="row"][aria-level]');
rows.forEach((row) => {
const rowId = row.dataset.rowId;
if (!rowId) return;
const flatRow = this.allRows.find((r) => r.node.id === rowId);
if (!flatRow) return;
let isHidden = false;
let currentParentId = flatRow.parentId;
while (currentParentId) {
if (!this.expandedIds.has(currentParentId)) {
isHidden = true;
break;
}
const parent = this.allRows.find((r) => r.node.id === currentParentId);
currentParentId = parent?.parentId ?? null;
}
row.style.display = isHidden ? 'none' : '';
});
}
private toggleRowSelection(rowId: string, rowDisabled: boolean) {
if (!this.selectable || rowDisabled) return;
const row = this.querySelector<HTMLElement>(`[role="row"][data-row-id="${rowId}"]`);
if (!row) return;
if (this.multiselectable) {
if (this.selectedRowIds.has(rowId)) {
this.selectedRowIds.delete(rowId);
row.setAttribute('aria-selected', 'false');
row
.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]')
.forEach((cell) => {
cell.classList.remove('selected');
});
} else {
this.selectedRowIds.add(rowId);
row.setAttribute('aria-selected', 'true');
row
.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]')
.forEach((cell) => {
cell.classList.add('selected');
});
}
} else {
// Clear previous selection
this.querySelectorAll('[aria-selected="true"]').forEach((el) => {
el.setAttribute('aria-selected', 'false');
el.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]').forEach(
(cell) => {
cell.classList.remove('selected');
}
);
});
this.selectedRowIds.clear();
if (!this.selectedRowIds.has(rowId)) {
this.selectedRowIds.add(rowId);
row.setAttribute('aria-selected', 'true');
row
.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]')
.forEach((cell) => {
cell.classList.add('selected');
});
}
}
this.dispatchEvent(
new CustomEvent('selection-change', {
detail: { selectedRowIds: Array.from(this.selectedRowIds) },
})
);
}
private selectAllVisibleRows() {
if (!this.selectable || !this.multiselectable) return;
this.getVisibleRows().forEach((row) => {
if (row.style.display === 'none') return;
if (row.getAttribute('aria-disabled') === 'true') return;
const rowId = row.dataset.rowId;
if (rowId) {
this.selectedRowIds.add(rowId);
row.setAttribute('aria-selected', 'true');
row
.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]')
.forEach((cell) => {
cell.classList.add('selected');
});
}
});
this.dispatchEvent(
new CustomEvent('selection-change', {
detail: { selectedRowIds: Array.from(this.selectedRowIds) },
})
);
}
private handleKeyDown(event: KeyboardEvent) {
const cell = event.currentTarget as HTMLElement;
const { key, ctrlKey } = event;
const { colIndex: colIndexStr, disabled, cellId, rowId, colId, isRowHeader } = cell.dataset;
const colIndex = parseInt(colIndexStr || '0', 10);
const colCount = this.getColumnCount();
const visibleRows = this.getVisibleRows().filter((r) => r.style.display !== 'none');
const currentRow = cell.closest<HTMLElement>('[role="row"]');
let handled = true;
switch (key) {
case 'ArrowRight': {
const hasChildren = currentRow?.dataset.hasChildren === 'true';
const rowDisabled = currentRow?.getAttribute('aria-disabled') === 'true';
const isExpanded = rowId ? this.expandedIds.has(rowId) : false;
if (isRowHeader === 'true' && hasChildren && !rowDisabled && !isExpanded) {
// Collapsed parent at rowheader: expand
this.expandRow(rowId!);
} else {
// Expanded parent at rowheader, leaf row at rowheader, or non-rowheader: move right
if (colIndex < colCount - 1) {
const currentRowEl = cell.closest('[role="row"]');
const cells = currentRowEl?.querySelectorAll('[role="gridcell"], [role="rowheader"]');
const nextCell = cells?.[colIndex + 1] as HTMLElement | null;
if (nextCell) this.focusCell(nextCell);
}
}
break;
}
case 'ArrowLeft': {
if (isRowHeader === 'true') {
const rowDisabled = currentRow?.getAttribute('aria-disabled') === 'true';
const isExpanded = rowId ? this.expandedIds.has(rowId) : false;
const hasChildren = currentRow?.dataset.hasChildren === 'true';
if (hasChildren && isExpanded && !rowDisabled) {
this.collapseRow(rowId!, colIndex);
} else if (currentRow?.dataset.parentId) {
// Move to parent row's same column
const parentRow = this.querySelector<HTMLElement>(
`[role="row"][data-row-id="${currentRow.dataset.parentId}"]`
);
if (parentRow && parentRow.style.display !== 'none') {
const parentCells = parentRow.querySelectorAll<HTMLElement>(
'[role="gridcell"], [role="rowheader"]'
);
const parentCell = parentCells[colIndex];
if (parentCell) this.focusCell(parentCell);
}
}
} else {
// Move left
if (colIndex > 0) {
const currentRowEl = cell.closest('[role="row"]');
const cells = currentRowEl?.querySelectorAll('[role="gridcell"], [role="rowheader"]');
const prevCell = cells?.[colIndex - 1] as HTMLElement | null;
if (prevCell) this.focusCell(prevCell);
}
}
break;
}
case 'ArrowDown': {
const visibleOnly = visibleRows.filter((r) => r.style.display !== 'none');
const actualIndex = visibleOnly.indexOf(currentRow!);
if (actualIndex < visibleOnly.length - 1) {
const nextRow = visibleOnly[actualIndex + 1];
const cells = nextRow.querySelectorAll('[role="gridcell"], [role="rowheader"]');
const nextCell = cells[colIndex] as HTMLElement | null;
if (nextCell) this.focusCell(nextCell);
}
break;
}
case 'ArrowUp': {
const visibleOnly = visibleRows.filter((r) => r.style.display !== 'none');
const actualIndex = visibleOnly.indexOf(currentRow!);
if (actualIndex > 0) {
const prevRow = visibleOnly[actualIndex - 1];
const cells = prevRow.querySelectorAll('[role="gridcell"], [role="rowheader"]');
const prevCell = cells[colIndex] as HTMLElement | null;
if (prevCell) this.focusCell(prevCell);
}
break;
}
case 'Home': {
if (ctrlKey) {
const firstCell = this.getCellAt(0, 0);
if (firstCell) this.focusCell(firstCell);
} else {
const currentRowEl = cell.closest('[role="row"]');
const cells = currentRowEl?.querySelectorAll('[role="gridcell"], [role="rowheader"]');
const firstCell = cells?.[0] as HTMLElement | null;
if (firstCell) this.focusCell(firstCell);
}
break;
}
case 'End': {
if (ctrlKey) {
const visibleOnly = visibleRows.filter((r) => r.style.display !== 'none');
const lastRow = visibleOnly[visibleOnly.length - 1];
const cells = lastRow?.querySelectorAll('[role="gridcell"], [role="rowheader"]');
const lastCell = cells?.[cells.length - 1] as HTMLElement | null;
if (lastCell) this.focusCell(lastCell);
} else {
const currentRowEl = cell.closest('[role="row"]');
const cells = currentRowEl?.querySelectorAll('[role="gridcell"], [role="rowheader"]');
const lastCell = cells?.[cells.length - 1] as HTMLElement | null;
if (lastCell) this.focusCell(lastCell);
}
break;
}
case 'PageDown': {
if (this.enablePageNavigation) {
const visibleOnly = visibleRows.filter((r) => r.style.display !== 'none');
const actualIndex = visibleOnly.indexOf(currentRow!);
const targetIndex = Math.min(actualIndex + this.pageSize, visibleOnly.length - 1);
const targetRow = visibleOnly[targetIndex];
const cells = targetRow?.querySelectorAll('[role="gridcell"], [role="rowheader"]');
const targetCell = cells?.[colIndex] as HTMLElement | null;
if (targetCell) this.focusCell(targetCell);
} else {
handled = false;
}
break;
}
case 'PageUp': {
if (this.enablePageNavigation) {
const visibleOnly = visibleRows.filter((r) => r.style.display !== 'none');
const actualIndex = visibleOnly.indexOf(currentRow!);
const targetIndex = Math.max(actualIndex - this.pageSize, 0);
const targetRow = visibleOnly[targetIndex];
const cells = targetRow?.querySelectorAll('[role="gridcell"], [role="rowheader"]');
const targetCell = cells?.[colIndex] as HTMLElement | null;
if (targetCell) this.focusCell(targetCell);
} else {
handled = false;
}
break;
}
case ' ': {
const rowDisabled = currentRow?.getAttribute('aria-disabled') === 'true';
this.toggleRowSelection(rowId!, rowDisabled);
break;
}
case 'Enter': {
// Enter activates cell, does NOT expand/collapse
if (disabled !== 'true') {
this.dispatchEvent(
new CustomEvent('cell-activate', {
detail: { cellId, rowId, colId },
})
);
}
break;
}
case 'a': {
if (ctrlKey) {
this.selectAllVisibleRows();
} else {
handled = false;
}
break;
}
default:
handled = false;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}
}
customElements.define('apg-treegrid', ApgTreeGrid);
</script> 使い方
---
import TreeGrid from './TreeGrid.astro';
const columns = [
{ id: 'name', header: '名前', isRowHeader: true },
{ id: 'size', header: 'サイズ' },
];
const nodes = [
{
id: 'folder1',
cells: [
{ id: 'folder1-name', value: 'ドキュメント' },
{ id: 'folder1-size', value: '--' },
],
children: [
{
id: 'file1',
cells: [
{ id: 'file1-name', value: 'レポート.pdf' },
{ id: 'file1-size', value: '2.5 MB' },
],
},
],
},
];
---
<TreeGrid
columns={columns}
nodes={nodes}
ariaLabel="ファイルブラウザ"
selectable
multiselectable
initialExpandedIds={['folder1']}
/> API
Props
| Prop | 型 | 説明 |
|---|---|---|
columns | TreeGridColumnDef[] | 列定義 |
nodes | TreeGridNodeData[] | 階層ノードデータ |
ariaLabel | string | アクセシブルな名前 |
initialExpandedIds | string[] | 初期展開される行ID |
selectable | boolean | 行選択を有効化 |
multiselectable | boolean | 複数行選択を有効化 |
注:Astro実装はクライアントサイドのインタラクティビティにWeb Componentsを使用します。
テスト
テストは、キーボードインタラクション、ARIA属性、アクセシビリティ要件全体でAPG準拠を検証します。TreeGridコンポーネントはGridとTreeViewのテスト戦略を組み合わせています。
テスト戦略
ユニットテスト(Testing Library)
フレームワーク固有のTesting Libraryユーティリティを使用してコンポーネントのレンダリングとインタラクションを検証します。これらのテストはコンポーネントの分離された動作を確認します。
- HTML構造と要素の階層(treegrid、row、rowheader、gridcell)
- 初期属性値(role、aria-label、aria-level、aria-expanded)
- 選択状態の変更(行のaria-selected)
- 階層深度インジケーター(aria-level)
- CSSクラスの適用
E2Eテスト(Playwright)
4つのフレームワークすべてで実際のブラウザ環境でコンポーネントの動作を検証します。これらのテストは完全なブラウザコンテキストが必要なインタラクションをカバーします。
- 2Dキーボードナビゲーション(矢印キー)
- rowheaderでのツリー操作(展開/折りたたみのArrowRight/Left)
- 拡張ナビゲーション(Home、End、Ctrl+Home、Ctrl+End)
- Spaceでの行選択
- フォーカス管理とローヴィングタブインデックス
- 非表示行の処理(折りたたまれた子)
- フレームワーク間の一貫性
テストカテゴリ
高優先度: APG ARIA属性 ( Unit + E2E )
| テスト | 説明 |
|---|---|
role="treegrid" | コンテナにtreegridロールがある |
role="row" | すべての行にrowロールがある |
role="rowheader" | 行の最初のセルにrowheaderロールがある |
role="gridcell" | 他のセルにgridcellロールがある |
role="columnheader" | ヘッダーセルにcolumnheaderロールがある |
aria-label | TreeGridにaria-label経由のアクセシブルな名前がある |
aria-level | すべての行にaria-level(1始まりの深さ)がある |
aria-expanded | 親行にaria-expanded(true/false)がある |
aria-selected on row | 行要素に選択がある(セルではない) |
aria-multiselectable | 複数選択が有効な場合に存在 |
高優先度: ツリー操作(Rowheaderで) ( E2E )
| テスト | 説明 |
|---|---|
ArrowRight expands | 折りたたまれた親行を展開 |
ArrowRight to child | 既に展開されている場合、最初の子に移動 |
ArrowLeft collapses | 展開された親行を折りたたみ |
ArrowLeft to parent | 折りたたみまたはリーフの場合、親行に移動 |
Enter activates only | Enterは展開/折りたたみをしない(Treeとは異なる) |
Children hidden | 親が折りたたまれると子行が非表示 |
高優先度: 2Dキーボードナビゲーション ( E2E )
| テスト | 説明 |
|---|---|
ArrowRight (non-rowheader) | フォーカスを右に1セル移動 |
ArrowLeft (non-rowheader) | フォーカスを左に1セル移動 |
ArrowDown | 次の表示行にフォーカスを移動 |
ArrowUp | 前の表示行にフォーカスを移動 |
Skip hidden rows | ArrowDown/Upは折りたたまれた子をスキップ |
高優先度: 拡張ナビゲーション ( E2E )
| テスト | 説明 |
|---|---|
Home | 行の最初のセルにフォーカスを移動 |
End | 行の最後のセルにフォーカスを移動 |
Ctrl+Home | 最初の表示行の最初のセルにフォーカスを移動 |
Ctrl+End | 最後の表示行の最後のセルにフォーカスを移動 |
高優先度: フォーカス管理(ローヴィングタブインデックス) ( Unit + E2E )
| テスト | 説明 |
|---|---|
tabindex="0" | 最初のフォーカス可能なセルにtabindex="0"がある |
tabindex="-1" | 他のセルにtabindex="-1"がある |
Headers not focusable | columnheaderセルにtabindexがない |
Tab exits treegrid | Tabでtreegridからフォーカスが外れる |
Focus update | ナビゲーション時にフォーカスされたセルのtabindexが更新される |
高優先度: 行選択 ( E2E )
| テスト | 説明 |
|---|---|
Space toggles row | Spaceで行の選択を切り替え(セルではない) |
Single select | 単一選択ではSpaceで前の選択をクリア |
Multi select | 複数選択では複数行を選択可能 |
Enter activates cell | Enterでセルをアクティブ化 |
中優先度: アクセシビリティ ( E2E )
| テスト | 説明 |
|---|---|
axe-core | アクセシビリティ違反がない |
テストコード例
以下は実際のE2Eテストファイルです (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.
*/
async function focusCell(_page: Page, cell: Locator): Promise<void> {
await cell.click({ position: { x: 5, y: 5 } });
}
/**
* 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();
await focusCell(page, firstRowheader);
await page.keyboard.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);
await focusCell(page, secondRowheader);
await page.keyboard.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();
await focusCell(page, firstRowheader);
await page.keyboard.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();
await focusCell(page, firstCell);
await page.keyboard.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);
await focusCell(page, secondCell);
await page.keyboard.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);
await focusCell(page, secondCell);
await page.keyboard.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();
await focusCell(page, firstRowheader);
await page.keyboard.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();
await focusCell(page, lastCell);
await page.keyboard.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();
await focusCell(page, firstRowheader);
await page.keyboard.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');
await focusCell(page, rowheader);
await page.keyboard.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();
await focusCell(page, rowheader);
await page.keyboard.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');
await focusCell(page, rowheader);
await page.keyboard.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');
await focusCell(page, rowheader);
await page.keyboard.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');
await focusCell(page, rowheader);
await page.keyboard.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();
await focusCell(page, rowheader);
// ArrowRight at expanded rowheader should move to the next cell (not expand again)
await page.keyboard.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();
await focusCell(page, firstCell);
await page.keyboard.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');
await focusCell(page, rowheader);
await page.keyboard.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');
await focusCell(page, rowheader);
await page.keyboard.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');
await focusCell(page, rowheader);
// Select
await page.keyboard.press('Space');
await expect(row).toHaveAttribute('aria-selected', 'true');
// Deselect
await page.keyboard.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
await focusCell(page, firstCell);
await expect(firstCell).toHaveAttribute('tabindex', '0');
await page.keyboard.press('ArrowRight');
// After navigation, tabindex should update
await expect(firstCell).toHaveAttribute('tabindex', '-1');
await expect(secondCell).toHaveAttribute('tabindex', '0');
});
});
});
} テストの実行
# TreeGridのユニットテストを実行
npm run test -- treegrid
# TreeGridのE2Eテストを実行(全フレームワーク)
npm run test:e2e:pattern --pattern=treegrid
# 特定フレームワークのE2Eテストを実行
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
Gridとの主な違い
- 選択対象: TreeGridは行を選択(rowのaria-selected)、Gridはセルを選択
- rowheaderでの矢印の動作: ArrowRight/Leftはツリー操作を行い、セルナビゲーションは行わない
- 階層: aria-levelの値と親子関係をテストする必要がある
- 非表示行: ナビゲーション中に折りたたまれた子をスキップする必要がある
テストツール
- Vitest (opens in new tab) - ユニットテスト用テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ(React、Vue、Svelte)
- Playwright (opens in new tab) - E2Eテスト用ブラウザ自動化
- axe-core/playwright (opens in new tab) - E2Eでの自動アクセシビリティテスト
完全なドキュメントは testing-strategy.md (opens in new tab) を参照してください。
リソース
- WAI-ARIA APG: TreeGrid パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist