キー操作に対応したグローバルナビゲーション

こんにちは、ナカムラです。
グローバルナビゲーションについて、2つの記事を書いてきました。
今回はこの記事の内容を踏まえて、実際に作っていきたいと思います。

tech.arms-soft.co.jp

tech.arms-soft.co.jp

DEMO

早速ですが完成したデモです。

See the Pen キー操作に対応したグローバルナビゲーション by Nakamura (@takayo-nakamura) on CodePen.

グローバルナビゲーションの仕様

デモにも書いてありますが今回実装した処理をまとめました。

PCの仕様(768px以上)

  • グロナビのメニューがクリックできる
  • グロナビのメニューをマウスオーバー/マウスアウトでメガメニューが表示/非表示される
    ※マウスアウトしても一定時間は表示された状態にする(カーソル移動中に意図せず消えないように)
    ※スマホの時はこのイベントは外す
  • キー操作でメガメニューの開閉ができる
  • メガメニューが閉じている時はキー操作でフォーカスされない
  • メガメニューはEscapeキーで閉じることができる
  • WAI-ARIAのaria-expanded属性で開閉できることを示す

スマホの仕様(767px以下)

  • スマホではハンバーガーメニューにする
  • ハンバーガーボタンをタップするとグローバルメニューが開閉する
  • メニュー横のボタンのタップによってメガメニューを開閉する
  • グローバルナビゲーション内のリンクをタップしたらグローバルナビゲーションを非表示にする(メガメニューも閉じる)

メガメニューを開く/閉じる

クリックした時、マウスオーバーした時など、さまざまな条件でメニューを開閉します。
そのため、開く処理と閉じる処理は関数にしておきます。 メガメニューは.megaMenuに.is-activeのつけ外しと、キー操作用のbuttonタグのaria-expanded属性の値を切り替えます。(ture/false)

メニューを開く

開く前に、開きたいメニュー以外のメガメニューを既に開いている場合もあるため、閉じる処理の後に対象のメガメニューを開くようにしています。
aria-expanded属性の値をtrueにします。

  // メガメーニューを開く
  function megamenuOpen(){
    allMegamenuClose() // 全てのメガメニューを閉じる
    megaMenu.classList.add('is-active')
    const thisToggleButton = navItem.querySelector('.toggleButton')
    thisToggleButton.setAttribute( 'aria-expanded', 'true' ) 
  }

メニューを閉じる

.is-activeのついた.megaMenuを探して.is-activeを外します。
aria-expanded="true"を探してfalseにします。

// 現在開いているメガメニューを閉じる。
function allMegamenuClose(){
  const activeMegaMenus = document.querySelectorAll('.megaMenu.is-active')
  activeMegaMenus.forEach(activeMegaMenu => {
    activeMegaMenu.classList.remove('is-active')
  })
  
  const activeToggleButtons = document.querySelectorAll('.toggleButton[aria-expanded="true"]')
  activeToggleButtons.forEach(activeToggleButton => {
    activeToggleButton.setAttribute( 'aria-expanded', 'false' );
  })
}

スマホではハンバーガーメニューでグローバルナビゲーションを表示/非表示する

シンプルなのでスマホの方から解説していきます。
画面の小さなスマホなどの環境ではグローバルナビゲーションはハンバーガーメニューを実装することが多いです。
三本線で装飾したボタンをタップし、開いていたら閉じ、閉じていたら開くという処理をつけます。
javaScriptではis-menu-openというクラスをhtmlタグにつけ変え、表示はCSSで切り替えます。
※今回は特に使っていませんが、メニューが開いているときに画面全体に対して何か付与することもあるのでbodyより上のhtmlにしています。どこにつけるかは目的に合わせて変えてください。

今回はメガメニューもあるナビゲーションなので、グローバルナビゲーション自体を閉じる際に、用のなくなった開きっぱなしのメガメニューも一緒に閉じるようにallMegamenuClose()を呼び出しています。

<button id="hamburger" class="hamburger" aria-label="メニューを開閉する"><span></span></button>
const hamburger = document.getElementById('hamburger')

// スマホのハンバーガーメニュー
hamburger.addEventListener('click', () => {
  const htmlStatus = document.documentElement
  if(htmlStatus.classList.contains('is-menu-open')){
    html.classList.remove('is-menu-open')
    allMegamenuClose() // メニューが開いていたら閉じる
  } else {
    html.classList.add('is-menu-open')    
  }
})

ナビゲーション内のリンクが押された時

ナビゲーション内のリンクを押された時はナビゲーションは不要になります。
そのため、閉じる処理を加えました。
html.classList.remove('is-menu-open')はスマホにだけ必要なのでメディアクエリで判定すると丁寧ですが、クラスの付け替えだけで影響が少ないのでまとめて書いています。

// メニューをクリックしたら閉じる
const menuLinks = document.querySelectorAll('.globalNav a')
menuLinks.forEach(menuLink => {
  menuLink.addEventListener('click', () => {
    html.classList.remove('is-menu-open')
    allMegamenuClose()
  })
})

マウスオーバー/マウスアウトでメガメニューが表示/非表示される

ここからPCサイズの時のものになります。
マウス操作があるのでマウスオーバー・マウスアウトに対応します。
CSSでもできますが、マウスアウトしても一定時間は表示された状態にする(カーソル移動中に意図せず消えないように)ため、javaScriptで制御します。

// グローバルナビゲーション内の処理
navItems.forEach(navItem => {
  const toggleButton = navItem.querySelector('.toggleButton')
  let megaMenu = navItem.querySelector('.megaMenu')
  if(megaMenu){
    let timeoutMouseOver
    
    // hoverした時(PCのみ)
    function handleMouseOver(){
      timeoutMouseOut = setTimeout(megamenuOpen, 50)
      clearTimeout(timeoutMouseOver)
    }
    
    // hoverが外れた時(PCのみ)
    function handleMouseOut(){
      clearTimeout(timeoutMouseOut)
      timeoutMouseOver = setTimeout(allMegamenuClose, 150)
    }
    
    // hoverした時(PCのみ)_リサイズ時に処理を実行
    window.addEventListener('resize', toggleMouseOverListener)

    // hover時の処理
    function toggleMouseOverListener() {
      if (window.matchMedia("(min-width: 768px)").matches) {
        if (!navItem.hasMouseOverListener) {
          navItem.addEventListener('mouseover', handleMouseOver)
          navItem.addEventListener('mouseout', handleMouseOut)
          navItem.hasMouseOverListener = true;
        }
      } else {
        // 768px未満ならイベントを削除
        navItem.removeEventListener("mouseover", handleMouseOver)
        navItem.removeEventListener("mouseout", handleMouseOut)
        navItem.hasMouseOverListener = false;
      }
    }
    
    // hoverした時(PCのみ)_初回実行
    toggleMouseOverListener()
        
    // トグルボタンをクリックした時
    toggleButton.addEventListener('click', () => {
      megamenuToggle()
    })
  }
  
  // メガメーニューを開く/閉じる
  function megamenuToggle(){
    const megaMenuStatus = navItem.querySelector('.megaMenu')
    if(megaMenuStatus.classList.contains('is-active')){
      allMegamenuClose()
    } else {
      megamenuOpen()
    }
  }
  
  // メガメーニューを開く
  function megamenuOpen(){
    allMegamenuClose()
    megaMenu.classList.add('is-active')
    const thisToggleButton = navItem.querySelector('.toggleButton')
    thisToggleButton.setAttribute( 'aria-expanded', 'true' ) 
  }
})

まずはマウスオーバー/マウスアウトした時の処理を関数にしています。
マウスオーバーした時は、setTimeoutでメガメニューを開く関数megamenuOpenを少し遅れて実行しています。
閉じる処理のtimeoutMouseOverはクリアにします。
マウスアウトした時はその逆を行います。
これでsetTimeoutで設定した時間だけ開閉の処理を動かすことができます。

// hoverした時(PCのみ)
    function handleMouseOver(){
      timeoutMouseOut = setTimeout(megamenuOpen, 50)
      clearTimeout(timeoutMouseOver)
    }

 // hoverが外れた時(PCのみ)
    function handleMouseOut(){
      clearTimeout(timeoutMouseOut)
      // allMegamenuClose()
      
      timeoutMouseOver = setTimeout(allMegamenuClose, 150)
    }

toggleMouseOverListener()でPCかスマホかを判定してイベントの付け外しの処理を行います。
removeEventListenerでイベントを消すことができます。

    // hover時の処理
    function toggleMouseOverListener() {
      if (window.matchMedia("(min-width: 768px)").matches) {
        if (!navItem.hasMouseOverListener) {
          navItem.addEventListener('mouseover', handleMouseOver)
          navItem.addEventListener('mouseout', handleMouseOut)
          navItem.hasMouseOverListener = true;
        }
      } else {
        // 768px未満ならイベントを削除
        navItem.removeEventListener("mouseover", handleMouseOver)
        navItem.removeEventListener("mouseout", handleMouseOut)
        navItem.hasMouseOverListener = false;
      }
    }

エスケープキーでメガメニューを閉じる

キー操作はaddEventListenerのkeydownでイベントを取得して、何が押されたかによって処理を与えます。
前回の記事で書いたF6も一応入れておきました。グローバルナビゲーションの最初のリンク要素へフォーカスします。

// F6またはEscapeが入力された時の処理
document.addEventListener("keydown", function(event) {
    // F6キーでグローバルナビゲーションにフォーカス
    if (event.key === "F6") { 
        event.preventDefault()
        const globalNav = document.querySelector("#global-nav a")
        if (globalNav) {
            globalNav.focus()
        }
    }
    // エスケープでメガメニューを閉じる
    if (event.key === "Escape") { 
      allMegamenuClose()
    }
});

メガメニューが閉じている時はキー操作でフォーカスされないようにする

メガメニューが閉じて隠れている時に、そのままだとタブキーの操作で隠れているメニューもフォーカスされてしまいます。
あらかじめvisibility: hidden;にしておいて、開くときにvisibility: visible;にします。

.megaMenu {
  /* ~省略~ */
  visibility: hidden;

  &.is-active {
    height: auto;
    padding: 20px;
    opacity: 1;
    visibility: visible;
  }
}

まとめ

作りながらこれも対応しなきゃなと条件や処理がどんどん増えていきました。
もっと良い書き方があるかもしれません。私はjavaScriptは苦手なので単純な開閉処理の使い回しで作りましたが、javaScriptが得意な方、アクセシビリティに詳しい方にぜひ「こういう作りが良いぞ!」というグローバルナビゲーションを作って解説してみていただきたいです。

この記事では処理の書き方についての説明が主になっていますが、作りながらUIとしてどうか、という点も考えさせられました。
ページの初めに、サイト全ページに渡ってこんなにたくさんの要素が必要なのだろうか、ページ内の構成で誘導したり、サイトマップとしての役割ならフッターにあれば十分では?自分がサイトを使うときはこの開閉の時間が億劫で、とりあえず第二階層の扉ページに遷移してから下層ページへ遷移していくことが多いのでメガメニューの存在自体に疑問を抱いてきました。開いた後に横に大きくカーソル移動させるレイアウトも多く、操作が面倒です。
億劫に感じさせないアニメーションなどの操作感も必要なのだと思います。 もしくは主要なメニューだけ表に出しておき、他はPCの時もハンバーガーメニューでまとめておくなど。 ただ頻繁に通い詰めるようなコンテンツではすぐに目的の場所へ遷移できるメリットもあります。

結局、機能を詰め込みはしましたが使いやすいかというのは別問題で、作りたいサイトの内容や目的に応じて変わってきます。 この記事で紹介したものは、この機能を実装するならこういう処理を入れたい、という考えのまとめになります。
全てに何でもかんでもというのは使いづらくする場合もあるので、必要な機能を適切に実装するということを心がけたいと思いました。