aria-controlsの正しい使い方を徹底解説!html・js・フレームワーク別実装例とアクセシビリティ対策

aria-controls HTML
記事内に広告が含まれています。

「アクセシビリティ対応のために aria-controls を使うって聞いたけど、正直よくわからない…」

そんな風に感じたことはありませんか?

Web制作において、HTMLの構造や見た目だけでなく、ユーザー補助技術に正しく情報を伝えることはとても大切です。特に視覚に頼らずにWebを操作するユーザーにとって、aria-controls のようなWAI-ARIA属性は、UIの意味や状態を理解するための重要なヒントになります。

でも、「どこにどう使えばいいの?」「aria-labelledbyaria-expanded と何が違うの?」「複数の要素を制御するには?」といった疑問が出てくるのも自然なことです。

この記事では、そんな方のために、aria-controlsの使い方をHTMLとJavaScript両方の視点から、初心者にもわかりやすく解説していきます。タブUIやアコーディオンなど、実際によく使われるパターンを通じて、正しく、効果的に使う方法を学びましょう。

記事を読んでわかること

  • aria-controls とは何か?どんな役割を持つ属性か
  • HTMLでの基本的な書き方と、タブUIでの具体的な使い方
  • JavaScriptで aria-controls を動的に活用する実装例(アコーディオン・タブUIなど)
  • スクリーンリーダーでの読み上げ例や挙動の理解
  • Bootstrap・React・Vue.js でのフレームワーク別実装方法
  • aria-controls を正しく使うための注意点と、よくある誤用例の回避法
  • aria-labelledby との違いや、使わなくてもよいケースの判断基準

aria-controlsとは?基本から理解するWAI-ARIA属性の役割

Webアクセシビリティの向上において、WAI-ARIA(Web Accessibility Initiative – Accessible Rich Internet Applications)属性は欠かせない技術要素の一つです。その中でもaria-controlsは、インタラクティブなUI要素において、ユーザーがどの要素を制御しているかを明確に示すための重要な属性です。現代のWebサイトでは、タブ、アコーディオン、ドロップダウンメニューなど、動的なユーザーインターフェースが当たり前となっており、これらの要素を適切にスクリーンリーダーなどの支援技術に伝えることが、包括的なWebエクスペリエンスの実現につながります。

aria-controlsとは?シンプルな定義と役割

aria-controlsは、ある要素が他の要素を制御していることを示すWAI-ARIA属性です。具体的には、ボタンやリンクなどのインタラクティブ要素が、別の要素の表示・非表示や状態変更を制御している関係性を、支援技術に対して明示的に伝える役割を担います。

この属性の値には、制御される要素のIDを指定します。例えば、タブボタンがタブパネルを制御している場合、タブボタンにaria-controls="tab-panel-1"のように記述することで、スクリーンリーダーユーザーは「このボタンがtab-panel-1という要素を制御している」ことを理解できます。

<button aria-controls="content-panel" aria-expanded="false">
  コンテンツを表示
</button>
<div id="content-panel" style="display: none;">
  ここに表示されるコンテンツ
</div>

aria-controlsの主な目的は、コンテキストの明確化です。視覚的にはボタンとそれが制御するコンテンツの関係が明らかであっても、スクリーンリーダーユーザーにとってはその関係性が分からない場合があります。aria-controlsを適切に使用することで、「このボタンを押すと何が起こるのか」「どの要素が影響を受けるのか」といった情報を、支援技術を通じて正確に伝えることができるのです。

WAI-ARIA仕様におけるaria-controlsの意味と正しい使いどころ

W3CのWAI-ARIA仕様において、aria-controlsはrelationship属性の一つとして定義されています。これは要素間の関係性を示す属性群の中に分類され、aria-ownsaria-describedbyなどと同様に、DOM構造だけでは表現できない論理的な関係性を補完する役割を果たします。

aria-controls - ARIA | MDN
グローバルな aria-controls 属性は、この属性が設定されている要素によってコンテンツまたは存在が制御される要素を識別します。

WAI-ARIA仕様では、aria-controlsは以下のような場面での使用が推奨されています:

タブインターフェースでは、各タブボタンが対応するタブパネルを制御する関係を示すために使用します。これにより、スクリーンリーダーユーザーは現在選択しているタブがどのコンテンツを表示しているかを理解できます。

アコーディオンUIにおいては、展開・折りたたみボタンが制御するコンテンツ領域との関係を明示します。ユーザーはボタンを操作する前に、そのボタンがどの領域に影響するかを事前に把握できるため、予期しない動作による混乱を避けることができます。

モーダルダイアログの制御では、モーダルを開くボタンと実際のモーダル要素との関係を示すことで、ユーザーがボタンの機能を正確に理解できるようになります。

重要なのは、aria-controlsは実際に制御関係が存在する場合にのみ使用するという点です。単に関連があるというだけではなく、ユーザーの操作によって対象要素の状態が変化する場合に限定して使用することが、WAI-ARIA仕様の本来の意図に沿った実装となります。

また、aria-controlsは多くの場合、他のARIA属性と組み合わせて使用されます。aria-expanded(展開状態を示す)、aria-selected(選択状態を示す)、role(要素の役割を定義)などと併用することで、より豊富なアクセシビリティ情報を提供できます。

aria-controlsの誤った使用例とアクセシビリティへの影響

aria-controlsの不適切な使用は、かえってアクセシビリティを損なう結果を招く可能性があります。よくある誤用パターンとその影響を理解することで、正しい実装につなげることができます。

最も一般的な誤用は、存在しないIDを参照してしまうケースです。aria-controlsの値として指定したIDが実際のHTML要素に存在しない場合、スクリーンリーダーは適切な情報を提供できず、ユーザーは混乱してしまいます。

<!-- 誤った例:参照先が存在しない -->
<button aria-controls="non-existent-panel">開く</button>
<!-- non-existent-panelというIDの要素が存在しない -->

もう一つの問題は、制御関係が実際に存在しないにも関わらずaria-controlsを使用してしまうことです。例えば、単なるナビゲーションリンクにaria-controlsを付けてしまうと、ユーザーはそのリンクが何かを制御すると期待してしまいますが、実際にはページ遷移が発生するだけで、期待と異なる動作にユーザーが困惑する可能性があります。

JavaScriptで動的にコンテンツを変更する際に、aria-controlsの更新を忘れてしまうパターンも頻繁に見られます。要素のIDが動的に変更されたり、制御される要素が追加・削除されたりした場合、それに合わせてaria-controlsの値も更新する必要があります。この同期が取れていないと、古い情報がスクリーンリーダーに伝わってしまい、ユーザーエクスペリエンスが大幅に悪化します。

// 誤った例:aria-controlsを更新し忘れ
function addNewPanel() {
  const newPanel = document.createElement('div');
  newPanel.id = 'panel-' + Date.now(); // 動的にIDを生成
  document.body.appendChild(newPanel);

  // aria-controlsの値を新しいIDに更新するのを忘れがち
  // button.setAttribute('aria-controls', newPanel.id); // この行が抜けている
}

これらの誤用を避けるためには、開発プロセスにおいてアクセシビリティテストを組み込むことが重要です。スクリーンリーダーでの実際の操作確認や、自動テストツールを活用したaria属性の整合性チェックを定期的に実施することで、問題の早期発見と修正が可能になります。

正しいaria-controlsの使用は、Webアクセシビリティの向上だけでなく、全体的なユーザーエクスペリエンスの質を高める効果もあります。視覚的なユーザーにとっても、明確な制御関係は直感的な操作につながり、より使いやすいインターフェースの実現に貢献するのです。

aria-controls の基本的な使い方とHTMLでの実装例

aria-controlsの理論的な理解を深めたところで、実際のHTML実装における具体的な使い方を詳しく見ていきましょう。実践的なコード例を通じて、aria-controlsがどのようにWebアクセシビリティの向上に貢献するかを体感できるはずです。現実のWeb制作において、適切なaria-controls実装は、スクリーンリーダーユーザーをはじめとする様々なユーザーにとって、より直感的で使いやすいインターフェースを提供します。

HTMLでの基本的な実装方法

aria-controlsの最もシンプルな実装パターンは、ボタンや要素が別の要素の表示・非表示を制御する場面です。基本的な構文はaria-controls="制御される要素のID"という形で記述します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>aria-controls基本実装例</title>
</head>
<body>
    <!-- 制御する要素(ボタン) -->
    <button
        id="toggle-button"
        aria-controls="content-panel"
        aria-expanded="false"
        onclick="togglePanel()">
        詳細情報を表示
    </button>

    <!-- 制御される要素(パネル) -->
    <div
        id="content-panel"
        role="region"
        aria-labelledby="toggle-button"
        style="display: none;">
        <h3>詳細情報</h3>
        <p>ここに詳細な情報が表示されます。aria-controlsにより、ボタンとこのパネルの関係が明確に示されています。</p>
    </div>

    <script>
        function togglePanel() {
            const button = document.getElementById('toggle-button');
            const panel = document.getElementById('content-panel');
            const isExpanded = button.getAttribute('aria-expanded') === 'true';

            // 表示状態を切り替え
            panel.style.display = isExpanded ? 'none' : 'block';

            // aria-expanded属性を更新
            button.setAttribute('aria-expanded', !isExpanded);

            // ボタンテキストも更新
            button.textContent = isExpanded ? '詳細情報を表示' : '詳細情報を非表示';
        }
    </script>
</body>
</html>

この基本実装では、いくつかの重要なポイントが含まれています。まず、aria-controls="content-panel"により、ボタンがcontent-panelという要素を制御していることを明示しています。同時にaria-expanded="false"を使用して、制御される要素の現在の展開状態を示しています。

制御される側の要素にはrole="region"を指定し、このエリアが独立したコンテンツ領域であることを示しています。また、aria-labelledby="toggle-button"により、このパネルがどのボタンによって制御されているかの逆参照も提供しています。このような相互参照は、スクリーンリーダーユーザーにとって非常に有用な情報となります。

複数要素を制御する場合の実装も重要なパターンです。一つのボタンが複数の要素を同時に制御する場合、aria-controlsの値にはスペース区切りで複数のIDを指定できます。

<button
    aria-controls="panel-1 panel-2 panel-3"
    aria-expanded="false"
    onclick="toggleMultiplePanels()">
    全セクションを展開
</button>

<div id="panel-1" style="display: none;">パネル1の内容</div>
<div id="panel-2" style="display: none;">パネル2の内容</div>
<div id="panel-3" style="display: none;">パネル3の内容</div>

シンプルタブUIでの実装例

タブインターフェースは、aria-controlsの使用例として最も代表的で理解しやすいパターンの一つです。適切に実装されたタブUIは、スクリーンリーダーユーザーにとって非常にナビゲーションしやすい構造となります。

<div class="tab-container">
    <!-- タブリスト -->
    <div role="tablist" aria-label="製品情報タブ">
        <button
            role="tab"
            id="tab-overview"
            aria-controls="panel-overview"
            aria-selected="true"
            tabindex="0"
            onclick="switchTab('overview')">
            概要
        </button>
        <button
            role="tab"
            id="tab-features"
            aria-controls="panel-features"
            aria-selected="false"
            tabindex="-1"
            onclick="switchTab('features')">
            機能
        </button>
        <button
            role="tab"
            id="tab-specs"
            aria-controls="panel-specs"
            aria-selected="false"
            tabindex="-1"
            onclick="switchTab('specs')">
            仕様
        </button>
    </div>

    <!-- タブパネル -->
    <div
        role="tabpanel"
        id="panel-overview"
        aria-labelledby="tab-overview"
        tabindex="0">
        <h3>製品概要</h3>
        <p>この製品の基本的な情報と特徴について説明します。</p>
    </div>

    <div
        role="tabpanel"
        id="panel-features"
        aria-labelledby="tab-features"
        tabindex="0"
        hidden>
        <h3>主要機能</h3>
        <ul>
            <li>高性能プロセッサ搭載</li>
            <li>長時間バッテリー駆動</li>
            <li>防水・防塵設計</li>
        </ul>
    </div>

    <div
        role="tabpanel"
        id="panel-specs"
        aria-labelledby="tab-specs"
        tabindex="0"
        hidden>
        <h3>技術仕様</h3>
        <table>
            <tr><td>CPU</td><td>2.4GHz クアッドコア</td></tr>
            <tr><td>メモリ</td><td>8GB RAM</td></tr>
            <tr><td>ストレージ</td><td>256GB SSD</td></tr>
        </table>
    </div>
</div>

<script>
function switchTab(targetTab) {
    // 全てのタブボタンとパネルを取得
    const allTabs = document.querySelectorAll('[role="tab"]');
    const allPanels = document.querySelectorAll('[role="tabpanel"]');

    // 全てのタブを非選択状態にする
    allTabs.forEach(tab => {
        tab.setAttribute('aria-selected', 'false');
        tab.setAttribute('tabindex', '-1');
    });

    // 全てのパネルを非表示にする
    allPanels.forEach(panel => {
        panel.setAttribute('hidden', '');
    });

    // 選択されたタブを有効化
    const selectedTab = document.getElementById(`tab-${targetTab}`);
    const selectedPanel = document.getElementById(`panel-${targetTab}`);

    selectedTab.setAttribute('aria-selected', 'true');
    selectedTab.setAttribute('tabindex', '0');
    selectedTab.focus();

    selectedPanel.removeAttribute('hidden');
}

// キーボード操作のサポート
document.addEventListener('keydown', function(e) {
    if (e.target.getAttribute('role') === 'tab') {
        const tabList = e.target.parentElement;
        const tabs = Array.from(tabList.querySelectorAll('[role="tab"]'));
        const currentIndex = tabs.indexOf(e.target);

        let newIndex;

        switch(e.key) {
            case 'ArrowRight':
                newIndex = (currentIndex + 1) % tabs.length;
                tabs[newIndex].focus();
                e.preventDefault();
                break;
            case 'ArrowLeft':
                newIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1;
                tabs[newIndex].focus();
                e.preventDefault();
                break;
            case 'Home':
                tabs[0].focus();
                e.preventDefault();
                break;
            case 'End':
                tabs[tabs.length - 1].focus();
                e.preventDefault();
                break;
        }
    }
});
</script>

<style>
.tab-container {
    max-width: 600px;
    margin: 20px auto;
    font-family: Arial, sans-serif;
}

[role="tablist"] {
    display: flex;
    border-bottom: 2px solid #ddd;
    margin-bottom: 0;
}

[role="tab"] {
    background: #f5f5f5;
    border: 1px solid #ddd;
    border-bottom: none;
    padding: 10px 20px;
    cursor: pointer;
    font-size: 14px;
    transition: background-color 0.2s;
}

[role="tab"]:hover {
    background: #e9e9e9;
}

[role="tab"][aria-selected="true"] {
    background: white;
    border-bottom: 2px solid white;
    margin-bottom: -2px;
    font-weight: bold;
}

[role="tabpanel"] {
    padding: 20px;
    border: 1px solid #ddd;
    border-top: none;
    background: white;
}

[role="tabpanel"][hidden] {
    display: none;
}
</style>

このタブUI実装では、各タブボタンにaria-controls属性を設定し、対応するタブパネルのIDを指定しています。また、aria-selected属性で現在選択されているタブを示し、tabindex属性でキーボードナビゲーションの順序を適切に管理しています。

重要なのは、タブパネル側にもaria-labelledby属性を設定し、どのタブによって制御されているかを明示している点です。これにより、スクリーンリーダーユーザーがタブパネル内にフォーカスした際に、現在どのタブの内容を閲覧しているかを正確に理解できます。

スクリーンリーダーへの作用と具体的な読み上げ例

aria-controlsが適切に実装された場合、スクリーンリーダーはユーザーに対してより豊富で有用な情報を提供できるようになります。実際の読み上げ例を通じて、aria-controlsの効果を具体的に理解していきましょう。

基本的なボタン・パネル構造での読み上げ例:

<button aria-controls="help-content" aria-expanded="false">
    ヘルプを表示
</button>
<div id="help-content" role="region" aria-labelledby="help-button" style="display: none;">
    このセクションではヘルプ情報を提供します。
</div>

スクリーンリーダー(NVDA/JAWSなど)での読み上げ:

  • ボタンにフォーカス時:「ヘルプを表示、ボタン、折りたたみ済み、help-contentを制御」
  • ボタン押下後:「ヘルプを表示、ボタン、展開済み」
  • パネル内移動時:「ヘルプを表示ボタンによって制御される領域、このセクションではヘルプ情報を提供します。」

タブインターフェースでの読み上げ例:

  • タブボタンフォーカス時:「タブ1、タブ、選択済み、3つ中1つ目、content-tab1を制御」
  • タブパネル移動時:「タブ1タブパネル、コンテンツ1」
<button role="tab" aria-controls="content-tab1" aria-selected="true">タブ1</button>
<div role="tabpanel" id="content-tab1" aria-labelledby="tab1">コンテンツ1</div>

これらの読み上げ情報により、スクリーンリーダーユーザーは以下のような恩恵を受けられます:

操作の予測可能性が向上します。ボタンを押す前に、どの要素が影響を受けるかを事前に知ることができるため、意図しない動作による混乱を避けられます。

コンテキストの理解が深まります。現在閲覧しているコンテンツがどの操作要素によって制御されているかを把握できるため、関連する操作を見つけやすくなります。

効率的なナビゲーションが可能になります。制御関係が明確に示されることで、ユーザーは目的のコンテンツや操作により迅速にアクセスできます。

重要な注意点として、aria-controlsの情報は全てのスクリーンリーダーで同じように読み上げられるわけではありません。NVDA、JAWS、VoiceOver、TalkBackなど、各スクリーンリーダーには独自の読み上げ方式があります。そのため、実装後は複数のスクリーンリーダーでのテストを行い、想定通りの情報が伝わっているかを確認することが重要です。

また、aria-controlsの効果を最大化するには、他のARIA属性との適切な組み合わせが不可欠です。aria-expandedaria-selectedaria-labelledbyなどの関連属性を併用することで、より完全なアクセシビリティ情報を提供できます。これらの属性が連携することで、スクリーンリーダーユーザーにとって直感的で使いやすいWebインターフェースが実現されるのです。

JavaScriptで aria-controls を動的に操作する実践

現代のWebアプリケーションでは、ユーザーの操作に応じてUIが動的に変化するのが当たり前となっています。このような動的なUIにおいて、aria-controls属性を適切に操作することは、アクセシビリティを維持する上で極めて重要です。静的なHTMLで設定したaria-controlsだけでは対応できない複雑なインタラクションに対して、JavaScriptを活用した動的な制御方法を詳しく解説していきます。

動的aria-controls操作の基本概念

JavaScriptでaria-controlsを動的に操作する際に最も重要なのは、ユーザーの操作タイミングと支援技術への情報伝達のタイミングを適切に同期させることです。単純に属性値を変更するだけでなく、関連するARIA属性(aria-expandedaria-selectedaria-hiddenなど)との連携も考慮する必要があります。

まず、動的操作の基本パターンを確認しましょう:

// 基本的な動的aria-controls設定
function setAriaControls(controlElement, targetElementId) {
    // aria-controls属性を設定
    controlElement.setAttribute('aria-controls', targetElementId);

    // 関連するARIA属性も同時に更新
    const targetElement = document.getElementById(targetElementId);
    if (targetElement) {
        // 制御対象要素の状態も同期
        targetElement.setAttribute('aria-labelledby', controlElement.id);
    }
}

// 動的にaria-controlsを削除
function removeAriaControls(controlElement) {
    controlElement.removeAttribute('aria-controls');
}

この基本パターンを理解した上で、実際のUI コンポーネントでの実装例を見ていきましょう。

アコーディオンUIでの実装例

アコーディオンUIは、aria-controlsの動的操作を学ぶのに最適な例です。ユーザーがヘッダーをクリックすると、対応するコンテンツパネルが展開・収縮し、この状態変化を支援技術に正確に伝える必要があります。

<div class="accordion">
    <button class="accordion-header" id="accordion-btn-1"
            aria-controls="accordion-panel-1"
            aria-expanded="false">
        セクション1のタイトル
    </button>
    <div class="accordion-panel" id="accordion-panel-1"
         aria-labelledby="accordion-btn-1"
         aria-hidden="true">
        <p>セクション1のコンテンツがここに表示されます。</p>
    </div>

    <button class="accordion-header" id="accordion-btn-2"
            aria-controls="accordion-panel-2"
            aria-expanded="false">
        セクション2のタイトル
    </button>
    <div class="accordion-panel" id="accordion-panel-2"
         aria-labelledby="accordion-btn-2"
         aria-hidden="true">
        <p>セクション2のコンテンツがここに表示されます。</p>
    </div>
</div>

対応するJavaScriptの実装は以下のようになります:

class AccessibleAccordion {
    constructor(accordionContainer) {
        this.container = accordionContainer;
        this.headers = this.container.querySelectorAll('.accordion-header');
        this.panels = this.container.querySelectorAll('.accordion-panel');

        this.init();
    }

    init() {
        this.headers.forEach((header, index) => {
            header.addEventListener('click', (e) => {
                this.togglePanel(e.target, index);
            });

            // キーボード操作への対応
            header.addEventListener('keydown', (e) => {
                this.handleKeyDown(e, index);
            });
        });
    }

    togglePanel(headerElement, panelIndex) {
        const panel = this.panels[panelIndex];
        const isExpanded = headerElement.getAttribute('aria-expanded') === 'true';

        if (isExpanded) {
            // パネルを閉じる
            this.closePanel(headerElement, panel);
        } else {
            // 他のパネルを全て閉じる(シングルアコーディオンの場合)
            this.closeAllPanels();
            // 選択されたパネルを開く
            this.openPanel(headerElement, panel);
        }
    }

    openPanel(headerElement, panel) {
        // aria-controls は既にHTMLで設定済みだが、動的に確認・設定
        const controlsId = panel.id;
        headerElement.setAttribute('aria-controls', controlsId);
        headerElement.setAttribute('aria-expanded', 'true');

        panel.setAttribute('aria-hidden', 'false');
        panel.style.display = 'block';

        // フォーカス管理(必要に応じて)
        // panel.querySelector('a, button, input, [tabindex="0"]')?.focus();
    }

    closePanel(headerElement, panel) {
        headerElement.setAttribute('aria-expanded', 'false');
        panel.setAttribute('aria-hidden', 'true');
        panel.style.display = 'none';

        // aria-controlsは保持(制御関係は継続)
        // headerElement.removeAttribute('aria-controls'); // 通常は削除しない
    }

    closeAllPanels() {
        this.headers.forEach((header, index) => {
            const panel = this.panels[index];
            this.closePanel(header, panel);
        });
    }

    handleKeyDown(event, index) {
        const { key } = event;

        switch (key) {
            case 'ArrowUp':
                event.preventDefault();
                this.focusPreviousHeader(index);
                break;
            case 'ArrowDown':
                event.preventDefault();
                this.focusNextHeader(index);
                break;
            case 'Home':
                event.preventDefault();
                this.headers[0].focus();
                break;
            case 'End':
                event.preventDefault();
                this.headers[this.headers.length - 1].focus();
                break;
        }
    }

    focusPreviousHeader(currentIndex) {
        const previousIndex = currentIndex > 0 ? currentIndex - 1 : this.headers.length - 1;
        this.headers[previousIndex].focus();
    }

    focusNextHeader(currentIndex) {
        const nextIndex = currentIndex < this.headers.length - 1 ? currentIndex + 1 : 0;
        this.headers[nextIndex].focus();
    }
}

// 初期化
document.addEventListener('DOMContentLoaded', () => {
    const accordions = document.querySelectorAll('.accordion');
    accordions.forEach(accordion => {
        new AccessibleAccordion(accordion);
    });
});

この実装では、aria-controlsを動的に管理しながら、以下の重要なポイントを押さえています:

  1. 状態の同期: aria-expandedaria-hiddenaria-controlsと連動させて更新
  2. キーボードナビゲーション: 矢印キーでのヘッダー間移動をサポート
  3. フォーカス管理: 適切な要素にフォーカスが移動するよう制御
  4. スクリーンリーダー対応: 状態変化が正確に伝わるよう属性を適切に設定

動的タブUIでの複合的な実装

より複雑な例として、動的にタブが追加・削除されるタブUIでのaria-controls操作を見てみましょう:

class DynamicTabInterface {
    constructor(tabContainer) {
        this.container = tabContainer;
        this.tabList = this.container.querySelector('[role="tablist"]');
        this.tabPanelContainer = this.container.querySelector('.tab-panels');
        this.tabs = [];
        this.panels = [];
        this.currentTabIndex = 0;

        this.init();
    }

    init() {
        // 既存のタブの初期化
        this.updateTabsAndPanels();
        this.bindEvents();
    }

    updateTabsAndPanels() {
        this.tabs = Array.from(this.tabList.querySelectorAll('[role="tab"]'));
        this.panels = Array.from(this.tabPanelContainer.querySelectorAll('[role="tabpanel"]'));

        // 各タブのaria-controls属性を確認・設定
        this.tabs.forEach((tab, index) => {
            const panel = this.panels[index];
            if (panel) {
                tab.setAttribute('aria-controls', panel.id);
                panel.setAttribute('aria-labelledby', tab.id);
            }
        });
    }

    addTab(tabLabel, tabContent) {
        const tabId = `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
        const panelId = `panel-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;

        // 新しいタブボタンを作成
        const newTab = document.createElement('button');
        newTab.setAttribute('role', 'tab');
        newTab.setAttribute('id', tabId);
        newTab.setAttribute('aria-controls', panelId);
        newTab.setAttribute('aria-selected', 'false');
        newTab.setAttribute('tabindex', '-1');
        newTab.textContent = tabLabel;

        // 新しいタブパネルを作成
        const newPanel = document.createElement('div');
        newPanel.setAttribute('role', 'tabpanel');
        newPanel.setAttribute('id', panelId);
        newPanel.setAttribute('aria-labelledby', tabId);
        newPanel.setAttribute('aria-hidden', 'true');
        newPanel.innerHTML = tabContent;
        newPanel.style.display = 'none';

        // DOMに追加
        this.tabList.appendChild(newTab);
        this.tabPanelContainer.appendChild(newPanel);

        // 内部配列を更新
        this.updateTabsAndPanels();

        // イベントリスナーを再バインド
        this.bindEvents();

        // 新しいタブをアクティブにする
        this.activateTab(this.tabs.length - 1);

        return { tab: newTab, panel: newPanel };
    }

    removeTab(tabIndex) {
        if (tabIndex < 0 || tabIndex >= this.tabs.length) return;

        const tabToRemove = this.tabs[tabIndex];
        const panelToRemove = this.panels[tabIndex];

        // aria-controls関係を解除
        tabToRemove.removeAttribute('aria-controls');
        panelToRemove.removeAttribute('aria-labelledby');

        // DOMから削除
        tabToRemove.remove();
        panelToRemove.remove();

        // 配列を更新
        this.updateTabsAndPanels();

        // アクティブタブの調整
        if (tabIndex === this.currentTabIndex) {
            const newActiveIndex = Math.min(this.currentTabIndex, this.tabs.length - 1);
            this.activateTab(newActiveIndex);
        } else if (tabIndex < this.currentTabIndex) {
            this.currentTabIndex--;
        }

        // イベントリスナーを再バインド
        this.bindEvents();
    }

    activateTab(tabIndex) {
        if (tabIndex < 0 || tabIndex >= this.tabs.length) return;

        // 全てのタブを非アクティブに
        this.tabs.forEach((tab, index) => {
            const panel = this.panels[index];
            const isActive = index === tabIndex;

            tab.setAttribute('aria-selected', isActive.toString());
            tab.setAttribute('tabindex', isActive ? '0' : '-1');

            if (panel) {
                panel.setAttribute('aria-hidden', (!isActive).toString());
                panel.style.display = isActive ? 'block' : 'none';

                // aria-controls関係を再確認
                if (isActive && tab.getAttribute('aria-controls') !== panel.id) {
                    tab.setAttribute('aria-controls', panel.id);
                }
            }
        });

        this.currentTabIndex = tabIndex;
        this.tabs[tabIndex].focus();
    }

    bindEvents() {
        // 既存のイベントリスナーをクリア(重複回避)
        this.tabs.forEach(tab => {
            tab.replaceWith(tab.cloneNode(true));
        });

        // 更新された要素を再取得
        this.updateTabsAndPanels();

        // 新しいイベントリスナーを追加
        this.tabs.forEach((tab, index) => {
            tab.addEventListener('click', () => {
                this.activateTab(index);
            });

            tab.addEventListener('keydown', (e) => {
                this.handleKeyDown(e, index);
            });
        });
    }

    handleKeyDown(event, tabIndex) {
        const { key } = event;

        switch (key) {
            case 'ArrowLeft':
                event.preventDefault();
                const prevIndex = tabIndex > 0 ? tabIndex - 1 : this.tabs.length - 1;
                this.activateTab(prevIndex);
                break;
            case 'ArrowRight':
                event.preventDefault();
                const nextIndex = tabIndex < this.tabs.length - 1 ? tabIndex + 1 : 0;
                this.activateTab(nextIndex);
                break;
            case 'Home':
                event.preventDefault();
                this.activateTab(0);
                break;
            case 'End':
                event.preventDefault();
                this.activateTab(this.tabs.length - 1);
                break;
            case 'Delete':
                if (this.tabs.length > 1) {
                    event.preventDefault();
                    this.removeTab(tabIndex);
                }
                break;
        }
    }
}

// 使用例
document.addEventListener('DOMContentLoaded', () => {
    const tabContainer = document.querySelector('.dynamic-tab-container');
    const tabInterface = new DynamicTabInterface(tabContainer);

    // 動的にタブを追加する例
    document.querySelector('#add-tab-btn')?.addEventListener('click', () => {
        tabInterface.addTab('新しいタブ', '<p>動的に追加されたコンテンツです。</p>');
    });
});

この実装では、タブの動的な追加・削除に際してaria-controls属性を適切に管理し、以下の課題に対応しています:

  1. 一意性の保証: 動的に生成されるIDが重複しないよう配慮
  2. 関係性の維持: タブとパネルの関係をaria-controlsaria-labelledbyで双方向に設定
  3. メモリリーク対策: 不要になったイベントリスナーの適切な削除
  4. フォーカス管理: タブ削除時のフォーカス移動を適切に処理

JavaScriptによるaria-controlsの動的操作は、ただ属性値を変更するだけでなく、UIの状態変化を支援技術に正確に伝達するための総合的なアプローチが必要です。関連するARIA属性との連携、キーボード操作への対応、そして適切なフォーカス管理を組み合わせることで、真にアクセシブルな動的UIを実現できます。

よくある質問(FAQ)

aria-controls を使わなくても UI は動いていますが、それでも必要ですか?

はい、aria-controls は視覚的な挙動ではなく、スクリーンリーダーなど支援技術に構造や関係性を伝えるための属性です。例えば、あるボタンが特定のコンテンツ領域を制御する場合、視覚的にはJavaScriptで制御できていたとしても、支援技術にはその関係が伝わりません。aria-controls を使うことで「このボタンはこの要素を制御しています」と機械的に明示でき、アクセシビリティの向上につながります。

aria-controls はどんな HTML 要素に使えますか?

一般的には 操作する側(例:ボタン、リンク)に付ける属性です。対象となる要素には id を指定しておき、操作する要素の aria-controls 属性にその id を設定します。例としては <button aria-controls="tab1"> のように使います。

aria-controls で複数の要素を指定したい場合はどうすればいい?

aria-controlsスペース区切りで複数のidを指定できます。たとえば、aria-controls="section1 section2" のように記述します。ただし、スクリーンリーダーによっては対応に差があるため、実装後は必ず支援技術での検証を行うことが重要です。

aria-controls を使っても Lighthouse で「意味のないARIA属性」と警告されます。なぜ?

この警告の多くは、aria-controls の対象となる id が存在しない、または要素間に意味的な関連がない場合に発生します。例えば、ボタンに aria-controls="menu1" を付けたにもかかわらず、id="menu1" の要素がHTML内に存在しない場合などです。また、装飾目的のアイコンなどに使うのは誤用です。属性を正しく設定し、意味的な関連性がある場面でのみ使用しましょう。

aria-controlsaria-expanded はどう違う?一緒に使うべき?

aria-controls は「何を制御するか」を伝える属性であり、aria-expanded は「開閉状態(展開/非展開)」を示す属性です。たとえば、ドロップダウンメニューの場合、ボタンには以下のように両方使うことが望まれます:

<button aria-controls="dropdown1" aria-expanded="false">メニュー</button>

状態が変化するたびに aria-expanded の値を true / false に切り替えることで、視覚以外のユーザーにも現在の状態を正しく伝えられます。

aria-controls を使う場面はどんなUIが多い?

主に以下のような コンテンツ切り替えや表示制御を伴うUI に用いられることが多いです:

  • タブ切り替え(Tab UI)
  • アコーディオン(FAQなど)
  • ドロップダウンメニュー
  • モーダルダイアログのトリガー
  • 表示/非表示が切り替わるナビゲーション

これらはすべて、ユーザー操作によって表示される領域が変化するため、aria-controls で関係性を明示するのが望ましいです。

aria-controls はSEOに影響ありますか?

直接的にSEO順位を左右する要素ではありませんが、Googleはアクセシビリティを重視しており、間接的な評価要素になる可能性はあります。また、ユーザー体験の向上(特に支援技術利用者に対して)に貢献することで、コンテンツの品質向上にもつながります。

まとめ

ここまで、aria-controlsの使い方について、基本的な定義から具体的なHTML・JavaScriptの実装例、さらには複数要素への対応方法やフレームワーク別の活用方法まで、幅広く解説してきました。

「なんとなく使っていたけど、実は正しく理解できていなかった」という方も、これを読めば自信を持って実装できるようになったのではないでしょうか?

重要ポイント

  • aria-controls は「このボタンがどの要素を制御するか」をスクリーンリーダーなどに伝える属性
  • 対象となる要素には id を付けて、ボタン側に aria-controls="そのid" を書くのが基本
  • 複数の要素を制御したいときは、スペース区切りで複数のidを指定できる
  • aria-expandedaria-hidden といった属性と組み合わせることで、より正確なUIの状態を伝えられる
  • BootstrapやReact、Vue.jsといったフレームワークでも、HTML構造と一緒にしっかり設定することが重要
  • 「なんでもaria-controlsを付ければいい」というわけではなく、意味のある関連性がある場合のみ使うのがルール

アクセシビリティ対応って、最初は少しとっつきにくいかもしれません。でも、こういった属性を1つずつ正しく理解していくことで、すべてのユーザーにとって使いやすいWebを作れるようになります。

HTML&CSSプログレスバー実装ガイド|おしゃれ・アニメ・円形・React対応まで+コピペOKサンプル付き
HTMLプログレスバーのデザインをもっと自由に!CSSで色や形をカスタマイズ、アニメーションやJavaScriptで動的な表現を加える方法をコピペで使えるサンプルコードと共に紹介します。円形プログレスバーの実装、Reactでの利用に加え、よくあるトラブルについても。初心者からステップアップしたい方におすすめです。
タイトルとURLをコピーしました