# キーボードで開けず、階層も伝わらないツリービュー

Source: https://easeweb.dev/learn/tree-views
Topics: 複雑なウィジェット > ツリービューが階層の深さを伝え、矢印キーで開閉できる (WCAG 1.3.1, 4.1.2, 2.1.1)
The fix: サイドバーを、入れ子にしたリンクのリストにし、各セクションを aria-expanded で開閉状態を伝えるボタンで開くか、完全なARIAのツリービューを作ります。後者では、role="tree"、treeitem、groupをセクションのとおりに入れ子にし、各セクションに aria-expanded を付け、Tabの移動先を1つにして矢印キーで動かします。平らに描くツリーでは、すべての項目に aria-level、aria-setsize、aria-posinset も付けます。
Test with: keyboard, screen reader, forced colors, zoom
References: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/ https://www.w3.org/WAI/ARIA/apg/patterns/treeview/examples/treeview-navigation/ https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/ https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/ https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/TR/wai-aria-1.2/#tree

よくある問題を再現した学習用のサンプルです。実在のクライアントの診断結果ではありません。コードは出発点となる実装例のため、実際の製品でも確認してください。ここでは支援技術でのテスト結果は報告していません。

## 場面

あるアプリのヘルプセンターには、トピックを「はじめに」「請求」「アカウント」の3つのセクションに分けたサイドバーがあります。セクションをクリックすると、その下にページが字下げされて開き、矢印が下を向きます。「請求」の中の「支払い方法」もセクションで、1段深いところに「カード」と「銀行振込」があります。セクションは、クリック処理を付けたdivです。マウスを使わずにTabを押すと、たまたま表示されているリンク、「プランと料金」と「請求書」を順に移動し、サイドバーを出て「サポートに問い合わせる」へ進みます。「はじめに」「支払い方法」「アカウント」は開けないため、その中のページにはたどり着けません。スクリーンリーダーには表示中のリンクが次々に読まれるだけで、それが「請求」の中にあることは伝わらず、セクション名は開けることを示すものがないただの文字として読まれます。

そこで、チームは手早い方法でサイドバーをツリーにします。サイドバーに `role="tree"`、すべてのセクションとリンクに `role="treeitem"` を付け、それ以外は何もしません。キーボードにとっては何も変わらず、Tabキーは今もすべてのセクションを飛ばします。スクリーンリーダーはサイドバーをツリーと伝えるようになりますが、その中で矢印キーは何もせず、すべての項目が「請求書、ツリーアイテム、階層1」のように階層1で読まれ、どのセクションも開いているかどうかを伝えません。

## 確認できる問題

達成基準2.1.1「キーボード」は、マウスでできることはすべてキーボードでもできることを求めています。セクションを開く方法は、フォーカスを受けないdivのクリックだけのため、閉じたセクションの中のページにはマウスなしでは届きません。ツリーのロールを足しても、ここは何も変わりません。セクションは今もフォーカスを受けず、ツリーが約束する矢印キーは何もしません。

達成基準4.1.2「名前（name）・役割（role）及び値（value）」は、操作部品が役割と状態を公開することを求めています。divのセクションはどちらも公開していません。押せることを示す役割も、開閉の状態もありません。ツリーのセクションは役割を持っていても `aria-expanded` がないため、スクリーンリーダーでは開いたセクションと閉じたセクションの区別も、セクションとページの区別もつきません。

達成基準1.3.1「情報及び関係性」は、画面上の構造がコードにもあることを求めています。字下げは、「請求書」が「請求」の中にあり、「カード」がさらに1段深いことを示しています。divのサイドバーでは、マークアップのどこにもそれが書かれていません。ツリーのロールがあると、ブラウザーは `group` 要素の入れ子から各項目の階層を求めますが、`group` がないため、すべての項目が階層1になります。ロールを付けたことで、間違った構造が正式なものになります。

## 利用者への影響

ヘルプセンター、ドキュメント、アカウント設定では、すべてのページをこのようなサイドバーの中に置くことがよくあります。狭い画面では同じサイドバーがメニューに移ることが多く、問題もそのまま付いていきます。開けないセクションは、クリックできない人からその中のページを隠します。

## 自動チェックとAIの限界

クリック処理を付けたdivには役割がないため、最初のサイドバーではaxeが確認することはありません。ツリーのロールはaxeに合格します。どの `treeitem` も `tree` の中にあり、どれにも名前があるからです。axeが確かめるのはツリーのロールの形（`tree` や `group` の外の `treeitem` は `aria-required-parent` として報告し、項目のない `tree` は `aria-required-children` の確認対象として送る）で、キーが何かをするか、階層が字下げと合っているかは確かめません。

この診断ツールはキーを押します。項目がTabの移動先を1つだけ共有するツリーでは、その項目にフォーカスを置いて矢印キーを押し、フォーカスがまったく動かなければ、ほかの項目に届かないとして報告します。`group` が隠れているのに `aria-expanded` のないセクションの `treeitem` も報告します。複数の項目がTabの移動先で矢印キーが何もしないツリー、`group` も `aria-level` もないのに項目が画面上で字下げされているツリー、ツリーと名付けた要素の中にdivで作られ、Tabが届かないクリックできる行があるものは、人が確認する項目として渡します。それと分かるクラス名のないdivのサイドバーは見つからず、階層が聞く人にとって筋が通っているかは、どの確認でも分かりません。

2つ目のサイドバーは、AIの修正で生まれることがあるものです。今あるdivに `role="tree"` と `role="treeitem"` を加えて終え、その結果はaxeに合格します。すべての行に `tabindex="0"` を付ける修正や、`aria-level` だけを加えて `aria-expanded` を付けない修正もあります。

## 修正前：クリックできるdivで作ったセクション

```html
<div class="help-nav">
  <div class="section">はじめに</div>
  <div class="pages" hidden>
    <a href="/help/create-account">アカウントを作成する</a>
    <a href="/help/invite-your-team">チームを招待する</a>
  </div>
  <div class="section is-open">請求</div>
  <div class="pages">
    <a href="/help/plans">プランと料金</a>
    <div class="section">支払い方法</div>
    <div class="pages" hidden>
      <a href="/help/cards">カード</a>
      <a href="/help/bank-transfer">銀行振込</a>
    </div>
    <a href="/help/invoices">請求書</a>
  </div>
  <div class="section">アカウント</div>
  <div class="pages" hidden>
    <a href="/help/change-email">メールアドレスを変更する</a>
    <a href="/help/delete-account">アカウントを削除する</a>
  </div>
</div>
```

```css
.help-nav a, .help-nav .section { display: block; }
.help-nav .pages { padding-left: 1rem; }
.help-nav .section { cursor: pointer; }
/* セクションが開いている間は矢印が下を向く。クラスだけで示している。 */
.help-nav .section::before { content: ''; display: inline-block; margin-right: 0.5em; padding: 3px; border: solid; border-width: 0 2px 2px 0; rotate: -45deg; }
.help-nav .section.is-open::before { rotate: 45deg; }
```

```js
for (const section of document.querySelectorAll('.help-nav .section')) {
  section.addEventListener('click', () => {
    const pages = section.nextElementSibling;
    pages.hidden = !pages.hidden;
    section.classList.toggle('is-open', !pages.hidden);
  });
}
```

セクションは `click` にしか反応せず、フォーカスも受けないため、閉じたセクションのページにはキーボードで届きません。セクションが開けること、開いているかどうか、後に続くリンクがそのセクションに属することは、マークアップのどこにも書かれていません。入れ子は余白でしかありません。

## 修正前：同じdivにツリーのロールを足しただけ

```html
<div class="help-nav" role="tree" aria-label="ヘルプのトピック">
  <div class="section" role="treeitem">はじめに</div>
  <div class="pages" hidden>
    <a href="/help/create-account" role="treeitem">アカウントを作成する</a>
    <a href="/help/invite-your-team" role="treeitem">チームを招待する</a>
  </div>
  <div class="section is-open" role="treeitem">請求</div>
  <div class="pages">
    <a href="/help/plans" role="treeitem">プランと料金</a>
    <div class="section" role="treeitem">支払い方法</div>
    <div class="pages" hidden>
      <a href="/help/cards" role="treeitem">カード</a>
      <a href="/help/bank-transfer" role="treeitem">銀行振込</a>
    </div>
    <a href="/help/invoices" role="treeitem">請求書</a>
  </div>
  <div class="section" role="treeitem">アカウント</div>
  <div class="pages" hidden>
    <a href="/help/change-email" role="treeitem">メールアドレスを変更する</a>
    <a href="/help/delete-account" role="treeitem">アカウントを削除する</a>
  </div>
</div>
```

CSSとスクリプトは上と同じで、違いは2つのロールだけです。ロールは、Tabの移動先が1つで、矢印キーで動き、開閉の状態と階層を伝えるツリーを約束しますが、それを実現するものは何もありません。Tabキーは今もすべてのセクションを飛ばします。NVDAはツリーの中でフォーカスモードに切り替えて矢印キーをページに渡しますが、ページはそれを無視します。`group` 要素がないため、ブラウザーはすべての項目に階層1を与え、どのセクションも開いているかどうかを伝えません。axeはこれを合格にします。

## 修正後：セクションごとにボタンを置いた、入れ子のリスト

```html
<nav class="help-nav" aria-label="ヘルプのトピック">
  <ul>
    <li>
      <button type="button" aria-expanded="false" aria-controls="help-start">はじめに</button>
      <ul id="help-start" hidden>
        <li><a href="/help/create-account">アカウントを作成する</a></li>
        <li><a href="/help/invite-your-team">チームを招待する</a></li>
      </ul>
    </li>
    <li>
      <button type="button" aria-expanded="true" aria-controls="help-billing">請求</button>
      <ul id="help-billing">
        <li><a href="/help/plans">プランと料金</a></li>
        <li>
          <button type="button" aria-expanded="false" aria-controls="help-payment">支払い方法</button>
          <ul id="help-payment" hidden>
            <li><a href="/help/cards">カード</a></li>
            <li><a href="/help/bank-transfer">銀行振込</a></li>
          </ul>
        </li>
        <li><a href="/help/invoices">請求書</a></li>
      </ul>
    </li>
    <li>
      <button type="button" aria-expanded="false" aria-controls="help-account">アカウント</button>
      <ul id="help-account" hidden>
        <li><a href="/help/change-email">メールアドレスを変更する</a></li>
        <li><a href="/help/delete-account">アカウントを削除する</a></li>
      </ul>
    </li>
  </ul>
</nav>
```

```css
.help-nav ul { list-style: none; margin: 0; padding: 0; }
.help-nav ul ul { padding-left: 1rem; }
/* 矢印は、スクリーンリーダーが読む状態から描く。 */
.help-nav button::before { content: ''; display: inline-block; margin-right: 0.5em; padding: 3px; border: solid; border-width: 0 2px 2px 0; rotate: -45deg; }
.help-nav button[aria-expanded='true']::before { rotate: 45deg; }
```

```js
for (const button of document.querySelectorAll('.help-nav button[aria-expanded]')) {
  const list = document.getElementById(button.getAttribute('aria-controls'));
  button.addEventListener('click', () => {
    const open = button.getAttribute('aria-expanded') === 'true';
    button.setAttribute('aria-expanded', String(!open));
    list.hidden = open;
  });
}
```

各セクションはHTML標準のボタンのため、Tabで届き、EnterキーとSpaceキーで開けます。キー操作を自分で書く必要はありません。`aria-expanded` により、スクリーンリーダーは「請求、ボタン、展開」や「折りたたみ」と読み上げます。各セクションのページは、そのリスト項目の中に入れ子にしたリストのため、構造がマークアップにあります。スクリーンリーダーはリストに入るとリストであることと項目数を伝え、入れ子の深さも伝えるものもあります。`hidden` により閉じたセクションのリンクはTabの順序から外れ、開いたセクションのリストはボタンのすぐ後にあるため、次のTabでその中へ進みます。ARIA Authoring Practicesが、開閉するリンクのまとまりを持つナビゲーションに勧めている、ディスクロージャーのパターンです。

## 修正後：完全なARIAのツリービュー

```html
<nav class="help-nav" aria-label="ヘルプのトピック">
  <ul role="tree" aria-label="ヘルプのトピック">
    <li role="treeitem" aria-expanded="false" aria-labelledby="help-start" tabindex="0">
      <span id="help-start">はじめに</span>
      <ul role="group" hidden>
        <li role="none"><a role="treeitem" href="/help/create-account" tabindex="-1">アカウントを作成する</a></li>
        <li role="none"><a role="treeitem" href="/help/invite-your-team" tabindex="-1">チームを招待する</a></li>
      </ul>
    </li>
    <li role="treeitem" aria-expanded="true" aria-labelledby="help-billing" tabindex="-1">
      <span id="help-billing">請求</span>
      <ul role="group">
        <li role="none"><a role="treeitem" href="/help/plans" tabindex="-1">プランと料金</a></li>
        <li role="treeitem" aria-expanded="false" aria-labelledby="help-payment" tabindex="-1">
          <span id="help-payment">支払い方法</span>
          <ul role="group" hidden>
            <li role="none"><a role="treeitem" href="/help/cards" tabindex="-1">カード</a></li>
            <li role="none"><a role="treeitem" href="/help/bank-transfer" tabindex="-1">銀行振込</a></li>
          </ul>
        </li>
        <li role="none"><a role="treeitem" href="/help/invoices" tabindex="-1">請求書</a></li>
      </ul>
    </li>
    <li role="treeitem" aria-expanded="false" aria-labelledby="help-account" tabindex="-1">
      <span id="help-account">アカウント</span>
      <ul role="group" hidden>
        <li role="none"><a role="treeitem" href="/help/change-email" tabindex="-1">メールアドレスを変更する</a></li>
        <li role="none"><a role="treeitem" href="/help/delete-account" tabindex="-1">アカウントを削除する</a></li>
      </ul>
    </li>
  </ul>
</nav>
```

```css
.help-nav ul { list-style: none; margin: 0; padding: 0; }
.help-nav [role='group'] { padding-left: 1rem; }
.help-nav a { display: block; }
/* セクションの枠は、その下に開いた枝ではなく、セクション名を囲む。 */
.help-nav li[role='treeitem']:focus-visible { outline: none; }
.help-nav li[role='treeitem']:focus-visible > span { outline: 2px solid; outline-offset: 2px; }
/* 矢印は、スクリーンリーダーが読む状態から描く。 */
.help-nav [aria-expanded] > span::before { content: ''; display: inline-block; margin-right: 0.5em; padding: 3px; border: solid; border-width: 0 2px 2px 0; rotate: -45deg; }
.help-nav [aria-expanded='true'] > span::before { rotate: 45deg; }
```

```js
const tree = document.querySelector('[role="tree"]');
const items = () => [...tree.querySelectorAll('[role="treeitem"]')];
// 移動できる項目。閉じたセクションの中にないものすべて。
const shown = () => items().filter((item) => !item.parentElement.closest('[role="group"][hidden]'));
const parentOf = (item) => item.parentElement.closest('[role="treeitem"]');
function setOpen(item, open) {
  item.setAttribute('aria-expanded', String(open));
  item.querySelector(':scope > [role="group"]').hidden = !open;
}
// Tabの移動先は1つ。最後に移動した項目。
function moveTo(item) {
  for (const other of items()) other.tabIndex = other === item ? 0 : -1;
  item.focus();
}
tree.addEventListener('keydown', (event) => {
  const item = event.target.closest('[role="treeitem"]');
  const list = shown();
  const i = list.indexOf(item);
  const state = item.getAttribute('aria-expanded');
  switch (event.key) {
    case 'ArrowDown': if (list[i + 1]) moveTo(list[i + 1]); break;
    case 'ArrowUp': if (i > 0) moveTo(list[i - 1]); break;
    case 'Home': moveTo(list[0]); break;
    case 'End': moveTo(list.at(-1)); break;
    case 'ArrowRight':
      if (state === 'false') setOpen(item, true);
      else if (state === 'true') moveTo(list[i + 1]);
      break;
    case 'ArrowLeft':
      if (state === 'true') setOpen(item, false);
      else if (parentOf(item)) moveTo(parentOf(item));
      break;
    case 'Enter':
    case ' ':
      if (state !== null) setOpen(item, state === 'false');
      else if (event.key === ' ') item.click();
      else return; // ページ上のEnterは、そのリンクをたどる。
      break;
    default:
      return;
  }
  event.preventDefault();
});
tree.addEventListener('click', (event) => {
  const item = event.target.closest('[role="treeitem"]');
  // 開いたセクションの枝の中で、項目の間をクリックしても、セクションのクリックにはしない。
  if (!item || item.querySelector(':scope > [role="group"]')?.contains(event.target)) return;
  const state = item.getAttribute('aria-expanded');
  if (state !== null) setOpen(item, state === 'false');
  moveTo(item);
});
```

ツリー全体がTabの移動先1つになり、ARIA Authoring Practicesのツリービューのとおりに矢印キーで移動できます。下と上で次と前の項目へ、右でセクションを開くかその中へ、左でセクションを閉じるか親のセクションへ、HomeとEndで最初と最後の項目へ移動します。EnterキーとSpaceキーはセクションを開閉し、Enterキーはページのリンクをたどります。各セクションのページはその中の `group` にあるため、ブラウザーは入れ子から各項目の階層、位置、組の項目数を求め、スクリーンリーダーは「請求書、ツリーアイテム、3項目中3、階層2」のように読み上げます。`aria-expanded` が各セクションの状態を伝え、矢印もそこから描きます。セクションは `aria-labelledby` で自分のラベルから名前を得ます。Authoring Practicesにあるとおり、内容から名前を求める場合も、ブラウザーは入れ子の `group` を名前に含めないため、これは名前を明示するためのものです。

## どの方法を選ぶか

どちらも基準を満たします。この例のようなページのサイドバーには、入れ子のリストを使ってください。どのブラウザー、スクリーンリーダー、音声操作のソフトも知っているリンクとボタンで、スクリプトは数行で済み、ページのほかの部分と同じようにTabで移動できます。ARIA Authoring Practicesも同じことを述べています。ツリーによるナビゲーションの例そのものに、ツリーのロールに必要なキー操作を必要とするサイトは少なく、一般的なサイトのナビゲーションにはディスクロージャーのパターンが合っていると書かれています。ツリーを作るのは、サイドバーがアプリケーションのウィジェットで、ページへ移動するのではなく項目を選んだり操作したりする場合です。フォルダーの選択やファイルマネージャーのように、項目が多くTabで1つずつ移動していられない場合もそうです。そのときは、ロール、状態、Tabの移動先1つ、すべてのキーをそろえます。

## 実装の判断

開いているページを示します。リストでは、そのリンクに `aria-current="page"` を付けます。[現在のページを見た目だけで示すナビゲーション](/ja/learn/current-page)で説明しているとおりです。ツリーでは、その `treeitem` に付け、読み込み時にその上のセクションを開いておきます。そうすれば、現在のページが見え、Tabで最初にそこへ届きます。`aria-selected` は、フォルダーを選んでから「移動」を押すときのように、項目を選ぶことと、そこへ移動することが別の段階になるツリーのためのものです。リンクのツリーには要りません。複数の項目を選べるツリーには、ARIA Authoring Practicesは代わりに `aria-checked` を勧めています。

ブラウザーが階層を数えられるのは、見える入れ子からだけです。長い、仮想化している、枝を必要なときに読み込むといった理由で、平らに描くツリーや一部だけを描くツリーでは、すべての項目に `aria-level`、`aria-setsize`、`aria-posinset` が必要です。Authoring Practicesは、テストでブラウザーとスクリーンリーダーが間違った値を伝える場合にも、これらを明示するよう勧めています。

ツリービューのパターンは、文字を押すとその文字で始まる次の項目へ移動する、先頭文字での移動も勧めています。すべてのツリーに、特に最上位に8つ以上の項目があるツリーに勧められています。このサイドバーは3つです。フォーカスのある項目と同じ階層のセクションをすべて開くアスタリスクは任意です。閉じたセクションで右キーを押すと、フォーカスは動かずにセクションが開きます。パターンが求めるとおりで、利用者は今の場所にとどまれます。もう一度右キーを押すと中へ移動します。

`treeitem` の中にリンクやボタンを置かないでください。項目そのものが操作部品で、その中の操作部品は、どこに属するかを伝えない2つ目のTabの移動先になります。リストでボタンとリンクを分けているのは、そのためです。狭い画面では、同じリストかツリーをメニューのパネルに入れます。そこで別のウィジェットに作り直さないでください。ライブラリーのツリーの部品はさまざまです。使う前に、グループを入れ子にしているか階層を設定しているか、すべてのキーを扱っているかを確かめます。

## 修正の確認

1. マウスを使わずに、Tabでサイドバーに入る。「請求」の中のセクションも含めてすべてのセクションを開き、すべてのページにたどり着ける。ツリーでは矢印キー、Home、Endを使い、Tabを1回押すとツリーを出ることを確かめる。
2. スクリーンリーダーで各セクションへ移動し、開閉する。展開と折りたたみが読み上げられ、画面上の矢印と一致する。
3. セクションの中のページと、深いセクションの中のページへ移動する。リストでは、ページのあるリストが読み上げられる。ツリーでは、3項目中3、階層2のように、項目の階層とセクション内の位置が読み上げられる。
4. ツリーでは、すべてのキーをツリービューのパターンと照らし合わせる。右キーは閉じたセクションを開き、次に中へ移動する。左キーは開いたセクションを閉じ、次に親へ移動する。ページ上のEnterキーはそのリンクをたどる。
5. 強制配色（Windowsのコントラスト テーマ）をオンにし、200%に拡大する。矢印、字下げ、フォーカスの枠がすべて見え、サイドバーのページが切れていない。

OS、ブラウザーと支援技術のバージョン、ビルド、実施日、各手順の実際の結果を記録してください。サイドバー、その部品、ライブラリーを変更した際は、再度確認します。

## 制限

このサンプルは、どのアプリケーションでも同じ結果になることを保証するものではありません。スクリプトは最小限の例で、ツリーには先頭文字での移動、複数選択、必要なときの読み込みがありません。ページ上部に横に並ぶドロップダウンメニューは別のパターンで、[マウスでしか開かないドロップダウンメニュー](/ja/learn/nav-menus)で扱っています。1つだけ開閉する部分は、[開いているか伝わらない表示・非表示ボタン](/ja/learn/show-hide-buttons)で扱っています。項目をドラッグで移動できるファイルのツリーには、ドラッグしない方法も必要です。[マウスでしか動かせないドラッグ＆ドロップのボード](/ja/learn/drag-and-drop)を参照してください。スレッドの受信トレイのように行に複数の列があるものはツリーグリッドで、別のパターンです。対象とする支援技術で、一連の操作全体をテストしてください。

---

このガイドをコーディングエージェントで使う: MCP https://easeweb.dev/mcp（設定は https://easeweb.dev/ja/services/developers）
