eureka

Nuxt.js(SSR/SSG)でハンバーガーメニューを一通り実装する

2

PCのときは普通のヘッダーメニューで、スマホの時だけハンバーガーメニューにしたい! 一つのソースでクラス有無によってPCとSPで表示を変えるということをしたい!

よかろう。

ググるとハンバーガーメニュー単体では結構実装方法が出てくるのですが、PCとソースを分けたくないとか、メニュー表示だけの実装内容でメニューをクリックしても閉じないとか、Nuxtのときはどうなるとか、ちゃんと動くものを実装しようとすると意外といいのが出てこないので、自分が実装した内容を一通り覚書します。

先に書いときますが一通りやるのでかなり長いですw

(12/24 追記)
短期間にいろいろ実装しましたがCSSでモバイルとPCの表示を変えるのではなくて、モバイルとPCでソースを二つ用意してv-ifで切り替えるほうが良いなと思いました笑

環境

nuxt 2.14.0

ヘッダーコンポーネントを作成する

まずはヘッダーコンポーネントを作成します。

<template>
  <header :class="$options.name">
    <h1 :class="`${$options.name}__title`">
      <nuxt-link to="/">
        <span>サイト名</span>
      </nuxt-link>
    </h1>

    <!-- ハンバーガーボタン -->
    <button type="button" class="button-menu">
      <span class="button-menu__icon">
        <span class="visually-hidden">メニューを開閉する</span>
      </span>
      <span class="button-menu__text">menu</span>
    </button>

    <!-- グローバルメニュー -->
    <nav class="global-nav">
      <h2 class="visually-hidden">サイト内メニュー</h2>
      <ul class="menu">
        <template v-for="(menuItem, index) in MENU">
          <li :key="index" class="menu__item" :class="menuItem.className">
            <nuxt-link :to="`/${menuItem.path}`" v-text="menuItem.name" />
          </li>
        </template>
      </ul>
    </nav>
  </header>
</template>

<script>
const MENU = [
  {
    id: 1,
    name: 'home',
    path: '',
    image: '',
    className: '',
  },
  {
    id: 2,
    name: '私たちについて',
    path: 'about',
    image: '',
    className: '',
  },
]

export default {
  name: 'Header',

  data() {
    return {
      MENU,
    }
  },
}
</script>

<style lang="scss" scoped>
.Header {
  width: calc(100% - 2em);
  height: em(16, 40);
  padding: em(16, 3) 1em;
  box-sizing: content-box;
  position: fixed;
  top: 0;
  left: 0;
  z-index: 10;
  color: #010101;
  transition: 0.2s all ease-in-out;
  @include lap {
    width: calc(100% - 8em);
    padding: em(16, 8) em(16, 64);
    display: flex;
    justify-content: space-between;
    align-items: center;
  }
}

.Header__title {
  width: 180px;
  display: inline-block;
  line-height: 1;
  @include lap {
    width: 250px;
  }

  a {
    display: inline-block;

    img {
      vertical-align: middle;
    }
  }
}

.global-nav {
  width: 100%;
  height: 120%;
  padding: calc(56px + 32px) 0 40px;
  background-color: #fafafa;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: -1;
  @include pc {
    position: static;
    padding: 0;
    height: 100%;
    background-color: transparent;
  }
}

.menu {
  font-size: fz(16);
  list-style: none;
  @include pc {
    display: flex;
    justify-content: flex-end;
  }

  & + .menu__item {
    @include pc {
      margin: 0 0 0 em(16, 8);
    }
  }
}

.menu__item {
  text-align: center;
  @include pc {
    display: inline-block;
    width: inherit;
  }

  a {
    text-transform: uppercase;
    display: inline-block;
    padding: em(16, 8) 0;
    text-decoration: none;
    width: 100%;
    @include pc {
      width: inherit;
      padding: em(16, 8);

      &:hover {
        opacity: 0.8;
      }
    }
  }
}

// メニューボタン
.button-menu {
  width: 40px;
  height: 40px;
  background-color: transparent;
  position: fixed;
  top: 3px;
  left: calc(100% - 2em);
  transform: translateX(-50%);
}

.button-menu__icon {
  display: inline-block;
  width: 28px;
  height: 2px;
  border-radius: 2px;
  background-color: #010101;
  position: absolute;
  top: calc(50% - 5px);
  left: 50%;
  transform: translate(-50%, -50%);

  &::before,
  &::after {
    content: '';
    display: inline-block;
    width: 100%;
    height: 100%;
    background-color: #010101;
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
  }

  &::before {
    top: -5px;
  }

  &::after {
    top: 5px;
  }
}

.button-menu__text {
  width: 100%;
  font-size: fz(12);
  position: absolute;
  left: 50%;
  bottom: 2px;
  transform: translateX(-50%);
}
</style>

MENUデータをコンポーネント内で定義していますが、実際にはグローバルとしてjsonから取得しています。メニューに合わせて配列を追加・編集してください。

スタイルは関数/Mixin/変数めちゃめちゃ使ってます。
そのままコピペするとエラーになると思うのでよしなに変換してください、、
(カラーコード変数以外を直す元気がなかったですスミマセン)

いちお使用している関数やMixinに関しては下記です。よしなに変換してください、、(2回目)
em(): em単位に計算する関数
fz(): font-sizeをremで返す関数
@include lap: メディアクエリ呼び出し(lap = min-width: 1200pxです)

ヘッダーをデフォルトレイアウトとして設定する

ヘッダーコンポーネントができたのでデフォルトレイアウトとして設定します。
ここでコンポーネントを呼び出すことで全ページにヘッダーが表示されるようになります。

<template>
  <div>
    <Header />
    <Nuxt />
  </div>
</template>

モバイル幅のときだけハンバーガーボタンを表示する

スマホのときだけハンバーガーボタンを出したいのですが、現状だとPCでも出てしまっているのでまずはハンバーガーボタンを表示切替します。

下記の流れでやっていきます。

  • ウィンドウ幅を取得
  • モバイルジャッジ用の関数を作成
  • v-showで切替え

スマホかどうかはismobilejsというライブラリもありましたが、
簡易的でよかったので自前で関数を作成しました。
ismobilejsを使用すると端末の種類とかでジャッジできるみたいですね。

それではコードを書いていきます。

  data() {
    return {
      MENU,
      windowWidth: 900, // 追加
    }
  },

dataにwindowWidthを追加しました。
初期値は900にしましたが、あとから上書きされるので一旦なんでもいいかなと思います(たぶんw)

続いてウィンドウ幅を取得します。
レンダリング時のウィンドウ幅を取得して、先ほどの初期値を上書いています。
またウィンドウ幅のリサイズにも対応します。

  mounted() {
    this.windowWidth = window.innerWidth
    this.$nextTick(() => {
      window.addEventListener('resize', () => {
        this.windowWidth = window.innerWidth
      })
    })
  },

取得したウィンドウ幅でモバイルかどうかをジャッジします。
ヘッダーメニューが多かったので1200pxというブレイクポイントの設定をしています。

  computed: {
    /**
     * スマホかどうかを判定する
     *
     * @return {Boolean}
     */
    isMobile() {
      return this.windowWidth < 1200
    },
  },

最後にv-showを使用してボタンの表示切替をします。

    <button v-show="isMobile" type="button" class="button-menu">
      <!-- 割愛します -->
    </button>

これでブラウザをリサイズしてみたり検証ツールを使用してみたりすると、ハンバーガーボタンがスマホのときに指定したブレイクポイントで表示されていると思います。(されてない?w)

スマホのときにボタンを押してメニュー開閉させる

では、ハンバーガーでお馴染みのメニュー開閉をしていきます。
ここらへんは基本ググって出てくるものと同じで、開閉用のフラグを用意してv-on:clickでトグルします。

まずは開閉フラグの用意から。

  data() {
    return {
      MENU,
      windowWidth: 900,
      isActiveMenu: false, // 追加
    }
  },

isActiveMenuというフラグを用意しました。
ブーリアン型で初期値はメニューを開かないのでfalseです。

グローバルナビにv-showを設置します。

    <nav v-show="isActiveMenu" class="global-nav">
      <!-- 割愛します -->
    </nav>

isActiveMenuがtrueのときだけ表示されるようになりました。
ここまででモバイル幅にしたらh1とハンバーガーボタンのみが表示されているはずです。

続いてハンバーガーボタンを押したらisActiveMenuがtrueになる(=表示される)ようにします。

    <button
      v-show="isMobile"
      type="button"
      class="button-menu"
      @click="isActiveMenu = !isActiveMenu"
    >
      <!-- 割愛します -->
    </button>

v-on:click内でisActiveMenu = !isActiveMenuと書くことで、trueならばfalseに、falseならばtrueに、という切替ができます。

これでメニューの開閉ができました。

ただし、このままではボタンをポチポチ押してからPC幅に戻したときに、グローバルメニューが表示されていないみたいなバグが起こっているので直しましょう。

    <nav v-show="!isMobile || isActiveMenu" class="global-nav">
      <!-- 割愛します -->
    </nav>

グローバルナビゲーションの表示条件に!isMobileをor条件で追加しました。
これによりグローバルナビがスマホじゃないとき(=PCのとき) もしくは isActiveMenuがtrueのときという条件で表示されるようになりました。

表示にアニメーションをつける

スマホメニューを表示するときにfadeのアニメーションを追加しようと思います。
vueが提供するtransitionを使用してサクッと実装します。

v-if/v-showがtransitionのトリガーとなるので、グローバルナビをtransitionで囲みます。
アニメーションの名前はfadeとしました。

    <transition name="fade">
      <nav v-show="!isMobile || isActiveMenu" class="global-nav">
        <!-- 割愛します -->
      </nav>
    </transition>

スタイルでアニメーションの定義をします。

.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s;
}
.fade-enter,
.fade-leave-to {
  opacity: 0;
}

ここらへんは公式サイトからまるっとコピペしましたw
接頭辞のfade-をtransition名と合わせることで動きます。

これでハンバーガーボタンをクリックするとフェードしながら表示/非表示するようになりました。

スマホメニューをタップ(クリック)したらハンバーガーメニューを閉じる

スマホメニューをクリックしてページ遷移した後もメニューが表示されたままなのでクリックしたらメニューが閉じるようにしたいですね。

nuxt-linkに対して処理をしていきます。
nuxt-linkはレンダリングされるとaタグとなるのですが、『画面遷移の動作を止めて、メニューを閉じてから画面遷移させる』といった処理をしたいです。

ところがnuxt-linkではevent.preventDefault()が使えない(参考)ようなのでちょっと工夫します。

        <ul class="menu">
          <template v-for="(menuItem, index) in MENU">
            <li :key="index" class="menu__item" :class="menuItem.className">
              <nuxt-link
                :to="`/${menuItem.path}`"
                v-text="menuItem.name"
                @click.native.prevent="trigger"
                event=""
              />
            </li>
          </template>
        </ul>

v-on:clickに.native.preventを追加しました。nuxt-linkの場合は.nativeがないと関数が呼ばれないみたいですね。
また、リンクを無効にするためにevent=""を入れました。

クリック時に呼び出す関数を設定していきます。

  methods: {
    trigger() {
      if (this.isMobile) {
        this.isActiveMenu = false
        this.$router.push({ path: event.target.pathname })
      } else {
        this.$router.push({ path: event.target.pathname })
      }
    },
  },

これでPCのときはそのまま遷移しつつ、スマホのときだけメニューをクリックしたときにメニューを閉じて画面遷移するようになりました。

以上で一通りのハンバーガーメニューの実装が終了となります。
お疲れ様でした!

最後に

ハンバーガーメニューの実装はググるといろいろ出てくるのですが、ちゃんと動くものをやろうと思うとそれだけでは情報が全然足りず苦戦していた中で編み出したものなので、もしかしたらもっとスマートなやり方があるかもしれません。
もっと良いやり方あったら教えてもらえると嬉しいです!

一応画面上で動かしながらコードの解説をしているのですが、実際のコード的にはグローバル変数とか状況をみてもっと色々設定していて、説明用に端折った部分もあったのでもしかしたら変なところあるかも・・(ゴメンナサイ)

2