ブログ

ドロップダウンメニューを段階的にアクセシブルにする:「動けばいい」からの脱却

この記事ではクリックで開閉するドロップダウンメニューを「動けばいい」実装から、アクセシブルな実装へと段階的に改善していく過程を解説しています。

筆者自身がアクセシビリティを学ぶ過程で感じた「どこから手をつければいいのか」「なぜこの対応が必要なのか」といった疑問に応えられるよう、各ステップでの考え方と実装方法をコードレベルで記述しました。

本記事のコードは筆者の観点と判断で改善したものであり、正式なWEBアクセシビリティ診断において合格したものではありません。あらかじめご了承ください。

Step 1: 最小限の実装(動けばいい版)

まずは「とりあえず動く」最小限のドロップダウンメニューを作成してみます。 以下のデモはマウスでクリックすれば問題なく動作しますが、ユーザビリティとアクセシビリティの観点では多くの問題を抱えています。

最小限の機能が実装されたドロップダウンメニュー

左にロゴ、右にグローバルメニューが配置された、よく見かけるヘッダーレイアウトです。 三角形のアイコンがある「メニューB」と「メニューC」が、クリックで展開するドロップダウンメニューになっています。

ドロップダウンメニューのHTMLの次のとおりです。

<li class="l-header__nav-item c-dropdown js-dropdown">
<div class="l-header__nav-text c-dropdown__toggle" data-dropdown="toggle">
<span class="c-dropdown__toggle-text">メニューB</span>
</div>
<div class="c-dropdown__menu" data-dropdown="menu">
<div class="c-dropdown__inner">
<ul class="c-dropdown__list">
<li class="c-dropdown__item"><a href="/">子メニューB-1</a></li>
<li class="c-dropdown__item"><a href="/">子メニューB-2</a></li>
<li class="c-dropdown__item"><a href="/">子メニューB-3</a></li>
<li class="c-dropdown__item"><a href="/">子メニューB-4</a></li>
</ul>
</div>
</div>
</li>

ヘッダーで表示されるトグルボタンと、そのボタンをクリックした際に展開されるサブメニューをそれぞれ設置しています。

JavaScriptのコードは次の通りです。

const initDropdown = () => {
const dropdownItems = document.querySelectorAll(".js-dropdown");
dropdownItems.forEach((dropdown) => {
const toggle = dropdown.querySelector('[data-dropdown="toggle"]');
const menu = dropdown.querySelector('[data-dropdown="menu"]');
toggle.addEventListener("click", () => {
toggle.classList.toggle("is-active");
menu.classList.toggle("is-open");
});
});
};
document.addEventListener("DOMContentLoaded", initDropdown);

わずか15行のコードで、一見問題なく動作するドロップダウンメニューが完成しました。 ボタンクリック時にトグルボタンにis-activeクラス、サブメニューにis-openクラスを付与することで開閉を実現しています。

CSSでは、これらのクラスに応じてスタイルを切り替えています。

// トグルボタンの三角アイコンを回転
.c-dropdown__toggle {
--_toggle-angle: 0; // 下向きの状態
}
.c-dropdown__toggle-text::before {
content: "";
width: 10px;
height: 5px;
clip-path: polygon(0 0, 50% 100%, 100% 0);
background-color: currentColor;
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%) rotate(var(--_toggle-angle));
transition: transform 0.3s;
}
.c-dropdown__toggle.is-active {
--_toggle-angle: 180deg; // 上向きに変化
}
// サブメニューの表示/非表示
.c-dropdown__menu {
display: grid;
grid-template-rows: 0fr; // 閉じている状態
transition: grid-template-rows 0.4s ease-out;
}
.c-dropdown__menu.is-open {
grid-template-rows: 1fr; // 開いている状態
}

動作を確認する

実際に上記のデモで以下を試してみてください。

  1. メニューBをクリック:サブメニューが表示される ✅
  2. 続けてメニューCをクリック:メニューBは開いたまま、メニューCも開く ❌
  3. Tabキーを押す:ドロップダウンメニューのボタンにフォーカスできない ❌
  4. さらにTabキーを押す:非表示のサブメニューにフォーカスが当たる ❌
  5. Escキーを押す:何も起こらない ❌

マウスでクリックして開く分には問題ありませんが、実際に使ってみると不便なことが分かります。

この実装の問題点

ユーザビリティとアクセシビリティの観点から、以下のような問題があると考えます。

問題点詳細
排他制御が機能していない複数のメニューを同時に開けてしまい、画面が散らかる。
キーボード操作への対応が不十分
  • ドロップダウンメニューのボタンにTabキーでフォーカスできない。
  • 非表示のサブメニュー内の要素にもTabキーでフォーカスできてしまう(tabIndex制御がない)。
  • Escキーでサブメニューを閉じられない。
スクリーンリーダーでの読み上げへの対応が不十分
  • スクリーンリーダーのユーザーに、ボタンの役割が伝わらない。
  • スクリーンリーダーのユーザーに、メニューの開閉状態が伝わらない。
その他のユーザビリティ向上機能がない
  • スクロールした際にメニューが閉じない。
  • 他の要素にフォーカスが移ってもメニューが閉じない。

この記事ではこれらの問題を、段階的に解決していきたいと思います。

Step 2: 排他制御の追加

まずは使いやすさを改善するために、排他制御を導入します。

現在の実装では、メニューBを開いた後にメニューCをクリックすると、両方のメニューが同時に表示されてしまいます。さらに、メニューCがメニューBに少し重なって表示され、見づらくなっています。

上の動画のように、複数のメニューが重なって表示されると使いにくいと思います。

サブメニューの表示は一つに限定した方が自然だと思うので、メニューをクリックしたらその他のメニューは閉じる処理を追加していきます。

JavaScriptにcloseOthers関数を追加します。この関数は、クリックされたドロップダウンメニュー以外のすべてのメニューを閉じる処理を行います。具体的には、他のメニューのトグルからis-activeクラスを、メニュー本体からis-openクラスを削除します。

const initDropdown = () => {
const dropdownItems = document.querySelectorAll(".js-dropdown");
// 他のメニューを閉じる関数
const closeOthers = (current) => {
dropdownItems.forEach((dropdown) => {
if (dropdown !== current) {
const toggle = dropdown.querySelector('[data-dropdown="toggle"]');
const menu = dropdown.querySelector('[data-dropdown="menu"]');
if (!toggle || !menu) return;
toggle.classList.remove("is-active");
menu.classList.remove("is-open");
}
});
};
// ドロップダウンメニュークリック時の処理
dropdownItems.forEach((dropdown) => {
const toggle = dropdown.querySelector('[data-dropdown="toggle"]');
const menu = dropdown.querySelector('[data-dropdown="menu"]');
toggle.addEventListener("click", () => {
closeOthers(dropdown);
toggle.classList.toggle("is-active");
menu.classList.toggle("is-open");
});
});
};
document.addEventListener("DOMContentLoaded", initDropdown);

下の動画のように、メニューBを開いた後にメニューCをクリックすると、メニューBが自動的に閉じるようになりました。またその逆も確認できます。

これで、常に1つのメニューだけが表示されるようになり、サブメニューの重なりが解消されました。

↓ここまでのコードとデモです。

排他制御を導入したドロップダウンメニュー

Step 3: キーボード操作への対応

次にキーボードでも問題なく操作できるようにしていきます。現状ではクリック(タップ)で操作可能なことが確認できますが、キーボード操作への対応は不十分です。

適切なフォーカス管理

現状では次の動画のように、「メニューB」と「メニューC」にうまくフォーカスが移りません。

動画では少しわかりにくいですが、ロゴ → メニューA → 子メニューB-1から子メニューB-4 → 子メニューC-1から子メニューC-4 → メニューDの順番にフォーカスが移動しています。

つまり次の2つの問題があることがわかります。

  • ドロップダウンメニューのトグルボタンにフォーカスが当たらない
  • 非表示のサブメニュー内の要素にフォーカスが当たる

これらの問題を解決してきます。

トグルボタンにフォーカスが当たるようにする

トグルボタンにフォーカスがあたらない原因は明確です。トグルボタンをdivでマークアップしているからです。

divタグからより適切なbuttonタグへと変更します。

<li class="l-header__nav-item c-dropdown js-dropdown">
<button class="l-header__nav-text c-dropdown__toggle" data-dropdown="toggle">
<span class="c-dropdown__toggle-text">メニューB</span>
</button>
<div class="c-dropdown__menu" data-dropdown="menu">
<div class="c-dropdown__inner">
<ul class="c-dropdown__list">
<li class="c-dropdown__item"><a href="/">子メニューB-1</a></li>
<li class="c-dropdown__item"><a href="/">子メニューB-2</a></li>
<li class="c-dropdown__item"><a href="/">子メニューB-3</a></li>
<li class="c-dropdown__item"><a href="/">子メニューB-4</a></li>
</ul>
</div>
</div>
</li>

すると次の動画のように「メニューB」と「メニューC」にもフォーカスが移動するようになりました。

またbuttonに変更することで、スペースキーまたはエンターキーを押すことでメニューが展開するようになりました。

フォーカスの動作には関係しませんが、buttonタグのユーザーエージェントのスタイルを打ち消すため、以下のCSSを追加しています。

button {
padding: 0;
border: none;
font: inherit;
color: inherit;
background: none;
touch-action: manipulation;
}

非表示のサブメニュー内の要素にフォーカスがあたらないようにする

現状では展開していないドロップダウンメニューの内のリンク要素(子メニューB-1など)へもフォーカスが移動してしまいます。これは不自然な動きで混乱を招きかねないため改善します。ここではtabindex属性を操作します。

tabindex属性を操作するためのupdateTabIndex関数を追加します(22-27行目)。この関数ではサブメニューと真偽値を引数として受け取り、真偽値に応じてサブメニュー内のリンク要素のtabindex属性を動的に変化させています。それぞれ適切な箇所でupdateTabIndex関数を実行します(16, 35, 44行目)。

const initDropdown = () => {
const dropdownItems = document.querySelectorAll(".js-dropdown");
// 他のメニューを閉じる関数
const closeOthers = (current) => {
dropdownItems.forEach((dropdown) => {
if (dropdown !== current) {
const toggle = dropdown.querySelector('[data-dropdown="toggle"]');
const menu = dropdown.querySelector('[data-dropdown="menu"]');
if (!toggle || !menu) return;
toggle.classList.remove("is-active");
menu.classList.remove("is-open");
// 他のメニューを閉じる時にtabIndexを-1に
updateTabIndex(menu, true);
}
});
};
// tabIndexを制御する関数
const updateTabIndex = (menu, isHidden) => {
const links = menu.querySelectorAll("a");
links.forEach((link) => {
link.tabIndex = isHidden ? -1 : 0;
});
};
// ドロップダウンメニュークリック時の処理
dropdownItems.forEach((dropdown) => {
const toggle = dropdown.querySelector('[data-dropdown="toggle"]');
const menu = dropdown.querySelector('[data-dropdown="menu"]');
// 初期状態でtabIndexを-1に設定
updateTabIndex(menu, true);
toggle.addEventListener("click", () => {
closeOthers(dropdown);
const isOpen = toggle.classList.toggle("is-active");
menu.classList.toggle("is-open");
// 開閉に応じてtabIndexを更新
updateTabIndex(menu, !isOpen);
});
});
};
document.addEventListener("DOMContentLoaded", initDropdown);

tabindex="-1"の要素はTabキーでのフォーカス移動の対象外になるので、以下のように状態に応じて変化させています。

状態tabindex属性
初期状態(非表示)全てのサブメニュー内のリンク: -1
メニューBを開くメニューBのリンク: 0
メニューCを開くメニューBのリンク: -1
メニューCのリンク: 0
メニューCを閉じるメニューCのリンク: -1

動きを確認すると次の動画のように、ロゴ → メニューA → メニューB → メニューC → メニューDへとスムーズにフォーカスが移動するようになりました。

またドロップダウンメニュー展開時にはサブメニュー内のリンク要素にフォーカスで移動できることが確認できます。

これで、Tabキーでの移動が自然になりました。↓ここまでのコードとデモです。

フォーカス管理を実装したドロップダウンメニュー

Escキーでメニューを閉じる

現状ではメニュー展開時にEscキーを押下しても変化は起きません。Escキー押下でメニューを閉じる方が自然だと思うので、その機能を実装します。

JavaScriptを次のように変更します。

const initDropdown = () => {
const dropdownItems = document.querySelectorAll(".js-dropdown");
// 他のメニューを閉じる関数
const closeOthers = (current) => {
dropdownItems.forEach((dropdown) => {
if (dropdown !== current) {
const toggle = dropdown.querySelector('[data-dropdown="toggle"]');
const menu = dropdown.querySelector('[data-dropdown="menu"]');
if (!toggle || !menu) return;
toggle.classList.remove("is-active");
menu.classList.remove("is-open");
updateTabIndex(menu, true);
}
});
};
// tabIndexを制御する関数
const updateTabIndex = (menu, isHidden) => {
const links = menu.querySelectorAll("a");
links.forEach((link) => {
link.tabIndex = isHidden ? -1 : 0;
});
};
// Escキー押下時の処理
const onKeydownEsc = (e) => {
if (e.key === "Escape") {
const activeToggle = document.querySelector(
'[data-dropdown="toggle"].is-active'
);
closeAll();
if (activeToggle) activeToggle.focus();
}
};
// すべてのメニューを閉じる関数
const closeAll = () => {
dropdownItems.forEach((dropdown) => {
const toggle = dropdown.querySelector('[data-dropdown="toggle"]');
const menu = dropdown.querySelector('[data-dropdown="menu"]');
if (!toggle || !menu) return;
toggle.classList.remove("is-active");
menu.classList.remove("is-open");
updateTabIndex(menu, true);
});
};
// ドロップダウンメニュークリック時の処理
dropdownItems.forEach((dropdown) => {
const toggle = dropdown.querySelector('[data-dropdown="toggle"]');
const menu = dropdown.querySelector('[data-dropdown="menu"]');
updateTabIndex(menu, true);
toggle.addEventListener("click", () => {
closeOthers(dropdown);
const isOpen = toggle.classList.toggle("is-active");
menu.classList.toggle("is-open");
updateTabIndex(menu, !isOpen);
});
});
// Escキーのイベントリスナーをdocumentに登録
document.addEventListener("keydown", onKeydownEsc);
};
document.addEventListener("DOMContentLoaded", initDropdown);

Escキー押下時の処理を定義したonKeydownEsc関数を追加します(29-37行目)。この関数では、Escキーが押されたときに全てのメニューを閉じ、閉じる前に開いていたメニューのトグルボタンにフォーカスを戻します。これにより、ユーザーは現在どこにいるのかを見失わずに済みます

またonKeydownEsc関数の中で使用する、closeAll関数を追加します(40-51行目)。この関数は、すべてのドロップダウンメニューからアクティブなクラスを削除し、各メニュー内のリンクのtabindex-1に設定します。これにより、すべてのメニューが確実に閉じた状態になります。

最後にkeydownonKeydownEsc関数が実行されるようにイベントリスナーを設定します(71行目)。

以上のJavaScriptを適用すると次の動画のように、Escキー押下でメニューが閉じるようになります。またメニューが閉じた際にフォーカスがトグルボタンに移動していることが確認できます。

これで、キーボードでの基本的な操作に対応できました。次はスクリーンリーダーユーザーのために、読み上げを改善していきます。

↓ここまでのコードとデモです。

Escキーでメニューを閉じるドロップダウンメニュー

Step 4: スクリーンリーダーによる読み上げへの対応

現在の実装ではスクリーンリーダーで「メニューB」へと移動すると以下のように「メニューB、ボタン」と読み上げられます(※読み上げの確認にはmacOSのVoiceOverを使用しています)。

スクリーンリーダーでメニューB、ボタンと読み上げられたスクリーンショット

「ボタン」と読み上げられるので押すことができるのはわかりますが、押すとどのようなアクションが発生するのが不明です。モーダルが開くのか、ページが遷移するのか、それともフォームが送信されるのか何もわからない状況です。親切な説明とは言えませんので改善してきます。

ボタンの役割を伝える

まずはボタンがどのような役割を持っているのかを伝えられるようにします。

ドロップダウンメニューのHTMLを次のように変更します。

<li class="l-header__nav-item c-dropdown js-dropdown">
<button class="l-header__nav-text c-dropdown__toggle" data-dropdown="toggle" aria-haspopup="true">
<span class="c-dropdown__toggle-text">メニューB</span>
</button>
<div class="c-dropdown__menu" data-dropdown="menu">
<div class="c-dropdown__inner">
<ul class="c-dropdown__list">
<li class="c-dropdown__item"><a href="/">子メニューB-1</a></li>
<li class="c-dropdown__item"><a href="/">子メニューB-2</a></li>
<li class="c-dropdown__item"><a href="/">子メニューB-3</a></li>
<li class="c-dropdown__item"><a href="/">子メニューB-4</a></li>
</ul>
</div>
</div>
</li>

buttonタグにaria-haspopup="true"を追加します。すると先ほどと違って次のように「メニューB、メニューポップアップ、ボタン」と読み上げられるようになりました。

スクリーンリーダーでメニューB、メニューポップアップ、ボタンと読み上げられたスクリーンショット

aria-haspopup属性は、このボタンを押すと何か(メニューやダイアログなど)がポップアップすることを伝える役割があります。trueの他にもmenudialogなどの値を指定できますが、ここでは下層メニューがあることを伝えるだけなのでシンプルにtrueを使用しています。

これで「展開すればメニューが表示されるボタン」であることが認識できるようになりました。

ボタンの開閉状態を伝える

ボタンの役割を伝えられるようになりましたが、現在開いているのか閉じているのかの状態がわからない状況です。ボタンの状態を伝えるためにaria-expanded属性を追加します。

<li class="l-header__nav-item c-dropdown js-dropdown">
<button class="l-header__nav-text c-dropdown__toggle" data-dropdown="toggle" aria-haspopup="true" aria-expanded="false">
<span class="c-dropdown__toggle-text">メニューB</span>
</button>
<div class="c-dropdown__menu" data-dropdown="menu">
<div class="c-dropdown__inner">
<ul class="c-dropdown__list">
<li class="c-dropdown__item"><a href="/">子メニューB-1</a></li>
<li class="c-dropdown__item"><a href="/">子メニューB-2</a></li>
<li class="c-dropdown__item"><a href="/">子メニューB-3</a></li>
<li class="c-dropdown__item"><a href="/">子メニューB-4</a></li>
</ul>
</div>
</div>
</li>

これで次のように「メニューB、メニューポップアップ下位項目が折りたたまれました、ボタン」と表示され、現在ポップアップが閉じていることがわかるようになりました。

スクリーンリーダーでメニューB、メニューポップアップ下位項目が折りたたまれました、ボタンと読み上げられたスクリーンショット

aria-expanded="false"によって下位項目の状態を認識できるようになりましたが、このままではずっと閉じた状態が伝えられるので、aria-expandedの値を動的に変化させます。

JavaScriptを次のように変更します。

const initDropdown = () => {
const dropdownItems = document.querySelectorAll(".js-dropdown");
// 他のメニューを閉じる関数
const closeOthers = (current) => {
dropdownItems.forEach((dropdown) => {
if (dropdown !== current) {
const toggle = dropdown.querySelector('[data-dropdown="toggle"]');
const menu = dropdown.querySelector('[data-dropdown="menu"]');
if (!toggle || !menu) return;
toggle.classList.remove("is-active");
toggle.setAttribute("aria-expanded", "false");
menu.classList.remove("is-open");
updateTabIndex(menu, true);
}
});
};
// tabIndexを制御する関数
const updateTabIndex = (menu, isHidden) => {
const links = menu.querySelectorAll("a");
links.forEach((link) => {
link.tabIndex = isHidden ? -1 : 0;
});
};
// Escキー押下時の処理
const onKeydownEsc = (e) => {
if (e.key === "Escape") {
const activeToggle = document.querySelector(
'[data-dropdown="toggle"].is-active'
);
const activeToggle = document.querySelector(
'[data-dropdown="toggle"][aria-expanded="true"]'
);
closeAll();
if (activeToggle) activeToggle.focus();
}
};
// すべてのメニューを閉じる関数
const closeAll = () => {
dropdownItems.forEach((dropdown) => {
const toggle = dropdown.querySelector('[data-dropdown="toggle"]');
const menu = dropdown.querySelector('[data-dropdown="menu"]');
if (!toggle || !menu) return;
toggle.classList.remove("is-active");
toggle.setAttribute("aria-expanded", "false");
menu.classList.remove("is-open");
updateTabIndex(menu, true);
});
};
// ドロップダウンメニュークリック時の処理
dropdownItems.forEach((dropdown) => {
const toggle = dropdown.querySelector('[data-dropdown="toggle"]');
const menu = dropdown.querySelector('[data-dropdown="menu"]');
updateTabIndex(menu, true);
toggle.addEventListener("click", () => {
closeOthers(dropdown);
// 現在の aria-expanded の値を取得
const expanded = toggle.getAttribute("aria-expanded") === "true";
// 現在の値と逆の状態(次の状態)を算出
const willExpand = !expanded;
// aria-expanded を次の状態に更新
toggle.setAttribute("aria-expanded", willExpand);
// willExpand(次の状態)に応じてクラスを付け外し
menu.classList.toggle("is-open", willExpand);
// 第二引数に willExpand の反対のbooleanを渡す
updateTabIndex(menu, !willExpand);
});
});
// Escキーのイベントリスナーをdocumentに登録
document.addEventListener("keydown", onKeydownEsc);
};
document.addEventListener("DOMContentLoaded", initDropdown);

69-79行目では、クリック時の処理をaria-expanded属性ベースに変更しています。現在のaria-expandedの値を取得し、その反対の状態に更新することで、開閉を切り替えています。updateTabIndex関数も、このaria-expandedの値に基づいて動作するよう変更しました。

これまではトグルの状態管理にはis-activeクラスを使用していましたが、それらを全てaria-expanded属性に変更しています(13、36、51行目)。

これでスクリーンリーダーの読み上げもメニューの状態に応じて変化するようになりました。開いている状態では次のように「メニューB、メニューポップアップ字間広く、ボタン」と読み上げられます。

スクリーンリーダーでメニューB、メニューポップアップ字間広く、ボタンと読み上げられたスクリーンショット

「下位項目が表示されました」の方がストレートでわかりやすいですが、「字間広く」と読み上げられるのはVoiceOverの日本語訳の問題のようです(英語では”expanded”と読み上げられます)。スクリーンリーダーによって表現は異なりますが、メニューが展開していることが認識できるようになりました。

CSSもis-activeクラスからaria-expanded属性ベースに変更します。これにより、aria属性が真の状態管理となり、CSSもその状態に基づいてスタイルを適用するようになります。

// アクティブ時
.c-dropdown__toggle.is-active {
--_toggle-angle: 180deg;
}
.c-dropdown__toggle[aria-expanded="true"] {
--_toggle-angle: 180deg;
}

↓ここまでのコードとデモです。残念ながらCodePenはスクリーンリーダーでの読み上げ対象外のようです。

aria-haspopupとaria-expandedを実装したドロップダウンメニュー

サブメニューの読み上げをコントロールする

トグルボタンの読み上げは改善できましたが、サブメニューの読み上げにはまだ課題があります。

メニューBが閉じている状態で読み上げを一つ次へ進めると、以下のように「リスト4項目、レベル2」と読み上げられます。このリスト4項目とは子メニューB-1〜子メニューB-4を指します。メニューBが閉じている状態では、これらのサブメニューをアクセシビリティツリーから非表示にするべきです。

スクリーンリーダーでリスト4項目、レベル2と読み上げられたスクリーンショット

要素の読み上げを制御するために、サブメニューの親要素にaria-hidden属性を追加します。初期値はtrueで、要素が読み上げられないようにします。

<li class="l-header__nav-item c-dropdown js-dropdown">
<button class="l-header__nav-text c-dropdown__toggle" data-dropdown="toggle" aria-haspopup="true"
aria-expanded="false">
<span class="c-dropdown__toggle-text">メニューB</span>
</button>
<div class="c-dropdown__menu" data-dropdown="menu" aria-hidden="true">
<div class="c-dropdown__inner">
<ul class="c-dropdown__list">
<li class="c-dropdown__item"><a href="/">子メニューB-1</a></li>
<li class="c-dropdown__item"><a href="/">子メニューB-2</a></li>
<li class="c-dropdown__item"><a href="/">子メニューB-3</a></li>
<li class="c-dropdown__item"><a href="/">子メニューB-4</a></li>
</ul>
</div>
</div>
</li>

メニューBが閉じている状態で読み上げを次へ進めると、以下のように「4の3」と読み上げられるようになりました。4の3とはグローバルメニューの「メニューC」を指します。サブメニューが読み上げ対象外になっていることを確認できます。

スクリーンリーダーで4の3と読み上げられたスクリーンショット

それでは次にaria-expandedと同じように、aria-hiddenの値もメニューの開閉状態に合わせて動的に変化するようにします。

JavaScriptを次のように変更します。

const initDropdown = () => {
const dropdownItems = document.querySelectorAll(".js-dropdown");
// 他のメニューを閉じる関数
const closeOthers = (current) => {
dropdownItems.forEach((dropdown) => {
if (dropdown !== current) {
const toggle = dropdown.querySelector('[data-dropdown="toggle"]');
const menu = dropdown.querySelector('[data-dropdown="menu"]');
if (!toggle || !menu) return;
toggle.setAttribute("aria-expanded", "false");
menu.classList.remove("is-open");
menu.setAttribute("aria-hidden", "true");
updateTabIndex(menu, true);
}
});
};
// 中略
// すべてのメニューを閉じる関数
const closeAll = () => {
dropdownItems.forEach((dropdown) => {
const toggle = dropdown.querySelector('[data-dropdown="toggle"]');
const menu = dropdown.querySelector('[data-dropdown="menu"]');
if (!toggle || !menu) return;
toggle.setAttribute("aria-expanded", "false");
menu.classList.remove("is-open");
menu.setAttribute("aria-hidden", "true");
updateTabIndex(menu, true);
});
};
// ドロップダウンメニュークリック時の処理
dropdownItems.forEach((dropdown) => {
const toggle = dropdown.querySelector('[data-dropdown="toggle"]');
const menu = dropdown.querySelector('[data-dropdown="menu"]');
updateTabIndex(menu, true);
toggle.addEventListener("click", () => {
closeOthers(dropdown);
const expanded = toggle.getAttribute("aria-expanded") === "true";
const willExpand = !expanded;
toggle.setAttribute("aria-expanded", willExpand);
menu.classList.toggle("is-open", willExpand);
menu.setAttribute("aria-hidden", expanded);
updateTabIndex(menu, !willExpand);
});
});
// Escキーのイベントリスナーをdocumentに登録
document.addEventListener("keydown", onKeydownEsc);
};
document.addEventListener("DOMContentLoaded", initDropdown);

これまではサブメニューの状態管理にはis-openクラスを使用していましたが、それらを全てaria-hidden属性に変更します(14、32、52行目)。

52行目では、aria-hiddenexpanded(開閉前の状態)を渡すことで、aria-expandedとは逆の値になるようにしています。つまり、メニューが開く時にはaria-hidden="false"となり、閉じる時にはaria-hidden="true"となります。

aria-expandedaria-hiddenの関係性をまとめると次のようになります。トグルボタンとサブメニューで、常に逆の状態を保ちます。

現在のドロップダウンの状態aria-expanded(トグルボタン)aria-hidden(サブメニュー)
閉じているfalsetrue
開いているtruefalse

CSSもis-openクラスからaria-hidden属性ベースに変更します。

// アクティブ時
.c-dropdown__menu.is-open {
grid-template-rows: 1fr;
}
.c-dropdown__menu[aria-hidden="false"] {
grid-template-rows: 1fr;
}

以上でサブメニューの読み上げのコントロールは完了です。

  • メニューBが「閉じている」時に次へ進める → 「4の3」(メニューCに移動)
  • メニューBが「開いている」時に次へ進める → 「リスト4項目、レベル2」(子メニューに移動)

これで、メニューの状態に応じて適切な読み上げがされるようになりました。

↓ここまでのコードとデモです。

aria-hiddenを実装したドロップダウンメニュー

要素を関連づける

次にトグルボタンとサブメニューの紐付けを行います。

現在の状況ではメニューBがどのコンテンツを制御できるのか識別できない状況です。aria-controlsを使用して関連性を明示していきます。

HTMLに直書きでも問題ないのですが、コピペ漏れやID重複のリスクを考慮して、JavaScriptで自動的に設定するようにします。JavaScriptを次のように変更します。

const initDropdown = () => {
const dropdownItems = document.querySelectorAll(".js-dropdown");
// 中略
// aria初期化
const initAria = (toggle, menu) => {
if (!menu.id) {
const id = `dropdown-${Math.random().toString(36).slice(2, 9)}`;
menu.id = id;
}
toggle.setAttribute("aria-controls", menu.id);
};
// ドロップダウンメニュークリック時の処理
dropdownItems.forEach((dropdown) => {
const toggle = dropdown.querySelector('[data-dropdown="toggle"]');
const menu = dropdown.querySelector('[data-dropdown="menu"]');
updateTabIndex(menu, true);
initAria(toggle, menu);
toggle.addEventListener("click", () => {
closeOthers(dropdown);
const expanded = toggle.getAttribute("aria-expanded") === "true";
const willExpand = !expanded;
toggle.setAttribute("aria-expanded", willExpand);
menu.setAttribute("aria-hidden", expanded);
updateTabIndex(menu, !willExpand);
});
});
// Escキーのイベントリスナーをdocumentに登録
document.addEventListener("keydown", onKeydownEsc);
};
document.addEventListener("DOMContentLoaded", initDropdown);

initAria関数を追加し(7-14行目)、各ドロップダウンの初期化時にその関数を呼び出します(22行目)。

initAria関数では以下の処理を行います:

  1. サブメニューにID属性があるか確認
  2. なければ「dropdown-」+7桁のランダムな文字列を生成してIDに設定(例: dropdown-a1b2c3d)
  3. そのIDをトグルボタンのaria-controlsにセット

これにより、HTMLでIDを手動で設定する手間が省け、ID重複のリスクも回避できます。

次のようにトグルボタンのaria-controlsとサブメニューのID属性が一致していることが確認できます。

aria-controlsとIDの値の一致が確認できるスクリーンショット

これでトグルボタンとサブメニューの紐付けは完了です。ただ残念ながらaria-controlsは、VoiceOverをはじめとしたほとんどのスクリーンリーダーでの読み上げに影響しません

一部のスクリーンリーダー(NVDA、JAWSなど)では、フォーカス中に特定のキー操作(Insert + Alt + M など)で、aria-controlsで紐付けられた要素に直接ジャンプできる機能があります。ただし、この機能を知っているユーザーは少ないのが現状です。

では、aria-controlsは不要なのか?というと、そういうわけではありません。

実用性は現時点では限定的ですが、W3Cのガイドラインでは推奨されており、将来的により広く活用される可能性があります。実装コストも低いため、ベストプラクティスとして含めておくことをお勧めします。

↓ここまでのコードとデモです。

aria-controlsを実装したドロップダウンメニュー

以上でスクリーンリーダーによる読み上げへの対応は完了です。だいぶ形になってきました。

Step 5: 使いやすさ向上のための追加機能

前章まででアクセシビリティ的にはほとんどの場合で対応できるものが完成しました。次はもう少し使いやすくなるような工夫をしていきます。

必須ではありませんが、プロジェクトによってはあった方が良いと思われる機能を追加していきます。

スクロール時に閉じる

現在の実装ではドロップダウンメニューを開いた状態で垂直方向にスクロールしても、メニューは閉じません。ヘッダーを上部に固定している場合では、コンテンツ視認の妨げとなる可能性があるので、スクロール時にはメニューが閉じるようにします。

JavaScriptを次のように変更します。

const initDropdown = () => {
const dropdownItems = document.querySelectorAll(".js-dropdown");
// 中略
// Escキーのイベントリスナーをdocumentに登録
document.addEventListener("keydown", onKeydownEsc);
// スクロール時にメニューを閉じるイベントリスナー
window.addEventListener(
"scroll",
() => {
const existsOpenedMenu = document.querySelector(
'.js-dropdown [data-dropdown="toggle"][aria-expanded="true"]'
);
if (existsOpenedMenu) {
closeAll();
}
},
{ passive: true }
);
};
document.addEventListener("DOMContentLoaded", initDropdown);

スクロール時にメニューを閉じるイベントリスナーを追加しました(10-22行目)。

イベントハンドラではaria-expandedの値がtrueのトグルボタンが存在する場合に、全てのメニューを閉じるcloseAll関数を呼び出しています。

第三引数のオプションにはpassive: trueを指定しています。これにより、イベントハンドラ内でpreventDefault()を呼ばないことをブラウザに伝え、スクロール処理と並行してハンドラーを実行できるようになります。結果として、スクロールのパフォーマンスが向上します。

これで次のようにスクロールでメニューが閉じるようになりました。

↓ここまでのコードとデモです。

スクロールでメニューが閉じるドロップダウンメニュー

フォーカスアウト時にメニューを閉じる

次にフォーカスアウト時の動きを変更します。

現状では次の動画のように子メニューB-4からメニューCにフォーカスが移動しても、メニューBは開いたままです。

メニューからフォーカスが外れた時には、そのメニューを閉じた方が自然だと思うので改善します。

JavaScriptを次のように変更します。

const initDropdown = () => {
const dropdownItems = document.querySelectorAll(".js-dropdown");
// 中略
// ドロップダウンメニュークリック時の処理
dropdownItems.forEach((dropdown) => {
const toggle = dropdown.querySelector('[data-dropdown="toggle"]');
const menu = dropdown.querySelector('[data-dropdown="menu"]');
updateTabIndex(menu, true);
initAria(toggle, menu);
// メニュー全体からフォーカスが外れたらメニューを閉じる処理
dropdown.addEventListener("focusout", (e) => {
// 次のフォーカス先がこのドロップダウン内でなければ閉じる
if (!dropdown.contains(e.relatedTarget)) {
toggle.setAttribute("aria-expanded", "false");
menu.setAttribute("aria-hidden", "true");
updateTabIndex(menu, true);
}
});
// 中略
});
// 中略
};
document.addEventListener("DOMContentLoaded", initDropdown);

メニュー全体からフォーカスが外れたらメニューを閉じる処理を追加しました(15-22行目)。

17行目のe.relatedTarget次にフォーカスが移動する先の要素を指します。dropdown.contains(e.relatedTarget)で「次のフォーカス先が現在のドロップダウン内か」を判定し、外に移動する場合のみメニューを閉じる処理を実現しています。

これにより、ドロップダウン内の要素間(トグルボタン → 子メニュー項目)でフォーカスが移動する際には、メニューが閉じないようになります。

これで次のようにフォーカスアウト時にメニューが閉じるようになります。

↓ここまでのコードとデモです。

フォーカスアウト時にメニューが閉じるドロップダウンメニュー

以上でユーザビリティ改善の実装は完了です。他にも以下のような追加機能が考えられます。

  • メニュー外クリックで閉じる: 背景やメニュー外の領域をクリックした時に閉じる
  • 閉じるボタン: メガメニューのような大きなメニューの場合に有効
  • ホバーでの開閉: デスクトップ環境での利便性向上

プロジェクトの要件に応じて、これらの機能を追加することも検討する必要がありそうです。

リファクタリング

JavaScriptが長くなったので、リファクタリングを行います。機能は変えずに、コードの可読性と保守性を向上させます。

リファクタリング後のコードは次の通りです。

const initDropdown = () => {
// セレクタの定義
const SELECTOR = {
wrapper: ".js-dropdown",
toggle: '[data-dropdown="toggle"]',
menu: '[data-dropdown="menu"]',
};
const dropdownItems = document.querySelectorAll(SELECTOR.wrapper);
// ===================================
// ヘルパー関数
// ===================================
// dropdown要素からtoggleとmenuを取得
const getParts = (dropdown) => ({
toggle: dropdown.querySelector(SELECTOR.toggle),
menu: dropdown.querySelector(SELECTOR.menu),
});
// tabIndexを制御
const updateTabIndex = (menu, isHidden) => {
menu
.querySelectorAll("a, button, input, select, textarea, [tabindex]")
.forEach((el) => {
el.tabIndex = isHidden ? -1 : 0;
});
};
// aria-controlsを設定
const initAria = (toggle, menu) => {
if (!menu.id) {
menu.id = `dropdown-${Math.random().toString(36).slice(2, 9)}`;
}
toggle.setAttribute("aria-controls", menu.id);
};
// 他のドロップダウンを閉じる
const closeOthers = (current) => {
dropdownItems.forEach((dropdown) => {
if (dropdown === current) return;
const { toggle, menu } = getParts(dropdown);
toggle.setAttribute("aria-expanded", "false");
menu.setAttribute("aria-hidden", "true");
updateTabIndex(menu, true);
});
};
// すべてのドロップダウンを閉じる
const closeAll = () => {
dropdownItems.forEach((dropdown) => {
const { toggle, menu } = getParts(dropdown);
toggle.setAttribute("aria-expanded", "false");
menu.setAttribute("aria-hidden", "true");
updateTabIndex(menu, true);
});
};
// Escキー押下時の処理
const handleEscKey = (e) => {
if (e.key === "Escape") {
const activeToggle = document.querySelector(
`${SELECTOR.toggle}[aria-expanded="true"]`
);
closeAll();
if (activeToggle) activeToggle.focus();
}
};
// ===================================
// 初期化
// ===================================
dropdownItems.forEach((dropdown) => {
const { toggle, menu } = getParts(dropdown);
// 初期状態を設定
updateTabIndex(menu, true);
initAria(toggle, menu);
// クリックイベント
toggle.addEventListener("click", () => {
closeOthers(dropdown);
const expanded = toggle.getAttribute("aria-expanded") === "true";
const willExpand = !expanded;
toggle.setAttribute("aria-expanded", willExpand);
menu.setAttribute("aria-hidden", !willExpand);
updateTabIndex(menu, !willExpand);
});
// フォーカスアウトイベント
dropdown.addEventListener("focusout", (e) => {
if (!dropdown.contains(e.relatedTarget)) {
toggle.setAttribute("aria-expanded", "false");
menu.setAttribute("aria-hidden", "true");
updateTabIndex(menu, true);
}
});
});
// グローバルイベント
document.addEventListener("keydown", handleEscKey);
window.addEventListener(
"scroll",
() => {
const hasOpenMenu = document.querySelector(
`${SELECTOR.toggle}[aria-expanded="true"]`
);
if (hasOpenMenu) closeAll();
},
{ passive: true }
);
};
document.addEventListener("DOMContentLoaded", initDropdown);

リファクタリングで変更した主なポイントは次の通りです。

項目改善内容
セレクタ管理定数SELECTORにまとめて一元管理
重複コードgetParts関数で toggle/menu 取得を共通化
コード構造ヘルパー関数と初期化処理を明確に分離
フォーカス制御aタグ以外にもサブメニュー内のすべてのフォーカス可能要素に対応
可読性関数名と変数名をより分かりやすく命名

機能は変わっていませんが、今後の機能追加や修正がしやすくなりました。

最初の動けばいい版では15行程度のJavaScripのコードでしたが、ユーザビリティとアクセシビリティを意識するとやはりコード量はどうしても長くなってしまいます。

↓最終的なコードとデモです。

リファクタリング後のドロップダウンメニュー

参考サイト