【Nuxt.js】コンテンツの高さが動的に変化するアコーディオンをいい感じに実装する

この記事は「[LCL Advent Calendar 2022](https://qiita.com/advent-calendar/2022/lcl)」21日目の記事です。


## あるある
アコーディオンを作りたい
↓
下記みたいな感じで書いてみるが所望の挙動にならない
（大抵の場合、`height:auto`であることが原因）
```scss:height.css
.element {
  transition: height 400ms ease;
}
```
↓
`max-height`による実装を行う
```scss:max_height.css
.content {
  transition: max-height 400ms ease;
  overflow: hidden;
  max-height: 0;

  &.open {
    max-height: 1000px; // 十分大きな値
  }
}
```

## max-heightによる実装の課題
- `max-height`の値が不十分だと要素次第では最後まで表示されない
- `max-height`の値が実際の高さに対して大きすぎるとUXの面で不適切
    - すごい速度で開く
        - 上例の場合、実際の高さがどのくらいであれ、400msで0→1000pxのtransitionを行うため
    - 押下してからなかなか閉じない
        - 上例の場合、実際の高さがどのくらいであれ、400msで1000px→0のtransitionを行うため

## 実装

方法はいろいろあると思いますが、最近

- アコーディオンを開いている間にコンテンツ（アコーディオンで開閉される要素）の高さが動的に変化する可能性がある
- その際も、heightはアニメーションしてほしい
- ただし、アコーディオン自体の高さには上限がほしい（コンテンツの高さがアコーディオンの高さを超えた場合はスクロールするようにする）

という仕様を実装する機会があったので、これも考慮して実装してみます。


## 結果
![ezgif.com-gif-maker.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506732/92fd9192-ae8f-4739-5b63-47e76fad5376.gif)

## サンプルコード
### アコーディオン
```vue:Accordion.vue
<template>
  <AccordionBase :contentHeight="contentHeight">
    <template v-slot:title>Accordion</template>
    <template v-slot:content>
      <ul ref="content" class="list">
        <li v-for="(item, index) in list" :key="index" class="item">
          {{ item }}
        </li>
      </ul>
    </template>
  </AccordionBase>
</template>

<script>
export default {
  props: {
    // 動的要素。「pages内でAPI requestした結果をpropsとして受け取る」等が多い
    list: { type: Array, required: true },
  },
  data() {
    return {
      contentHeight: 0, // アコーディオンコンテンツの高さ
    }
  },
  watch: {
    list: {
      handler() {
        this.$nextTick(() => {
          // listの変化に対してアコーディオンコンテンツの高さを再計算
          this.contentHeight = this.$refs.content.getBoundingClientRect().height
        })
      },
      deep: true,
    },
  },
}
</script>

<style lang="scss" scoped>
// 動作と無関係なcssは記載していません
</style>
```

アコーディオン実装の大元である`AccordionBase.vue`でwrapする形で実装します。

ポイントは[$nextTick](https://jp.vuejs.org/v2/api/index.html#vm-nextTick)です。これにより、DOMの更新完了後（listが変化してコンテンツの高さが変化した後）にcontentHeightの値が変化するようになります。


### アコーディオン（本体）

```vue:AccordionBase.vue
<template>
  <div class="Accordion">
    <div class="Accordion__title" @click="toggle">
      <slot name="title" />
    </div>
    <transition
      @before-enter="beforeEnter"
      @enter="enter"
      @before-leave="beforeLeave"
      @leave="leave"
    >
      <div v-show="isOpen" ref="content" class="Accordion__content">
        <slot name="content" />
      </div>
    </transition>
  </div>
</template>

<script>
export default {
  props: {
    // コンテンツの高さ
    contentHeight: { type: Number, required: false, default: 0 },
  },
  data() {
    return {
      isOpen: false,
    }
  },
  watch: {
    contentHeight() {
      this.updateHeight(this.contentHeight)
    },
  },
  methods: {
    updateHeight(height) {
      this.$refs.content.style.height = `${height}px`
    },
    beforeEnter(el) {
      el.style.height = `0`
    },
    enter(el) {
      el.style.height = `${el.scrollHeight}px`
    },
    beforeLeave(el) {
      el.style.height = `${el.scrollHeight}px`
    },
    leave(el) {
      el.style.height = '0'
    },
    toggle() {
      this.isOpen = !this.isOpen
    },
  },
}
</script>
<style lang="scss" scoped>
// 動作と無関係なcssは記載していません

.Accordion {
  &__content {
    max-height: 400px; // コンテンツの最大高さ 400px以上の場合はscrollさせる
    overflow-y: scroll;
    transition: height 400ms ease-in-out;
  }
}
</style>
```

基本のトランジション+JavaScriptフックに加えて、コンテンツ高さが変化した場合もheightの値を変更します。
どちらの場合でもheightの値が明示的になるので、transitionによるアニメーションが有効になります。


### ページ

```vue:pages/index.vue
<template>
  <div>
    <div>
      <button @click="push">push</button>
      <button @click="pop">pop</button>
    </div>
    <AccordionSample :list="accordionContents"></AccordionSample>
  </div>
</template>

<script>
export default {
  data() {
    return {
      accordionContents: ['init element'], // 動的に変化する要素
    }
  },
  methods: {
    push() {
      this.accordionContents.push(`element-${this.accordionContents.length}`)
    },
    pop() {
      this.accordionContents.pop()
    },
  },
}
</script>

<style lang="scss" scoped>
  // 動作と無関係なcssは記載していません
</style>
```

一応載せていますが、本筋とはあまり関係ないです。


## 他の書き方（コンテンツもコンポーネントとして定義したい場合）
スコープ付きスロットを使用します。

### アコーディオン

```vue:AccordionSample.vue
<template>
  <AccordionBase :contentHeight="contentHeight">
    <template v-slot:title>Accordion</template>
    <template v-slot:content="contentProps">
      <AccordionContent :list="list" @updateHeight="contentProps.updateHeight" />
    </template>
  </AccordionBase>
</template>

<script>
export default {
  props: {
    list: { type: Array, required: true },
  },
  data() {
    return {
      contentHeight: 0,
    }
  },
}
</script>

<style lang="scss" scoped>
  // 動作と無関係なcssは記載していません
</style>
```

### アコーディオンのコンテンツ部分（コンポーネント化）
```vue:AccordionContent.vue
<template>
  <ul ref="content" class="list">
    <li v-for="(item, index) in list" :key="index" class="item">
      {{ item }}
    </li>
  </ul>
</template>

<script>
export default {
  props: {
    list: { type: Array, required: true },
  },
  watch: {
    list: {
      handler() {
        this.$nextTick(() => {
          this.$emit('updateHeight', this.$el.getBoundingClientRect().height)
        })
      },
      deep: true,
    },
  },
}
</script>

<style lang="scss" scoped>
  // 動作と無関係なcssは記載していません
</style>
```

### アコーディオン（本体）

```vue:AccordionBase.vue
<template>
  <div class="Accordion">
    <div class="Accordion__title" @click="toggle">
      <slot name="title" />
    </div>
    <transition
      @before-enter="beforeEnter"
      @enter="enter"
      @before-leave="beforeLeave"
      @leave="leave"
    >
      <div v-show="isOpen" ref="content" class="Accordion__content">
        <slot name="content" :updateHeight="updateHeight" />
      </div>
    </transition>
  </div>
</template>

<script>
export default {
  props: {
    contentHeight: { type: Number, required: false, default: 0 },
  },
  data() {
    return {
      isOpen: false,
    }
  },
  watch: {
    contentHeight() {
      this.updateHeight(this.contentHeight)
    },
  },
  methods: {
    updateHeight(height) {
      this.$refs.content.style.height = `${height}px`
    },
    beforeEnter(el) {
      el.style.height = `0`
    },
    enter(el) {
      el.style.height = `${el.scrollHeight}px`
    },
    beforeLeave(el) {
      el.style.height = `${el.scrollHeight}px`
    },
    leave(el) {
      el.style.height = '0'
    },
    toggle() {
      this.isOpen = !this.isOpen
    },
  },
}
</script>
<style lang="scss" scoped>
// 動作と無関係なcssは記載していません

.Accordion {
  &__content {
    max-height: 400px;
    overflow-y: scroll;
    transition: height 400ms ease-in-out;
  }
}
</style>
```

## 終わりに
heightの値を直接書き換える点がややイケてない気がしなくもないですが、挙動としてはいい感じになると思います。