12周年記念パーティ開催! 2024/5/10(金) 19:00

ライブラリなしで実装する定番UI 第1回 ドロワーナビの基本

ライブラリを使わず、標準のHTML、CSS、JavaScriptだけを用いて、ドロワータイプのナビゲーションを実装してみましょう。今回はまず全体の大枠を決め、ポイントを押さえながら、基本的な動作までを作ります。

発行

著者 國仲 義則 フロントエンド・エンジニア
ライブラリなしで実装する定番UI シリーズの記事一覧

はじめに

ウェブサイトでよく見かけるUIにはいろいろとあります。タブコンテンツ、スティッキー・ヘッダー、アコーディオン……。その中でもこの数年で非常によく見かけるようになったものにドロワータイプのナビゲーション(または、コンテンツ)があります。ウェブサイト制作を行っている方は、一度は作ったことがあるのではないしょうか。

初めはスマートフォン向けのUIなどで見かけましたが、現在では画面の大きさを問わず、ナビゲーションは全部その中に押し込んでしまうようなパターンも見かけます。このシリーズでは、順を追って基本的なドロワーナビを作成していきます。

技術選定方針としては、jQueryやjQueryプラグインなど、ライブラリを使用しません。これまでライブラリを使うことを前提に実装を進めることが多かった方でも、それらを使わない実装を学んでおくと、技術選定のバリエーションが増え、業務に役立てることができるでしょう。

今回作るドロワーナビについて

まずは今回作るドロワーナビを見てみましょう。

このUIには「ハンバーガーナビ」「スライドナビ」などの呼び名がありますが、このシリーズでは「ドロワーナビ」と呼びます。

また、トリガーとなるハンバーガーボタンの見た目から、それを押した後の見た目や動きのパターンまで非常に多くのものがありますが、今回作るのはとてもシンプルなもので、基本要件は次のとおりです。

  • 開くボタンと閉じるボタンは見た目もHTMLも別にある
  • ボタンは右上にあり、ナビゲーションも右からスライドインして表示される
  • 表示中、ナビ以外部分にはオーバーレイを表示する
  • オーバーレイをクリックするとナビゲーションを閉じる
  • 開くボタンは固定ヘッダ内にある

なお、このデモは次の環境で動作確認を行いました。

  • Google Chrome 64
  • Mozilla Firefox 58
  • Internet Explorer 11 (Windows 10)
  • Microsoft Edge 41 (EdgeHTML 16)
  • Safari 11
  • iOS Safari 11.2
  • Android Chrome 64

それでは、まずは以上を満たす最低限の状態を目指して作っていきましょう。

HTMLの構成

HTMLは次のようになります。まずはざっと眺めてみてください。

<header class="Header">
  <h1 class="Header-logo">...</h1>
  <button type="button" class="Header-button js-openDrawer" aria-controls="drawer" aria-expanded="false">
    <img src="open.svg" class="Header-button-image" alt="ナビゲーションを開く" width="24" height="18">
  </button>
</header>
<div id="drawer" class="Drawer js-drawer" aria-expanded="false">
  <div class="Drawer-backdrop js-backdrop"></div>
  <nav class="Drawer-nav Nav">
    <button type="button" class="Nav-button js-closeDrawer" aria-controls="drawer" aria-expanded="false">
      <img src="close.svg" class="Nav-button-image" alt="ナビゲーションを閉じる" width="18" height="18">
    </button>
    <ul class="Nav-list">
      <li class="Nav-item">
        <a href="#" class="Nav-link">アイテム 1</a>
      </li>
      <!-- 以下繰り返し -->
    </ul>
  </nav>
</div>
<main>
  <!-- コンテンツ部分 -->
</main>
<footer>
  <!-- フッタ部分 -->
</footer>

nav要素で定義されたドロワーナビ内は、オーバーレイ用のdiv要素、button要素(閉じるボタン)とul要素(リンク集)という単純なものです。

オーバーレイ用に空のdivを利用するようなマークアップをしたくない、またはできない場合、ドロワー本体の疑似要素で表現することもできますが、処理が少し異なりますので、JavaScriptによる動作実装セクションのコラムを参照してください。

ほかに気をつける点としては、開くボタンと閉じるボタンはbuttonでマークアップするところです。視覚的、かつ、クリックできるものという点ではdivなどで作ることも可能ではありますが、ボタンとしての機能をdivなどで実装しようとすると手間がかかります。

たとえば、div要素のrole属性*の値をbuttonにし、tabindex属性によりフォーカス可能にしたとしても、キーボードによる操作を自前で実装しなければなりません。その際、エンターキーとスペースキーのイベントの発生タイミングの違い、スペースキーの押下時に発生するスクロールの制御なども考えると、それがボタンであるならbuttonでマークアップしたほうが実装の手間も省けます。

*注:role属性

role属性の基本は次の記事などを参考にしてください。

button要素のtype属性の初期値はsubmit*ですので、値をbuttonにしておきましょう。

*注:button要素のtype属性

仕様書には次のように記述されています。

The missing value default is the submit button state.

aria-expanded属性*は対象が展開されているか否かを示す属性です。対象がその要素の子孫要素でない場合は、aria-controls属性によって対象を参照すべきです。

この作例の場合、ドロワーのdiv要素はbutton要素の子孫要素ではないので、aria-controls属性で対象であるドロワーのコンテナとなるdiv要素のid属性値を指定しています。これでbutton要素から対象となるdiv要素を参照します。

*注:aria-expandedとaria-controlsの組み合わせ

仕様書には次のように記述されています。

If the element with the aria-expanded attribute controls the expansion of another grouping container that is not 'owned by' the element, the author SHOULD reference the container by using the aria-controls attribute.

ここでの「owned by」とはaria-ownsによって特定の要素が指定されていない場合は、子孫要素であるかどうか、という意味になります。

WAI-ARIAについてはWAI-ARIAを活用したフロントエンド実装シリーズも参照してくだだい。

スタイル指定と遷移アニメーション

次はスタイルと遷移アニメーションの付与です。

基本状態

次に挙げるのは基本状態のCSSです(不要な部分は省略してあります)。

.Header {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 60px;
}

.Drawer {
  position: fixed;
  z-index: 0;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

.Drawer-backdrop {
  position: absolute;
  z-index: -1;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
}

.Drawer-nav {
  position: absolute;
  top: 0;
  right: 0;
  height: 100%;
  width: 280px;
}

.Nav {
  overflow: auto;
}

ヘッダーは固定、という条件に合わせて、レイアウトを作成しました。ナビゲーション内がスクロールできないと、画面からはみ出たときにコンテンツが閲覧できなくなるのでoverflow: autoを指定しておきます。

開閉時のアニメーション

次に開閉したときの動きを指定します。

.Drawer[aria-expanded] {
  transition-property: visibility;
  transition-duration: 0.25s;
}

.Drawer[aria-expanded] .Drawer-backdrop {
  transition-property: opacity;
  transition-duration: 0.25s;
  transition-timing-function: linear;
}

.Drawer[aria-expanded] .Drawer-nav {
  transition-property: transform;
  transition-duration: 0.25s;
  transition-timing-function: ease;
}

/* 開いているとき */
.Drawer[aria-expanded="true"] {
  visibility: visible;
}

.Drawer[aria-expanded="true"] .Drawer-backdrop {
  opacity: 1;
}

.Drawer[aria-expanded="true"] .Drawer-nav {
  transform: translateX(0);
}

/* 閉じているとき */
.Drawer[aria-expanded="false"] {
  visibility: hidden;
}

.Drawer[aria-expanded="false"] .Drawer-backdrop {
  opacity: 0;
}

.Drawer[aria-expanded="false"] .Drawer-nav {
  transform: translateX(100%);
}

冒頭の基本要件とサンプルをもう一度見るとわかるとおり、上記のCSSで次のようなアニメーションをします。

  • ドロワーが閉じているときは画面上に表示されない
  • ドロワーを開くときは画面の右から出てくる
  • オーバーレイはフェードインしてくる

まず、ドロワー自体は開いていないときはvisibility: hiddenで非表示にしておきます。このUIを作る際、visibilityプロパティはとても便利で、プロパティ値がhiddenの場合、見えない、触れない、読み上げられない、という状態になります。さらに、アニメーション可能でもあります。

表示か非表示かという2つの状態であるvisiblehiddenの間のアニメーションがどういう動きになるのかは少し想像しづらいかもしれません。

hiddenの状態を0visibleの状態を1とし、アニメーション中はその間で数値が変動し、数値が0より大きい場合は1として扱われます。さらに詳しい情報は2017年11月30日時点のCSS Transitions仕様内のvisibilityについてを参照してください。

これはどういうことかというと、hiddenからvisibleに遷移するときはアニメーション開始直後にvisible状態となり、visibleからhiddenへと遷移するときはアニメーション終了時にhidden状態になるということです。

この動作を利用し、ドロワーのvisibilityの遷移時間を疑似要素のopacityやナビゲーションのスライドアニメーションの遷移時間と合わせることで、表示と非表示のタイミングを無理なく実装できます。

ナビゲーションのスライドアニメーションにはtransformプロパティのtranslateX関数を利用します。移動にはleftrightプロパティを利用することも可能ではありますが、これらのプロパティはレイアウトの再計算を引き起こすため、負荷が高くなり、アニメーションの滑らかさが失われる可能性が高くなります。一方、transformプロパティではレイアウトの再計算は起こりません。

アニメーションのパフォーマンスをよくするためには多くの調査や実装方法の検討が必要となります。今回の例もさらにパフォーマンスを向上させることが可能でしょう。ですが、まずできることとして要素の移動は基本的にtransformプロパティで行う、といったところから始めるとよいでしょう。

JavaScriptによる動作実装

最後に、ボタンをクリックした際の動作を実装していきます。ドロワー本体、開く・閉じるボタンのクリックイベントに合わせて、aria-expandedの属性値を変更していきます。

(function () {
  // ボタンと本体
  const openButton = document.querySelector(".js-openDrawer");
  const drawer = document.querySelector(".js-drawer");
  const closeButton = drawer.querySelector(".js-closeDrawer");
  const backdrop = drawer.querySelector(".js-backdrop");

  // 現在の状態(開いていたらtrue)
  let drawerOpen = false;

  // stateは真偽値
  function changeAriaExpanded(state) {
    const value = state ? "true" : "false";
    drawer.setAttribute("aria-expanded", value);
    openButton.setAttribute("aria-expanded", value);
    closeButton.setAttribute("aria-expanded", value);
  }

  // stateは真偽値
  function changeState(state) {
    if(state === drawerOpen) {
      console.log("2回以上連続で同じ状態に変更しようとしました");
      return;
    }
    changeAriaExpanded(state);
    drawerOpen = state;
  }

  function openDrawer() {
    changeState(true);
  }

  function closeDrawer() {
    changeState(false);
  }

  function onClickOpenButton() {
    openDrawer();
  }

  function onClickCloseButton() {
    closeDrawer();
  }

  openButton.addEventListener("click", onClickOpenButton, false);
  closeButton.addEventListener("click", onClickCloseButton, false);
  backdrop.addEventListener("click", onClickCloseButton, false);
})();

開閉動作を行う際、現在の状態と次の状態(ボタンクリック後の状態)を比較することで、すでにドロワーが開いている場合は開く動作をしないようにします。こうすれば、開くボタンを連続でクリックされたとしても、何度も開く動作が行われることはありません。 今回の場合はdrawerOpen変数の真偽値により、状態を管理しています。

ARIAステートを変更する際のsetAttribute関数の第二引数に渡す値ですが、trueまたはfalseの真偽値のままでも自動的に文字列に変換されるので、動作は問題ありません。

ですが、設定する値が文字列であり、"1"/"0"などではなく"true"/"false"であるということをわかりやすくする意味もあり、文字列として第二引数に設置しています。

【コラム】擬似要素をオーバーレイに利用する

HTMLの構成セクションで触れた、疑似要素をオーバーレイに利用する場合を考えてみます。

オーバーレイのスタイルとしてはCSSの.Drawer[aria-expanded="true"] .Drawer-backdropセレクタ部分を.Drawer[aria-expanded="true"]::beforeにすれば問題ありません。

ですが、疑似要素はDOMツリーに存在しませんので、疑似要素自体のクリックイベントにイベントリスナーを追加できません。結果、この場合はドロワー本体のクリックイベントがトリガーとなります。

ここで気をつける点は、クリックされた要素が何なのかを判定しないと、誤動作を起こす可能性があることです。たとえば、単にナビゲーション内の空白をクリックしただけでドロワーが閉じてしまうのは予期せぬ動作だといえます。そのため、前述のJavaScriptコードに手を加えなければなりません。

function onClickBackdrop(event) {
  if (event.target !== drawer) {
    return;
  }
  closeDrawer();
}

// ...

drawer.addEventListener("click", onClickBackdrop, false);

以上のように、クリックされた対象がドロワーの要素であるかどうかを判定した上で、閉じる動作を行います。

なぜこういったことをする必要があるのかは、イベント伝播についての基本的な知識が必要となります。これについてはJavaScript再入門シリーズ 1〜3回で解説されていますので、そちらをご覧ください。

まとめ

最後にもう一度、今回の作例を掲載しておきます。

今回で最低限の機能を作成しましたが、実際に動かしてみると使いづらい点があります。まずはドロワーが開いているときに、ナビゲーション内でスクロールできるのは問題ありませんが、触れないはずのメインコンテンツがスクロールできてしまう点です。

次回はその問題を解消するためにスクロールの制御を行い、それと関連してスクロールバーによって発生する問題を解決していきます。