Vue3-carouselの両端を固定する

# はじめに
Vueのプロジェクトでカルーセルスライダーを作りたい時、自分で一から作るのは面倒なので、ライブラリを利用することも多いんじゃないかと思います。

https://vue3-carousel.ismail9k.com/

Vue3でカルーセルスライダーを使う選択肢としてはVue3-carouselが便利なので、こちらを選ぶ方が多いと思いますが、Vue3-carouselは両端の固定を行わないため、少し挙動に違和感を感じます。

例えばスライド1は左端なのでこれ以上左に移動して欲しくありません。
![マイムービー 1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303906/a255ea6b-590d-6eb1-a170-4e6ed20e8dd1.gif)

右端に当たるスライド10も同様です。
![マイムービー 1-1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303906/85dc83da-56a4-92bc-2ef8-ce0d7392a632.gif)

今回はこの挙動を改善し、両端のスライダーを固定する機能を自作したので共有します。
(slick.js, swiperなども同様に端が固定されませんが、本記事の内容を修正することでそちらも対応できるのではと思います)


# Vue3-carouselの仕組み
Vue3-carouselはtranslateXの値を0から変化させていくことで横にスライドしていく動作を実現しています。

スライド1の時はtranslateXが0pxになっています。
![スクリーンショット 2024-11-07 10.18.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303906/d7e86de0-e0bc-4587-80c3-976b5bba0e7a.png)
![スクリーンショット 2024-11-07 10.18.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303906/3265a3d7-422b-0dd4-7b20-8af0cce760ce.png)


スライド10だと、translateXが-1800pxです。
![スクリーンショット 2024-11-07 10.19.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303906/1c015897-1639-a31a-87d8-14ec51be3305.png)
![スクリーンショット 2024-11-07 10.19.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303906/004dd96d-c9d7-76c3-e9f3-5ea98f92bb8d.png)

## 両端の挙動の原因
左端、右端からさらに移動しようとした時に固定されない原因は、translateXの最大値、最小値の制御を行う機能が存在していないためです。
つまり、
- translateXが0pxより大きくならない
- translateXが-1800px未満にならない

が達成されれば両端を固定できます。

# 解決
MutationObserverを利用してcarousel__trackクラスのスタイルの変化を監視し、translateXが上記条件の幅に収まるように実装しました。
下記が実際に利用したコードです。

```vue
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import 'vue3-carousel/dist/carousel.css'
import { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel'

onMounted(() => {
  const track = document.querySelector('.carousel__track') as HTMLElement;
  if (!track) return;

  const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
        const transform = track.style.transform;
        // 正規表現でtranslateXが存在するかどうか確認
        const match = transform.match(/translateX\(([-\d.]+)px\)/);
        if (!match) return;

        // 現在のpx値を取得
        const currentX = parseFloat(match[1]);
        
        let limitedX = 0;
        // -1800より小さくならず、0より大きくならない範囲指定。
        // 今回は固定値で入力しているが、スライドの数や配置方法で自由に変更すると良い
        limitedX = Math.max(-1800, Math.min(0, currentX));
        
        if (currentX !== limitedX) {
          track.style.transform = `translateX(${limitedX}px)`;
        }
      }
    });
  });

  observer.observe(track, {
    attributes: true,
    attributeFilter: ['style']
  });

  onUnmounted(() => {
    observer.disconnect();
  });
});
</script>

<template>
  <div>
    <Carousel :items-to-show="1">
      <Slide v-for="slide in 10" :key="slide">
        <div class="carousel__item">{{ slide }}</div>
      </Slide>
      <template #addons>
        <Navigation />
        <Pagination />
      </template>
    </Carousel>
  </div>
</template>
```

これで両端の固定ができました。実際の挙動が下のgifになります。
![マイムービー 3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303906/47259031-4751-97c7-fd28-bdd1cc0a499b.gif)

![マイムービー ３−２.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303906/41c0a149-4eea-19a9-bc8c-78bfdf50d546.gif)