ページ本文へ移動
炭火ブログ

MENU

ねこ ねこ

アクセシビリティ重視のdialog式ハンバーガーメニュー解説

JavaScriptを知りたいと思い、自作のテーマに組み込める機能はないかと調べると、それまで使っていたCSSだけのハンバーガーメニューは色々な問題があることを知りました。Input要素でボタンを作ってもTabキーでフォーカスが効きません。できるだけ多くの方にとって使いやすいWebを提供できるように「アクセシビリティ」に配慮して作成する必要があることを今まで考慮していませんでした。

学習を兼ねてアクセシビリティを最優先に、ハンバーガメニューを作ることにし、仕様を調べたところ以下のリストの様に作れば良いらしいことがわかりました。特にdialog要素を使うべきで、現在Chromium系ブラウザ(Chorme,Edgeなど)はJavaScriptを使わずにdialog要素のハンバーガーメニューが作成できるようになっているようです。

dialog要素を使ったハンバーガーメニューに必要な機能

dialog要素は最上位のレイヤーに位置する

これまで使っていたCSSだけのメニューと同じ、CSSで書かれた3本線が動いて閉じるマークに変化するアニメーションを使います。ただdialog要素はshowModal()で開くと最上位に表示されz-indexに影響を受けません。position:fixedで固定されたbuttonはdialog要素とdialog:backdropに覆われて操作できません。そこで、同じ形のbuttonをdialog要素内におき、2つのbutton要素を重ねる事で対処します。

フォーカストラップの範囲は議論のあるところ

「フォーカストラップ」といわれる、メニューが開いた時のフォーカスの制御は、JavaScriptでメニューの中のリンクに制限しています。これには異論もあるようで、ブラウザの機能(タブやメニューバー)にアクセスするほうが良いという意見も多く見ました。下のコードのJavaScriptに含まれるフォーカストラップの部分を削除すればdialog要素の標準機能である前記のメニューとブラウザの機能にアクセスできるようになります。

CSSでレイアウトの動きを抑制

いつもFirefoxを使っているので今まで知らなかったのですが、Chromeはメニューバーに幅があり全画面と解除を切り替えるとレイアウトが横に動きます。これはCSSで修正できます。常にスクロールバーの幅を保持する事で全画面になってもレイアウトの動きが無くなります。

html {
    scrollbar-gutter: stable;
}

CSSでメイン画面のスクロールを止める

メニューが開いたときにメイン画面を不活性にするためにスクロールを止めます。JavaScriptで止めるかCSSで止めるか、JavaScriptの場合はスクロールバーをマウスでドラッグすると、メイン画面は動きますがそれ以外の操作では動きません。CSSでoverflow:hiddenだけを使う方法はiPhoneのSafariで効果がありません。そこで次のプロパティを使います。

html:has(dialog#d1[open]) {
    overflow: hidden;
    overscroll-behavior: none
}

display:noneをアニメーションさせるには

通常はtransitionプロパティによってCSSアニメーションを動かしますが、display:noneからdisplay:blockに変化する場合はこの方法は使えません。そこで@starting-styleを使いdisplay:noneの時のCSSを定義します。下はdialog要素のアニメーションに関する部分だけを抜き出したものです。変化の前後に加えて@starting-styleによる定義が必要です。この時、display:noneで消えているにも関わらずopacity:0が必要なのは、opacityプロパティでアニメーションする場合は全てにopacityの設定が必要だからです。ない場合は表示されるアニメーションだけになり、消える際はアニメーションがなく0.8秒後に一斉に消えます。

dialog#d1 {
    opacity: 0;
    transition: all .8s allow-discrete;
    
}
dialog#d1[open] {
    opacity: 1
}

@starting-style {
    dialog#d1[open]{
        opacity: 0
    }
}

Safariのフォーカスを消すには

すべてが思うように動作しiPhoneのSafariでメニューを開くと、autofocus属性を付与したリンクにフォーカスが表示されます。生成AIにたずねても解決できなくて困っていたところ、解決方法を的確に解説されているのを見つけられたのは幸運でした。

方法はマウスなどのキーボード以外の操作を検知した場合にhtml要素に属性を付与し、CSSでoutline: noneによってフォーカスを消すというもので、わかりやすい解説のおかげで私でも実装できました。

Safariの予期しない動作

daialog要素を動かすとiPhoneのSafariで動作がおかしいです。まず、dialog要素を表示させた後に消えるアニメーションの最中は最上位の特性が消えます。おそらくz-index:0の状態です。消えるときに画像の下に潜り込んだり他のz-indexを指定された要素の下に表示されるので、dialog要素にz-indexを使っています。

dialog要素には初期値でposition:fixedがかかっていますが、省略するとiPhoneのSafariで動作に不具合が出るようです。あわせてinset:0を追加すると良いです。

コピペで使えるHTML・JavaScript・CSS

以下のHTML・JavaScript・CSSを使えばモバイルで使えるようにしました。

<button id="d2" class="d1 d5" type="button" aria-expanded="false" aria-controls="d1" aria-label="メニューを開く"><span class="d2"></span></button>
<dialog id="d1" aria-labelledby="d3">
    <nav>
        <h1 id="d3">メニュー</h1>
        <a class="d3" autofocus href="">リンク1</a>
        <a class="d3" href="">リンク2</a>
        <a class="d3" href="">リンク3</a>
    </nav>
    <button class="d4 d5" type="button" aria-expanded="true" aria-controls="d1" aria-label="メニューを閉じる"><span class="d2"></span></button>
</dialog>
// ---------------------------
// グローバルイベントリスナーでマウス操作とキーボード操作を検知
// ---------------------------
document.documentElement.addEventListener('mousedown', () => {
    document.documentElement.setAttribute('dm', '');
}, { passive: true });

document.documentElement.addEventListener('keydown', (event) => {
    if (
        event.key === "Tab" ||
        event.key === "Enter" ||
        event.key === "Escape" ||
        event.key.startsWith("Arrow")
    ) {
        document.documentElement.removeAttribute('dm');
    }
}, { passive: true });

// ---------------------------
// DOM取得
// ---------------------------
const dialog = document.getElementById('d1');
const openTriggerButton = document.getElementById('d2');
const closeTriggerButton = dialog ? dialog.querySelector('.d4') : null;

// ---------------------------
// ダイアログが閉じる際の共通処理
// ---------------------------
function handleDialogClose() {

    openTriggerButton.classList.remove('io');
    closeTriggerButton.classList.remove('io');

    openTriggerButton.disabled = false;
    openTriggerButton.setAttribute('aria-expanded', 'false');
    openTriggerButton.removeAttribute('dn');
    openTriggerButton.focus({ preventScroll: true });

    closeTriggerButton.disabled = true;
    closeTriggerButton.setAttribute('aria-expanded', 'false');
    // closeTriggerButton.style.display = 'none'; // 明示的に非表示にする場合
}

// ---------------------------
// メニューを開く処理
// ---------------------------
function openMenu() {
 
    openTriggerButton.classList.add('io');
    closeTriggerButton.classList.add('io');

    openTriggerButton.disabled = true;
    openTriggerButton.setAttribute('aria-expanded', 'true');
    openTriggerButton.setAttribute('dn', '');

    closeTriggerButton.style.display = '';
    closeTriggerButton.disabled = false;
    closeTriggerButton.setAttribute('aria-expanded', 'true');

    dialog.showModal();

    // モーダル内のキー操作で dm 属性を除去
    const handleDialogKeyDown = (event) => {
        if (event.key === "Tab" || event.key.startsWith("Arrow")) {
            document.documentElement.removeAttribute('dm');
        }
    };

    dialog.addEventListener('keydown', handleDialogKeyDown, { passive: true });

    dialog.addEventListener('close', () => {
        dialog.removeEventListener('keydown', handleDialogKeyDown);
    }, { once: true });
}

// ---------------------------
// イベントリスナーの設定
// ---------------------------

// 開く専用ボタン(id=d2)
openTriggerButton.addEventListener('click', openMenu);

// 閉じる専用ボタン(class=d4)
closeTriggerButton.addEventListener('click', () => {
    if (dialog.hasAttribute('open')) {
        dialog.close();
    }
});

// ダイアログが閉じられたときの共通処理
dialog.addEventListener('close', handleDialogClose);

// Backdropクリックでダイアログを閉じる処理
dialog.addEventListener("click", (event) => {
    const rect = dialog.getBoundingClientRect();
    const inDialog =
        rect.top <= event.clientY &&
        event.clientY <= rect.top + rect.height &&
        rect.left <= event.clientX &&
        event.clientX <= rect.left + rect.width;

    if (!inDialog) {
        dialog.close();
    }
});

// ---------------------------
// フォーカストラップの実装(Tabキーでモーダル内を循環)
// ---------------------------
const focusableElementsSelector = 'a,button';

dialog.addEventListener("keydown", function (event) {
    if (event.key === "Tab") {
        event.preventDefault();

        const focusableElements = Array.from(
            dialog.querySelectorAll(focusableElementsSelector)
        );
        const focusedItemIndex = focusableElements.indexOf(document.activeElement);

        if (event.shiftKey) {
            // Shift + Tab
            if (focusedItemIndex === 0) {
                focusableElements[focusableElements.length - 1].focus();
            } else {
                focusableElements[focusedItemIndex - 1].focus();
            }
        } else {
            // Tab
            if (focusedItemIndex === focusableElements.length - 1) {
                focusableElements[0].focus();
            } else {
                focusableElements[focusedItemIndex + 1].focus();
            }
        }
    }
});

// ---------------------------
// 初期状態の設定
// ---------------------------
closeTriggerButton.disabled = true;
closeTriggerButton.setAttribute('aria-expanded', 'false');

openTriggerButton.disabled = false;
openTriggerButton.setAttribute('aria-expanded', 'false');
html {
    scrollbar-gutter: stable
}

.d5 {
    position: fixed;
    top: 15px;
    right: 15px;
    display: flex;
    height: 50px;
    width: 50px;
    justify-content: center;
    align-items: center;
    z-index: 4;
    border: none;
    border-radius: 50%;
    transition: .8s;
    background: #eee;
    cursor: pointer
}

.d2,
.d2:before,
.d2:after {
    content: '';
    display: block;
    height: 2px;
    width: 25px;
    border-radius: 2px;
    background: #444;
    transition: .3s;
    position: absolute
}

.d2:before {
    bottom: 8px
}

.d2:after {
    top: 8px
}

dialog#d1 {
    position: fixed;
    inset: 0;
    max-width: unset;
    color: unset;
    border: unset;
    overflow: unset;
    margin: unset;
    inset-inline-start: unset;
    inset-inline-end: unset;
    inset-block-start: unset;
    inset-block-end: unset;
    border: none;
    width: 300px;
    max-height: 100svh;
    height: 100vh;
    right: 0;
    top: 0;
    padding: 6em 3em 0 3em;
    background: #111;
    opacity: 0;
    transition: all .8s allow-discrete;
    z-index: 9
}

.d5.io {
    background: #333
}

.d5.io .d2 {
    background: transparent
}

.d5.io .d2:before {
    bottom: 0;
    transform: rotate(45deg);
    background: #ccc
}

.d5.io .d2:after {
    top: 0;
    transform: rotate(-45deg);
    background: #ccc
}

#d3 {
    font-size: 1em;
    margin: 0;
    font-weight: 400;
    color: #ccc
}

a.d3:visited {
    color: #ccc
}

a.d3 {
    display: block;
    color: #ccc
}

dialog::backdrop {
    background: #666666bb;
    backdrop-filter: blur(3px);
    opacity: 0;
    transition: all .3s allow-discrete
}

dialog#d1[open],
dialog#d1[open]::backdrop {
    opacity: 1
}

@starting-style {

    .d5.io .d2:before,
    .d5.io .d2:after {
        transform: rotate(0deg);
        background: #444
    }

    dialog#d1[open],
    dialog#d1[open]::backdrop {
        opacity: 0
    }
}

html:has(dialog#d1[open]) {
    overflow: hidden;
    overscroll-behavior: none;
}

:where(html[dm] dialog *) {
    outline: none
}

[data-modal-open]:where(html[dm] *) {
    outline: none
}

コード自体や解決方法を教えていただきました。文末になりますが以下の作者に大きな感謝を申し上げます。

最後までお読みいただき、誠にありがとうございます。

WordPress関連

Amazon PA APIからCreators APIへ移行|Offers V1終了の対策と方法 Amazon Creators API Amazon PAAPI廃止とCreators API完全移行への対策|5月15日 PAAPI 5.0 Deprecation WordPress静的化とCloudflare Pagesでコストゼロ運営を実現 simply-static-cloudflare-pages-wordpress さくらレンタルサーバー移転手順とCloudflare活用法 sakura hosting service-cloudflare SEOプラグイン不要!WordPress構造化データ自作手順 Structured Data Jason-ld WordPress

SEO関連

PageSpeed Insightsの長時間タスクをCSS・JS・DOMから検証 Pagespeed Insights Long Task CSPレポートの保存と可視化をCloudflareで実現する手順 create-a-csp-report-list-page ブログにCSP script-src導入でセキュリティを強固にする方法 content-security-policy-script-src Cloudflare×KVでCSPレポートを効率的に受信・保存する手順 CSP Cloudflare Workers KV AdSenseのCLS悪化と対策|自動広告の課題と遅延読み込みでの改善策 google-adsense-cls

総合ランキング

U字溝と耐火レンガで作る格安の焼鳥台の作り方と使い方 U字溝と耐火レンガで炭火を使う キャビティプロ導入で水槽管理はどう変わる?3ヶ月使用レポート キャビティプロ TOPPING Mini 300を測定値で見る!最高級の音質を誇る低価格アンプ Topping Mini 300 低価格で高音質!中華アンプとDACのASRの評価とその選び方 Fosi Audio V3 鉄久で焼鳥を焼く・焼鳥台の工夫と鉄の棒の購入と使い方 U字溝と鉄久
管理者
ほんだ

当サイトの管理者 : 炭火で美味しいものを作ることを中心に、日々の趣味についてを文章にすることで、WordPressを使ってのWebページ作成を忘れないようにするブログです。熱帯魚の世話や野菜の栽培、Linuxについて、音楽を聴くための機材やアイデアなど、興味のあることを書いています。兵庫県在住。