.is-disabled(class属性)ではなく:disabledまたはaria-disabledを使おう!

UI開発者 寒川

この記事はミツエーリンクスアドベントカレンダー2019 - Qiitaの16日目の記事です。

突然ですが、みなさんはボタンなどを非活性状態にする場合はどのように行っていますか?

button要素の場合はdisabled属性で以下のようにします。

<button type="button" disabled>メニュー</button>

上記のようにマークアップするとTabキー操作によるフォーカスはされなくなり、スクリーンリーダーでは「ボタン使用不能 メニュー」や「メニュー 無効 ボタン」といった形で読み上げられ、非活性状態であることを伝えることができます。

では、disabled属性を使えない要素の場合はどのように対処すべきでしょうか。

以下はa要素でよく見かける例ですが、class属性を付与して対処していませんか?

<a href="#menu" class="is-disabled">メニュー</a>

上記のようにマークアップするとTabキー操作によるフォーカスができてしまいます。加えてスクリーンリーダーでは「メニュー リンク」と読み上げられてしまい、視覚的表現以外ではリンクが非活性状態であることを伝えることができません。

これではユーザーによって「button要素にdisabled属性を設定した」場合と伝わり方が異なってしまうので、あまりよい方法とは言えないでしょう。

そこで、本記事では非活性状態であることを伝えるためのよりよい方法をご紹介します。

a要素をプレースホルダ化する

a要素はhref属性を設定することでハイパーリンクになりますが、href属性を設定しない場合はプレースホルダとなります。

<a>メニュー</a>

上記のようにマークアップするとTabキー操作によるフォーカスはされなくなり、スクリーンリーダーでは「メニュー」と読み上げられ、リンクとは伝えなくなります。

プレースホルダ化はhref属性を削除しても支障がない場合やJavaScriptイベントが実装されていない場合に活用できるのではないでしょうか。

aria-disabled属性を設定する

href属性を削除すると都合が悪い場合やJavaScriptイベントに対して非活性処理を実装したい場合は、aria-disabled属性を用いる方法で対処しましょう。

まずはaria-disabled属性の大まかな紹介です。

  • 編集や操作ができないことを示します。
  • aria-disabled属性を設定した要素およびその子孫にあたるフォーカス可能な要素に適応されます。
  • disabled属性が使用できない要素に対して使用してください。
  • 非活性処理自体はJavaScriptで実装する必要があります。

より詳しくは以下のドキュメントを参照してください。

主に以下のような場面で使用します。

  • WAI-ARIAを利用してフォーム系の要素を自作する場合
  • 上記以外にも(基本的にはbutton要素を使いますが)role="button"role="tab"などを使用している場合
  • スムーススクロール機能やダイアログ機能の起動元にa要素を使用している場合

基本的にaria-disabled属性はtrueを設定して非活性状態を示し、解除する場合には属性自体を取り除いてしまいましょう。同時に非活性状態の場合はTabキー操作によるフォーカスがされないようにtabindex属性に-1を設定します。

それでは実際にマークアップを書いてみます。

<a href="#menu" aria-disabled="true" tabindex="-1">メニュー</a>

上記のようにマークアップするとTabキー操作によるフォーカスはされなくなり、スクリーンリーダーでは「使用不能リンク メニュー」や「メニュー 無効 リンク」といった形で読み上げられ、非活性状態であることを伝えることができます。

非活性処理を実装する

マークアップは書けましたが、このままではハイパーリンクやJavaScriptイベントが動作してしまうので、非活性用の処理をJavaScriptで実装します。

以下はクリックした場合に「要素が非活性状態か確認した」例になります。

elem.addEventListener('click', (event) => {
    const btn = event.currentTarget;

    // 対象が非活性状態か確認
    if (btn.getAttribute('aria-disabled') === 'true') {
        event.preventDefault();
        event.stopPropagation();

        return;
    }
});

こうすることでaria-disabled属性がtrueで設定されている場合はイベントが実行されなくなりました。

JavaScriptで使いやすくする

disabled属性は使用可能な場面と使い分けが必要です。

disabled属性が設定可能な要素は主に以下の要素になります。

  • button
  • fieldset
  • input
  • link[rel="stylesheet"]
  • option
  • optgroup
  • select
  • textarea

要素に応じて非活性用の属性を適切に設定できるように、「非活性を設定」「非活性を解除」「要素が非活性状態か確認する」3つの関数を用意します。

((doc) => {
    'use strict';

    const ARIA_DISABLED_ELEMS = 'a[href], area[href], [aria-controls], [role="button"], [role="tab"]';
    const DISABLED_ELEMS = 'button, fieldset, input, link[rel="stylesheet"], option, optgroup, select, textarea';

    /**
     * 非活性を設定
     *
     * @param {Object} elem 対象の要素
     * @return {void}
     * @example
     *     addDisabled(elem);
     */
    const addDisabled = (elem) => {
        if (elem.matches(DISABLED_ELEMS)) {
            elem.disabled = true;
        } else if (elem.matches(ARIA_DISABLED_ELEMS)) {
            elem.setAttribute('aria-disabled', 'true');
        }
    };
    /**
     * 非活性を解除
     *
     * @param {Object} elem 対象の要素
     * @return {void}
     * @example
     *     removeDisabled(elem);
     */
    const removeDisabled = (elem) => {
        if (elem.matches(DISABLED_ELEMS)) {
            elem.disabled = false;
        } else if (elem.matches(ARIA_DISABLED_ELEMS)) {
            elem.removeAttribute('aria-disabled');
        }
    };
    /**
     * 要素が非活性状態か確認する
     *
     * @param {Object} elem 確認対象の要素
     * @return {Boolean} 非活性状態かの判定結果
     * @example
     *     if (isDisabled(elem)) {
     *         // ...
     *     }
     */
    const isDisabled = (elem) => {
        if (
            elem.matches(DISABLED_ELEMS) &&
            elem.disabled
        ) {
            return true;
        } else if (
            elem.matches(ARIA_DISABLED_ELEMS) &&
            elem.getAttribute('aria-disabled') === 'true'
        ) {
            return true;
        }

        return false;
    };
})(window.document);
  • aria-disabled属性を設定したい要素を定数ARIA_DISABLED_ELEMSとして宣言しておきます。
  • disabled属性が設定可能な要素を定数DISABLED_ELEMSとして宣言しておきます。
  • 非活性を設定する際は、addDisabled(elem);のようにすることでJavaScriptが対象要素を判定してdisabledまたはaria-disabledのどちらか適切な属性を設定します。
  • 非活性を解除する際は、removeDisabled(elem);のようにすることでJavaScriptが対象要素を判定してdisabledまたはaria-disabledの属性を取り除きます。
  • 要素が非活性状態か確認する場合は、if (isDisabled(elem)) {のようにすることでJavaScriptが対象要素を判定可能になります。

以下はクリックした場合に「要素が非活性状態か確認する」関数を使用した例になります。

elem.addEventListener('click', (event) => {
    const btn = event.currentTarget;

    // 対象が非活性状態か確認
    if (isDisabled(btn)) {
        event.preventDefault();
        event.stopPropagation();

        return;
    }
});

これでdisabled属性とaria-disabled属性の使い分けができるようになりました。

aria-disabled属性を監視してtabindex属性を設定する

先ほど紹介した「非活性を設定」と「非活性を解除」の関数にはあえてtabindex属性の処理を書きませんでした。

aria-disabled属性を静的に設定した場合にtabindex属性を付け忘れるかもしれないので、MutationObserverを使って対象の要素を監視しaria-disabled属性の有無に合わせてtabindex属性の設定する処理を書いてみます。

((doc) => {
    'use strict';

    /**
     * 指定した要素および属性を監視して、aria-disabled属性に対するtabindexを制御する機能
     *
     * @param {String} selectors querySelectorAll() に指定する値
     * @param {Object} options 設定
     * @return {void}
     */
    const tabindexToAriaDisabled = (selectors, options) => {
        const targets = doc.querySelectorAll(selectors);

        if (targets.length <= 0) {
            return;
        }

        const config = Object.assign({
            customDataAttrSuffixName: 'keep-tabindex' // tabindexを保持しておくためのdata-*属性
        }, options);

        const observer = new MutationObserver((mutations) => {
            mutations.forEach((m) => {
                const elem = m.target;
                const watchAttrName = m.attributeName;

                if (watchAttrName === 'aria-disabled') {
                    if (elem.getAttribute('aria-disabled') === 'true') {
                        elem.tabIndex = -1;
                    } else {
                        const keepTabindex = elem.getAttribute('data-' + config.customDataAttrSuffixName);

                        // data-keep-tabindex属性に保管しておいたtabindex属性値が適切な値(-1か0以上の整数)か確認する
                        if (/^(-1|\d+)$/.test(keepTabindex)) {
                            elem.tabIndex = keepTabindex;
                        } else {
                            elem.removeAttribute('tabindex');
                        }
                    }
                }

                if (watchAttrName === 'tabindex') {
                    if (elem.getAttribute('aria-disabled') !== 'true') {
                        // tabindex属性が更新された場合に、設定されているaria-disabled属性値が'true'ではない場合はdata-keep-tabindex属性に保管し直す
                        elem.setAttribute('data-' + config.customDataAttrSuffixName, elem.getAttribute('tabindex'));
                    }
                }
            });
        });

        targets.forEach((elem) => {
            const isAriDisabledTrue = elem.getAttribute('aria-disabled') === 'true';

            // 既存のtabindex属性をdata-keep-tabindex属性に保管しておく
            elem.setAttribute('data-' + config.customDataAttrSuffixName, isAriDisabledTrue ? null : elem.getAttribute('tabindex'));
            observer.observe(elem, {
                attributes: true,
                attributeFilter: ['aria-disabled', 'tabindex'] // 監視対象となる属性リスト
            });

            if (isAriDisabledTrue) {
                elem.tabIndex = -1;
            }
        });
    };

    // 実行
    doc.addEventListener('DOMContentLoaded', () => {
        tabindexToAriaDisabled('[href*="#"], [role="button"]');
    });
})(window.document);
  • tabindexToAriaDisabled();の引数に指定したセレクタを監視対象にします。
  • 監視を開始する前に、ページ読み込みまでに監視対象に設定されてたtabindex属性値をdata-keep-tabindex属性に保管しておきます。存在しない場合はnullを設定します。
  • その後、引数に指定したセレクタに加えattributeFilterに指定した属性(aria-disabledtabindex)を対象に監視を開始します。
  • ページ読み込みまでにaria-disabledtrueに設定されている対象にMutationObservertabindex="-1"を設定します。
  • ページ読み込み後は、関数での処理や開発者ツールで設定したaria-disabled属性に合わせてtabindex属性が自動で設定されます。

以下が実際に動作した例になります。

aria-disabled属性をMutationObserverで監視した例

元からtabindex属性が存在していた場合は以下のように動作します。

元からtabindex属性が存在していた場合にaria-disabled属性をMutationObserverで監視した例

今回の監視処理はあくまでaria-disabled属性を設定した要素のみにしかtabindex属性を設定できません。子孫要素のフォーカス可能な要素に対して行いたい場合はまた別の処理が必要になります。

まとめ

  • 非活性状態にする場合は、見た目だけではなくその振る舞いや意味も伝えられるようにHTMLをマークアップしましょう。
  • そのためにdisabled属性を使いましょう。
  • disabled属性が使えない要素の場合はaria-disabled属性を使いましょう。
  • JavaScriptで工夫すれば、たくさんある対象要素を分類し適切な処理が実現できるでしょう。
  • a要素のプレースホルダ化で済ませられる場合は低コストで対処できるので、選択肢の1つになるのではないでしょうか。
  • 対象がJavaScriptに依存するかどうかが使い分けのポイントになるでしょう。

以上になります。

早いもので、今年も残すところあと半月になりましたね。それではよい年の瀬をお過ごしください。