【Nuxt.js】ブログ記事の目次をモーダルで表示

このブログはNuxt.jsとmicroCMSで構築しているのですが、目次を記事の途中でもモーダルで見れるようにしてみました。
手順が多かったので、備忘をかねて記事にしておこうと思います。

動作内容

デフォルトはこんな感じで、記事の一番最初に目次が表示されます。



スクロールが目次のエリアを超えると、右下にボタンが出現します。



ボタンを押すとモーダルが開いて目次を確認できます。


実装の流れ

  1. 記事に目次を作る
  2. 目次を表示するモーダル部分を作る
  3. モーダルを開閉するボタンを作る


ざっくりとまとめるとこのような形です。

記事に目次を作る

目次そのものを作ることに関しては公式ブログを参考にしました。
「microCMSで目次を作成する」

公式ブログの通りに行うと、Array型で目次がページを表示するテンプレートに渡されるようになります。

[ 
  { "text": "記事詳細コンテンツの取得方法", "id": "he3b4b2e270", "name": "h2" }, 
  { "text": "URLパターンの追加", "id": "hee85fd8fbf", "name": "h2" },
  { "text": "viewの追加", "id": "h502ed860d8", "name": "h2" }, { "text": "記事一覧ページの編集", "id": "h1900426c17", "name": "h2" },
  ...省略
]


記事内の適当な場所にv-forを使って表示させます。

<!-- pages/_slug/index.vue -->

<ul class="toc" ref="toc">
    <li :class="`list ${item.name}`" v-for="item in toc" :key="item.id">
      <n-link v-scroll-to="`#${item.id}`" to>
        {{ item.text }}
      </n-link>
    </li>
</ul>


ref="toc"としている部分が重要で、のちに要素の位置を取得するのに必要です。

目次を表示するモーダル部分を作る

つぎにcomponentsディレクトリにtocModal.vueを作成して以下のようにします。

<!-- components/tocModalvue -->

<template>
<transition name="modal" appear>
  <div class="modal-container" v-show="isOpen">
    <div class="modal-overlay" @click.self="closeModal">
      <div class="modal-body">
        <ul class="toc">
          <li :class="`list ${item.name}`" v-for="item in toc" :key="item.id">
            <n-link v-scroll-to="`#${item.id}`" to>
              {{ item.text }}
            </n-link>
          </li>
        </ul>
        <button type="button" class="button" name="button" @click="closeModal">記事に戻る</button>
      </div>
    </div>
  </div>
</transition>
</template>


<script>
export default {
  name: 'TocModal',
  props: {
    toc: {
      type: Array,
      required: true
    },
    isOpen: {
      type: Boolean,
      required: true
    }
  },
  methods: {
    closeModal() {
      this.$emit('close-modal')
    }
  }
}
</script>

<style scoped>


.modal-container {
  position: relative;
  z-index: 11;
  width: 100%;
  height: 100%;
}

.modal-body {
  position: relative;
  z-index: 12;
  box-sizing: border-box;
  height: 80%;
  width: 100%;
  max-width: 640px;
  padding: 16px;
  margin: auto;
  background-color: #fff;
}

.modal-overlay {
  position: fixed;
  z-index: 13;
  top: 0;
  left: 0;
  display: flex;
  overflow: auto;
  width: 100%;
  height: 100%;
  padding: 20px 60px;
  background-color: rgba(0, 0, 0, 0.7);
}

@media (max-width: 1024px) {
  .modal-overlay {
    padding: 10px 10px;
  }
  .modal-body{
    height: 90%;
    width: 100%;
  }
}
</style>


呼び出される親テンプレートから、目次のリストと、bool型の開閉フラグを受け取ります。
子コンポーネントで行うことは目次の表示とクリックに応じてモーダルを閉じるだけです。

methods内でthis.$emit(...)とすることで、開閉フラグを持っている親テンプレートに閉じる動作を行ったことを教えることができます。

モーダルを開閉するボタンを作る


以下のようにして、位置に応じてボタンを出現させることができました。
ちょっと長いです。

<!-- pages/_slug/index.vue -->
<template>
...省略


<transition name="tocBtn" appear>
     
  <!-- モーダル開閉ボタン -->
  <button class="button toc-button" @click="openModal" v-show="isTocBtnVisible">
    目次
  </button>
</transition>
  
<!--
   先ほど作成したモーダルテンプレート 
   子コンポーネントから@close-modalイベントを受け取りcloseModalを呼び出す 
  -->
<toc-modal v-show="modalFlag" :isOpen="modalFlag" @close-modal="closeModal" :toc='toc'>
</toc-modal>
</template>


<script>
export default {
  async asyncData({
    $microcms,
    params
  }) {
    //...記事を取得する処理
    //...目次を取得する処理


    return {
      data,
      body: $.html(),
      // 目次
      toc,
    }
  },
  data() {
    return {
      // modalの開閉フラグ
      modalFlag: false,
      // スクロール位置を格納
      scrollY: 0,
      // 目次のボトムの位置
      tocBottom: 0,
    }
  },
  mounted() {
    // スクロールを監視
    window.addEventListener('scroll', this.handleScroll);
    // ref="toc"とした部分を読み、要素のボトムの位置を保存
    const rect = this.$refs.toc.getBoundingClientRect()
    this.tocBottom = rect.bottom + window.pageYOffset
  },
  methods: {
    openModal: function() {
      this.modalFlag = true
    },
    closeModal: function() {
      this.modalFlag = false
    },
    handleScroll() {
      this.scrollY = window.scrollY;
    },
  },
  // スクロール位置が目次のボトム位置を上回ったらtrueを返す
  computed: {
    isTocBtnVisible: function() {
      return this.scrollY >= this.tocBottom
    }
  },
}
</script>

<style scoped>
/* 右下に固定 */
.toc-button {
  position: fixed;
  right: 150px;
  bottom: 50px;
  transition: 1s;
}


@media (max-width: 1024px) {
  .toc-button {
    right: 50px;
    bottom: 30px;
  }
}
</style>


まずasyncDataフックとは別の部分で監視するプロパティを用意しています。
ここの部分ですね。

data() {
    return {
   // modalの開閉フラグ
      modalFlag: false,
   // スクロール位置を格納
      scrollY: 0,
   // 目次のボトムの位置
      tocBottom: 0,
    }
  },


modalFlagはボタンのクリックに応じてモーダルの開閉を制御しているだけです。

スクロールの位置と、目次のボトムの位置はDOMが作成された直後にプロパティに値をセットする処理をしています。

mounted() {
    window.addEventListener('scroll', this.handleScroll);
    const rect = this.$refs.toc.getBoundingClientRect()
    this.tocBottom = rect.bottom + window.pageYOffset
  },
methods: {
    handleScroll() {
      this.scrollY = window.scrollY;
    },
 }


addEventListenerとmethodsを併用することで、スクロールの値をリアクティブに格納することができます。

tocの位置については最初に書き出した処理でref="toc"としていました。

そうすると、this.$refs.tocとしてアクセスすることができます。

次にcomputedで以下のように値を監視するようにします。

computed: {
    isTocBtnVisible: function() {
      return this.scrollY >= this.tocBottom
    }
  },


スクロール位置が目次のbottomの位置を超えたらtrueを返す処理です。

あとはv-showディレクティブとバインドすれば、スクロールが目次部分を超えたあたりで、ボタンが出現するようになります。

<button class="button toc-button" @click="openModal" v-show="isTocBtnVisible">
  目次
</button>


おわりに

ブログ記事は自分で見返すことも多いので、目次にアクセスしやすいとうれしいです。
Nuxt.jsはcomponentを使って、似たような処理をまとめていけるところが良いですね。

例えば今回の場合でも、目次の書き出し処理が記事ページとモーダルの中の両方にあります。
こういった場合はコンポーネントにまとめると見た目がすっきりします。

簡単に紹介して終わります。
componentsディレクトリにTableOfContent.vueを作成します。

<!-- components/TableOfContent.vue -->
<template>
<ul class="toc">
  <li :class="`list ${item.name}`" v-for="item in toc" :key="item.id">
    <n-link v-scroll-to="`#${item.id}`" to>
      {{ item.text }}
    </n-link>
  </li>
</ul>
</template>


<script>
export default {
  name: 'TableOfContent',
  props: {
    toc: {
      type: Array,
      required: true
    },
  }
}
</script>


<style scoped>
.toc::before {
  display: block;
  content: '目次';
  margin-bottom: 20px;
  font-weight: bold;
}


.toc {
  margin-top: 20px;
  margin-bottom: 40px;
  background-color: #f6f0cc;
  padding: 20px;
  font-size: 1.6rem;
}


.toc>>>li {
  margin-top: 1em;
  list-style-type: none;
}


.h1 {
  margin-left: .5rem;
}


.h2 {
  margin-left: 1rem;
}


.h3 {
  margin-left: 2rem;
}


.list>>>a {
  color: #333;
}

.list>>>a:hover {
  color: #ff6c94;
  opacity: .9;
}
</style>


モーダルにはslotで値を親から渡すようにします。
ここに先ほど作成した`TableOfContent.vue`が入るイメージです。

こうすると、親からtocのリスト自体は渡す必要がないので、propsの中身もフラグを受け取るだけになります。

<!-- components/TocModal.vue -->
<template>
<transition name="modal" appear>
  <div class="modal-container" v-show="isOpen">
    <div class="modal-overlay" @click.self="closeModal">
      <div class="modal-body">
        <slot />
        <button type="button" class="button" name="button" @click="closeModal">記事に戻る</button>
      </div>
    </div>
  </div>
</transition>
</template>

<script>
export default {
  name: 'TocModal',
  props: {
    isOpen: {
      type: Boolean,
      required: true
    }
  },
  methods: {
    closeModal() {
      this.$emit('close-modal')
    }
  }
}
</script>


後は親テンプレートから渡すだけです。

<!-- pages/_slug\index.vue -->
<template>
  <div ref="toc">
    <table-of-content :toc="toc"></table-of-content>
  </div>
  ...省略
  <toc-modal v-show="modalFlag" :isOpen="modalFlag" @close-modal="closeModal">
      <table-of-content :toc="toc"></table-of-content>
    </toc-modal>
</template>


目次のcssを変更したい場合はTableOfCOntent.vueだけを修正すればいいので、メンテナンス性が高くなります。

TOPページ