APG Patterns
English
English

Grid

キーボードナビゲーション、セル選択、アクティベーションを備えたインタラクティブな2Dデータグリッド。

デモ

矢印キーでセル間を移動。Spaceでセルを選択/選択解除。Enterでセルをアクティベート。

Use arrow keys to navigate between cells. Press Space to select/deselect cells. Press Enter to activate a cell.

Name
Email
Role
Status
alice@example.com
Admin
Active
bob@example.com
Editor
Active
charlie@example.com
Viewer
Inactive
diana@example.com
Admin
Active
eve@example.com
Editor
Active

デモのみ表示 →

アクセシビリティ

ネイティブHTMLとGridロール

grid ロールは、2Dキーボードナビゲーションを備えたインタラクティブなデータグリッドを作成します。静的なデータテーブルには、代わりにネイティブの <table>要素を使用してください。grid は以下の場合に使用します:

  • セルがフォーカス可能でインタラクティブ(編集可能、選択可能、またはウィジェットを含む)
  • インタラクティブ性のない静的データ表示
  • スプレッドシートやデータグリッドに類似したインターフェース

WAI-ARIA ロール

ロール 対象要素 説明
grid コンテナ グリッドコンテナ(複合ウィジェット)
row 行コンテナ セルを水平方向にグループ化
columnheader ヘッダーセル 列ヘッダー(この実装ではフォーカス不可)
rowheader 行ヘッダーセル 行ヘッダー(オプション)
gridcell データセル インタラクティブセル(フォーカス可能)

WAI-ARIA grid role (opens in new tab)

WAI-ARIA プロパティ(グリッドコンテナ)

属性 必須 説明
role="grid" - はい コンテナをグリッドとして識別
aria-label String はい* グリッドのアクセシブルな名前
aria-labelledby ID reference はい* aria-labelの代替
aria-multiselectable true いいえ 複数選択モード時のみ存在
aria-rowcount 数値 いいえ 総行数(仮想化用)
aria-colcount 数値 いいえ 総列数(仮想化用)

* グリッドコンテナには aria-label または aria-labelledby のいずれかが必須です。

WAI-ARIA ステート(グリッドセル)

属性 必須 説明
tabindex 0 | -1 はい フォーカス管理用のroving tabindex
aria-selected true | false いいえ* グリッドが選択をサポートする場合に存在。選択をサポートする場合、すべてのgridcellにaria-selectedが必要。
aria-disabled true いいえ* セルが無効であることを示す
aria-rowindex 数値 いいえ* 行位置(仮想化用)
aria-colindex 数値 いいえ* 列位置(仮想化用)

* 選択をサポートする場合、すべてのgridcellにaria-selectedが必要です。

キーボードサポート

2Dナビゲーション

キー アクション
フォーカスを右のセルに移動
フォーカスを左のセルに移動
フォーカスを下の行に移動
フォーカスを上の行に移動
Home フォーカスを行の最初のセルに移動
End フォーカスを行の最後のセルに移動
Ctrl + Home フォーカスをグリッドの最初のセルに移動
Ctrl + End フォーカスをグリッドの最後のセルに移動
PageDown フォーカスをページサイズ分下に移動(デフォルト5)
PageUp フォーカスをページサイズ分上に移動(デフォルト5)

選択とアクティベーション

キー アクション
Space フォーカス中のセルを選択/選択解除(選択可能時)
Enter フォーカス中のセルをアクティベート(onCellActivateをトリガー)

フォーカス管理

このコンポーネントはフォーカス管理にroving tabindexを使用します:

  • 1つのセルのみがtabindex="0"(フォーカス中のセル)を持ち、他のすべてのセルはtabindex="-1"を持つ
  • グリッドは単一のTabストップ(Tabでグリッドに入り、Shift+Tabで離脱)
  • ヘッダーセル(columnheader)はフォーカス不可(この実装ではソート機能なし)
  • データ行のgridcellのみがキーボードナビゲーションに含まれる
  • グリッドを離れて再入場した際、最後にフォーカスされたセルが記憶される

無効化セル

  • aria-disabled="true"を持つ
  • フォーカス可能(キーボードナビゲーションに含まれる)
  • 選択またはアクティベートできない
  • 視覚的に区別される(例:グレーアウト)

ソースコード

Grid.svelte
<script lang="ts">
  import { SvelteMap } from 'svelte/reactivity';

  // =============================================================================
  // Types
  // =============================================================================

  export interface GridCellData {
    id: string;
    value: string | number;
    disabled?: boolean;
    colspan?: number;
    rowspan?: number;
  }

  export interface GridColumnDef {
    id: string;
    header: string;
    colspan?: number;
  }

  export interface GridRowData {
    id: string;
    cells: GridCellData[];
    hasRowHeader?: boolean;
    disabled?: boolean;
  }

  interface Props {
    columns: GridColumnDef[];
    rows: GridRowData[];
    ariaLabel?: string;
    ariaLabelledby?: string;
    selectable?: boolean;
    multiselectable?: boolean;
    selectedIds?: string[];
    defaultSelectedIds?: string[];
    defaultFocusedId?: string;
    totalColumns?: number;
    totalRows?: number;
    startRowIndex?: number;
    startColIndex?: number;
    wrapNavigation?: boolean;
    enablePageNavigation?: boolean;
    pageSize?: number;
    onSelectionChange?: (selectedIds: string[]) => void;
    onFocusChange?: (focusedId: string | null) => void;
    onCellActivate?: (cellId: string, rowId: string, colId: string) => void;
    renderCell?: (cell: GridCellData, rowId: string, colId: string) => string | number;
  }

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

  let {
    columns,
    rows,
    ariaLabel,
    ariaLabelledby,
    selectable = false,
    multiselectable = false,
    selectedIds: controlledSelectedIds,
    defaultSelectedIds = [],
    defaultFocusedId,
    totalColumns,
    totalRows,
    startRowIndex = 1,
    startColIndex = 1,
    wrapNavigation = false,
    enablePageNavigation = false,
    pageSize = 5,
    onSelectionChange,
    onFocusChange,
    onCellActivate,
    renderCell,
  }: Props = $props();

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

  let internalSelectedIds = $state<string[]>([]);
  let focusedIdState = $state<string | null>(null);
  let initialized = $state(false);

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

  // Compute effective focused ID (use state if set, otherwise derive from props)
  const focusedId = $derived(focusedIdState ?? defaultFocusedId ?? rows[0]?.cells[0]?.id ?? null);

  // Initialize selection state on mount
  $effect(() => {
    if (!initialized && rows.length > 0) {
      internalSelectedIds = defaultSelectedIds ? [...defaultSelectedIds] : [];
      initialized = true;
    }
  });

  // Set tabindex="-1" on all focusable elements inside grid cells
  // This ensures Tab exits the grid instead of moving between widgets
  $effect(() => {
    if (gridRef && rows.length > 0) {
      const focusableElements = gridRef.querySelectorAll<HTMLElement>(
        '[role="gridcell"] a[href], [role="gridcell"] button, [role="rowheader"] a[href], [role="rowheader"] button'
      );
      focusableElements.forEach((el) => {
        el.setAttribute('tabindex', '-1');
      });
    }
  });

  // Svelte action to register cell refs
  function registerCell(node: HTMLDivElement, cellId: string) {
    cellRefs.set(cellId, node);
    return {
      destroy() {
        cellRefs.delete(cellId);
      },
    };
  }

  // =============================================================================
  // Derived
  // =============================================================================

  const selectedIds = $derived(controlledSelectedIds ?? internalSelectedIds);

  // Map cellId to cell info for O(1) lookup
  const cellById = $derived.by(() => {
    const map = new SvelteMap<
      string,
      { rowIndex: number; colIndex: number; cell: GridCellData; rowId: string }
    >();
    rows.forEach((row, rowIndex) => {
      row.cells.forEach((cell, colIndex) => {
        map.set(cell.id, { rowIndex, colIndex, cell, rowId: row.id });
      });
    });
    return map;
  });

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

  function getCellPosition(cellId: string) {
    const entry = cellById.get(cellId);
    if (!entry) {
      return null;
    }
    const { rowIndex, colIndex } = entry;
    return { rowIndex, colIndex };
  }

  function getCellAt(rowIndex: number, colIndex: number) {
    const cell = rows[rowIndex]?.cells[colIndex];
    if (!cell) {
      return undefined;
    }
    return cellById.get(cell.id);
  }

  function setFocusedId(id: string | null) {
    focusedIdState = id;
    onFocusChange?.(id);
  }

  function focusCell(cellId: string) {
    const cellEl = cellRefs.get(cellId);
    if (cellEl) {
      // Check if cell contains a focusable element (link, button, etc.)
      // Per APG: when cell contains a single widget, focus should be on the widget
      const focusableChild = cellEl.querySelector<HTMLElement>(
        'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
      );
      if (focusableChild) {
        // Set tabindex="-1" so Tab skips this element and exits the grid
        // The widget can still receive programmatic focus
        focusableChild.setAttribute('tabindex', '-1');
        focusableChild.focus();
      } else {
        cellEl.focus();
      }
      setFocusedId(cellId);
    }
  }

  function findNextFocusableCell(
    startRow: number,
    startCol: number,
    direction: 'right' | 'left' | 'up' | 'down',
    skipDisabled = true
  ): { rowIndex: number; colIndex: number; cell: GridCellData } | null {
    const colCount = columns.length;
    const rowCount = rows.length;

    let rowIdx = startRow;
    let colIdx = startCol;

    const step = () => {
      switch (direction) {
        case 'right':
          colIdx++;
          if (colIdx >= colCount) {
            if (wrapNavigation) {
              colIdx = 0;
              rowIdx++;
              if (rowIdx >= rowCount) return false;
            } else {
              return false;
            }
          }
          break;
        case 'left':
          colIdx--;
          if (colIdx < 0) {
            if (wrapNavigation) {
              colIdx = colCount - 1;
              rowIdx--;
              if (rowIdx < 0) return false;
            } else {
              return false;
            }
          }
          break;
        case 'down':
          rowIdx++;
          if (rowIdx >= rowCount) return false;
          break;
        case 'up':
          rowIdx--;
          if (rowIdx < 0) return false;
          break;
      }
      return true;
    };

    if (!step()) return null;

    let iterations = 0;
    const maxIterations = colCount * rowCount;

    while (iterations < maxIterations) {
      const entry = getCellAt(rowIdx, colIdx);
      if (entry && (!skipDisabled || !entry.cell.disabled)) {
        return { rowIndex: rowIdx, colIndex: colIdx, cell: entry.cell };
      }
      if (!step()) break;
      iterations++;
    }

    return null;
  }

  function setSelectedIds(ids: string[]) {
    internalSelectedIds = ids;
    onSelectionChange?.(ids);
  }

  function toggleSelection(cellId: string, cell: GridCellData) {
    if (!selectable || cell.disabled) {
      return;
    }

    if (multiselectable) {
      const newIds = selectedIds.includes(cellId)
        ? selectedIds.filter((id) => id !== cellId)
        : [...selectedIds, cellId];
      setSelectedIds(newIds);
    } else {
      const newIds = selectedIds.includes(cellId) ? [] : [cellId];
      setSelectedIds(newIds);
    }
  }

  function selectAll() {
    if (!selectable || !multiselectable) {
      return;
    }

    const allIds = Array.from(cellById.values())
      .filter(({ cell }) => !cell.disabled)
      .map(({ cell }) => cell.id);
    setSelectedIds(allIds);
  }

  function handleKeyDown(event: KeyboardEvent, cell: GridCellData, rowId: string, colId: string) {
    const pos = getCellPosition(cell.id);
    if (!pos) {
      return;
    }

    const { rowIndex, colIndex } = pos;
    const { key, ctrlKey } = event;
    let handled = true;

    switch (key) {
      case 'ArrowRight': {
        const next = findNextFocusableCell(rowIndex, colIndex, 'right');
        if (next) focusCell(next.cell.id);
        break;
      }
      case 'ArrowLeft': {
        const next = findNextFocusableCell(rowIndex, colIndex, 'left');
        if (next) focusCell(next.cell.id);
        break;
      }
      case 'ArrowDown': {
        const next = findNextFocusableCell(rowIndex, colIndex, 'down');
        if (next) focusCell(next.cell.id);
        break;
      }
      case 'ArrowUp': {
        const next = findNextFocusableCell(rowIndex, colIndex, 'up');
        if (next) focusCell(next.cell.id);
        break;
      }
      case 'Home': {
        if (ctrlKey) {
          const firstCell = rows[0]?.cells[0];
          if (firstCell) focusCell(firstCell.id);
        } else {
          const firstCellInRow = rows[rowIndex]?.cells[0];
          if (firstCellInRow) focusCell(firstCellInRow.id);
        }
        break;
      }
      case 'End': {
        if (ctrlKey) {
          const lastRow = rows[rows.length - 1];
          const lastCell = lastRow?.cells[lastRow.cells.length - 1];
          if (lastCell) focusCell(lastCell.id);
        } else {
          const currentRow = rows[rowIndex];
          const lastCellInRow = currentRow?.cells[currentRow.cells.length - 1];
          if (lastCellInRow) focusCell(lastCellInRow.id);
        }
        break;
      }
      case 'PageDown': {
        if (enablePageNavigation) {
          const targetRowIndex = Math.min(rowIndex + pageSize, rows.length - 1);
          const targetCell = rows[targetRowIndex]?.cells[colIndex];
          if (targetCell) focusCell(targetCell.id);
        } else {
          handled = false;
        }
        break;
      }
      case 'PageUp': {
        if (enablePageNavigation) {
          const targetRowIndex = Math.max(rowIndex - pageSize, 0);
          const targetCell = rows[targetRowIndex]?.cells[colIndex];
          if (targetCell) focusCell(targetCell.id);
        } else {
          handled = false;
        }
        break;
      }
      case ' ': {
        toggleSelection(cell.id, cell);
        break;
      }
      case 'Enter': {
        if (!cell.disabled) {
          onCellActivate?.(cell.id, rowId, colId);
        }
        break;
      }
      case 'a': {
        if (ctrlKey) {
          selectAll();
        } else {
          handled = false;
        }
        break;
      }
      default:
        handled = false;
    }

    if (handled) {
      event.preventDefault();
      event.stopPropagation();
    }
  }
</script>

<div
  bind:this={gridRef}
  role="grid"
  aria-label={ariaLabel}
  aria-labelledby={ariaLabelledby}
  aria-multiselectable={multiselectable ? 'true' : undefined}
  aria-rowcount={totalRows}
  aria-colcount={totalColumns}
  class="apg-grid"
>
  <!-- Header Row -->
  <div role="row" aria-rowindex={totalRows ? 1 : undefined}>
    {#each columns as col, colIndex (col.id)}
      <div
        role="columnheader"
        aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
        aria-colspan={col.colspan}
      >
        {col.header}
      </div>
    {/each}
  </div>

  <!-- Data Rows -->
  {#each rows as row, rowIndex (row.id)}
    <div role="row" aria-rowindex={totalRows ? startRowIndex + rowIndex : undefined}>
      {#each row.cells as cell, colIndex (cell.id)}
        {@const isRowHeader = row.hasRowHeader && colIndex === 0}
        {@const isFocused = cell.id === focusedId}
        {@const isSelected = selectedIds.includes(cell.id)}
        {@const colId = columns[colIndex]?.id ?? ''}
        <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
        <div
          role={isRowHeader ? 'rowheader' : 'gridcell'}
          tabindex={isFocused ? 0 : -1}
          aria-selected={selectable ? (isSelected ? 'true' : 'false') : undefined}
          aria-disabled={cell.disabled ? 'true' : undefined}
          aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
          aria-colspan={cell.colspan}
          aria-rowspan={cell.rowspan}
          class="apg-grid-cell"
          class:focused={isFocused}
          class:selected={isSelected}
          class:disabled={cell.disabled}
          onkeydown={(e) => handleKeyDown(e, cell, row.id, colId)}
          onfocusin={() => setFocusedId(cell.id)}
          use:registerCell={cell.id}
        >
          {#if renderCell}
            <!-- eslint-disable-next-line svelte/no-at-html-tags -- renderCell returns sanitized HTML from the consuming application -->
            {@html renderCell(cell, row.id, colId)}
          {:else}
            {cell.value}
          {/if}
        </div>
      {/each}
    </div>
  {/each}
</div>

使い方

使用例
<script lang="ts">
  import Grid from './Grid.svelte';

  const columns = [
    { id: 'name', header: '名前' },
    { id: 'email', header: 'メール' },
    { id: 'role', header: '役割' },
  ];

  const rows = [
    {
      id: 'user1',
      cells: [
        { id: 'user1-0', value: '田中太郎' },
        { id: 'user1-1', value: 'tanaka@example.com' },
        { id: 'user1-2', value: '管理者' },
      ],
    },
  ];

  let selectedIds = $state<string[]>([]);

  function handleSelectionChange(ids: string[]) {
    selectedIds = ids;
  }
</script>

<Grid
  {columns}
  {rows}
  ariaLabel="ユーザー一覧"
  selectable
  multiselectable
  {selectedIds}
  onSelectionChange={handleSelectionChange}
/>

API

Grid Props

Prop デフォルト 説明
columns GridColumnDef[] 必須 列定義
rows GridRowData[] 必須 行データ
ariaLabel string - グリッドのアクセシブルな名前
selectable boolean false セル選択を有効化
multiselectable boolean false 複数セル選択を有効化

テスト

テストはキーボード操作、ARIA属性、アクセシビリティ要件にわたるAPG準拠を検証します。Gridコンポーネントは2層のテスト戦略を採用しています。

テスト戦略

ユニットテスト (Testing Library)

フレームワーク固有のTesting Libraryユーティリティを使用して、コンポーネントのレンダリングとインタラクションを検証します。これらのテストはコンポーネントの動作を分離して確認します。

  • HTML構造と要素階層(grid, row, gridcell)
  • 初期属性値(role, aria-label, tabindex)
  • 選択状態の変更(aria-selected)
  • CSSクラスの適用

E2Eテスト (Playwright)

4つのすべてのフレームワークで、実際のブラウザ環境でコンポーネントの動作を検証します。これらのテストはフルブラウザコンテキストを必要とするインタラクションをカバーします。

  • 2Dキーボードナビゲーション(矢印キー)
  • 拡張ナビゲーション(Home, End, Ctrl+Home, Ctrl+End)
  • ページナビゲーション(PageUp, PageDown)
  • セルの選択とアクティベーション
  • フォーカス管理とroving tabindex
  • クロスフレームワークの一貫性

テストカテゴリー

高優先度 : APG ARIA属性

テスト 説明
role="grid" コンテナがgridロールを持つ
role="row" すべての行がrowロールを持つ
role="gridcell" データセルがgridcellロールを持つ
role="columnheader" ヘッダーセルがcolumnheaderロールを持つ
role="rowheader" 行ヘッダーセルがrowheaderロールを持つ(該当時)
aria-label グリッドがaria-labelでアクセシブルな名前を持つ
aria-labelledby グリッドがaria-labelledbyでアクセシブルな名前を持つ
aria-multiselectable 複数選択が有効な場合に存在
aria-selected 選択が有効な場合、すべてのセルに存在
aria-disabled 無効なセルに存在

高優先度 : 2Dキーボードナビゲーション

テスト 説明
ArrowRight フォーカスを右に1セル移動
ArrowLeft フォーカスを左に1セル移動
ArrowDown フォーカスを下に1行移動
ArrowUp フォーカスを上に1行移動
ArrowUp at first row 最初のデータ行で停止(ヘッダーには移動しない)
ArrowRight at row end 行末で停止(デフォルト)または折り返し(wrapNavigation)

高優先度 : 拡張ナビゲーション

テスト 説明
Home フォーカスを行の最初のセルに移動
End フォーカスを行の最後のセルに移動
Ctrl+Home フォーカスをグリッドの最初のセルに移動
Ctrl+End フォーカスをグリッドの最後のセルに移動
PageDown フォーカスをページサイズ分下に移動
PageUp フォーカスをページサイズ分上に移動

高優先度 : フォーカス管理 (Roving Tabindex)

テスト 説明
tabindex="0" 最初のフォーカス可能なセルがtabindex="0"を持つ
tabindex="-1" 他のセルがtabindex="-1"を持つ
Headers not focusable columnheaderセルにtabindexがない(フォーカス不可)
Tab exits grid Tabでフォーカスがグリッド外に移動
Focus update ナビゲーション時にフォーカスセルのtabindexが更新される
Disabled cells 無効セルはフォーカス可能だがアクティベート不可

高優先度 : 選択

テスト 説明
Space toggles Spaceでセル選択をトグル(選択可能時)
Single select 単一選択モードではSpaceで前の選択をクリア
Multi select 複数選択モードでは複数セルを選択可能
Enter activates Enterでセルをアクティベート
Disabled no select Spaceで無効セルは選択できない
Disabled no activate Enterで無効セルはアクティベートできない

中優先度 : 仮想化サポート

テスト 説明
aria-rowcount totalRows指定時に存在
aria-colcount totalColumns指定時に存在
aria-rowindex 仮想化時に行/セルに存在
aria-colindex 仮想化時にセルに存在

中優先度 : アクセシビリティ

テスト 説明
axe-core アクセシビリティ違反がないこと

テストツール

詳細は testing-strategy.md (opens in new tab) を参照してください。

リソース