APG Patterns
English
English

TreeGrid

Gridの2Dナビゲーションと、TreeViewの展開可能な行を組み合わせた階層データグリッド。

デモ

矢印キーで移動。rowheaderでArrowRight/Leftで展開/折りたたみ。Spaceで行を選択。

名前
サイズ
更新日
ドキュメント
--
2024-01-15
Q4レポート.pdf
2.5 MB
2024-01-10
年次報告書.pdf
5.2 MB
2024-01-05
旅行.jpg
3.1 MB
2024-01-08
アプリインストーラー.exe
125 MB
2024-01-12
README.md
4 KB
2024-01-01

デモのみ表示 →

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が必須
  • ナビゲーション: 折りたたまれた子はナビゲーションでスキップ

ソースコード

TreeGrid.astro
---
// =============================================================================
// 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).

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の値と親子関係をテストする必要がある
  • 非表示行: ナビゲーション中に折りたたまれた子をスキップする必要がある

テストツール

完全なドキュメントは testing-strategy.md (opens in new tab) を参照してください。

リソース