Vueで幅に応じて...で省略されるテキストを作る

## はじめに
どうも。[スペリス](https://spalice.com)で開発をしていますyasunagaです。
この記事では、Vue.jsを用いて、テキストの幅に応じて、省略されるテキストを作成してみます。

例えば以下のテキストを省略したい場合。

:::note info
私は場合とにかくその永続院に対する事の時になるなけれた。おおかた半分に尊重人はもちろんその記憶ないただけで云ってなりでをも周旋思っうなて、どうにももっましんなけれあり。示威をさでしのもとにかく今日がいやしくもなけれでな。何とも大森さんが発展冠詞ぴたり相違にします火事その先生あいつかお話のという大実在なけれないますですて、いわゆる始めもあなたか言葉科学が作っから、ネルソン君ののを男
:::

以下のように...で自動的に省略されるようなコンポーネントを作成してみたいと思います。

:::note info
私は場合とにかくその永続院に対する事の時になるなけれた。おおかた半分に尊重人はもちろんその記憶ないただけで云ってなりでをも周旋思っうなて、どうにももっまし...
:::

## コンポーネントの作成
まず、コンポーネントを作成します。コンポーネントの名前はEllipsisText.vueとします。

また、ここではtypescriptを使用しております。

```vue:EllipsisText.vue
<template>
    {{ text }}
</template>

<script lang="ts">
import Vue from "vue"

export default Vue.extend({
    props: {
        text: {
            type: String,
            default: "",
    },
})
</script>
```

これだとtextをそのまま出力するコンポーネントなので、内部的に変換した文字を表示する必要があります。
変換後のdataでtruncatedTextを定義します。

```diff_vue:EllipsisText.vue
<template>
-    {{ text }}
+    {{ truncatedText }}
</template>

<script lang="ts">
import Vue from "vue"

export default Vue.extend({
    props: {
        text: {
            type: String,
            default: "",
    },
+   data() {
+       return {
+            truncatedText: "",
+       }
+   }
})
</script>
```

## font sizeを計算
まず、テキストのfont-sizeを計算します。

初めはミニマムにmountedで動的にfont-sizeを計算してみます。getComputedStyleを使用します。

```diff_vue:EllipsisText.vue
<template>
    {{ truncatedText }}
</template>

<script lang="ts">
import Vue from "vue"

export default Vue.extend({
    props: {
        text: {
            type: String,
            default: "",
    },
    data() {
        return {
             truncatedText: "",
        }
    },
+    mounted() {
+        const fontSize = window
+                .getComputedStyle(this.$el, null)
+                .getPropertyValue("font-size")
+                .replace("px", "")
+    }
})
</script>
```

## widthを計算
次にコンポーネントのwidthを動的に取得。こちらはthis.$el?.clientWidthが使えます。

```diff_vue:EllipsisText.vue
<template>
    {{ truncatedText }}
</template>

<script lang="ts">
import Vue from "vue"

export default Vue.extend({
    props: {
        text: {
            type: String,
            default: "",
    },
    data() {
        return {
             truncatedText: "",
        }
    },
    mounted() {
        const fontSize = window
                .getComputedStyle(this.$el, null)
                .getPropertyValue("font-size")
                .replace("px", "")
+        const clientWidth = this.$el?.clientWidth
    }
})
</script>
```

## 表示できる文字数を計算
widthをfont-sizeで割って、**何文字表示できるか**を計算します。
例えば、`width: 100px, font-size: 10px`で、中に入り切る文字数は10文字になります。

```diff_vue:EllipsisText.vue
<template>
    {{ truncatedText }}
</template>

<script lang="ts">
import Vue from "vue"

export default Vue.extend({
    props: {
        text: {
            type: String,
            default: "",
    },
    data() {
        return {
             truncatedText: "",
        }
    },
    mounted() {
        const fontSize = window
                .getComputedStyle(this.$el, null)
                .getPropertyValue("font-size")
                .replace("px", "")
        const clientWidth = this.$el?.clientWidth
+       const lines = 1
+       const words = (this.clientWidth / this.fontSize) * lines
    }
})
</script>
```

linesは表示したい行数です。2行目で省略したい場合、linesは2になります。propsで定義してやると、外から注入できて良いですね。

## はみ出たテキストを省略する
substrを使って、文字を削除し、...で省略します。入る文字数を超過していない場合は、textをそのまま代入します。

```diff_vue:EllipsisText.vue
<template>
    {{ truncatedText }}
</template>

<script lang="ts">
import Vue from "vue"

export default Vue.extend({
    props: {
        text: {
            type: String,
            default: "",
    },
    data() {
        return {
             truncatedText: "",
        }
    },
    mounted() {
        const fontSize = window
                .getComputedStyle(this.$el, null)
                .getPropertyValue("font-size")
                .replace("px", "")
        const clientWidth = this.$el?.clientWidth
        const lines = 1
        const words = (this.clientWidth / this.fontSize) * lines
+       this.truncatedText =
+           this.text.length <= words
+             ? this.text
+             : `${this.text.substr(0, words)}...`
    }
})
</script>
```

## リファクタリング
`clientWidth, fontSize`はcomputedに抽出したほうがテストとかもしやすくなるので、良いかもしれません。また、linesもpropsで定義して、外から指定できるようにすると良いかもしれませんね。

完成系はこちら。

```vue
<template>
  <p>
    {{ truncatedText }}
  </p>
</template>

<script lang="ts">
import Vue from "vue"

export default Vue.extend({
  props: {
    text: {
      type: String,
      default: "",
    },
    lines: {
      type: Number,
      default: 1,
    },
  },
  data() {
    return {
      truncatedText: "",
    }
  },
  computed: {
    fontSize(): number {
      const fontSize = window
        .getComputedStyle(this.$el, null)
        .getPropertyValue("font-size")
        .replace("px", "")
      // NOTE: fontSizeそのままだと横幅ギリギリまで省略されず、画面幅によっては折り返されてしまうので1を足している
      return parseInt(fontSize, 10) + 1
    },
    clientWidth(): number {
      return this.$el?.clientWidth
    },
  },
  mounted() {
    this.truncate()
  },
  methods: {
    truncate() {
      const words = (this.clientWidth / this.fontSize) * this.lines
      this.truncateText =
        this.text.length <= words
          ? this.text
          : `${this.text.substr(0, words)}...`
    },
  },
})
</script>
```

## テスト
最後にjestでテストを書きます。

```js
import { mount } from "@vue/test-utils"
import EllipsisText from "~/components/EllipsisText.vue"

describe("EllipsisText", () => {
  describe("text", () => {
    test("指定しない場合、デフォルト値が設定される", () => {
      const wrapper = mount(EllipsisText)
      expect(wrapper.vm.text).toEqual("")
    })
    test("指定した場合、文字が省略して表示される", async () => {
      const wrapper = await mount(EllipsisText, {
        propsData: {
          text: "ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWX",
        },
        computed: { fontSize: () => 18, clientWidth: () => 50 },
      })
      return wrapper.vm.$nextTick().then(() => {
        expect(wrapper.text()).toEqual("AB...")
      })
    })
    test("textが省略されるまで長くない場合、textがそのまま設定される", async () => {
      const wrapper = await mount(EllipsisText, {
        propsData: { text: "ABCD", lines: 1 },
        computed: { fontSize: () => 1, clientWidth: () => 4 },
      })
      return wrapper.vm.$nextTick().then(() => {
        expect(wrapper.text()).toEqual("ABCD")
      })
    })
  })
  describe("lines", () => {
    test("省略される行数を指定できる", async () => {
      const wrapper = await mount(EllipsisText, {
        propsData: {
          text: "ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWX",
          lines: 2,
        },
        computed: { fontSize: () => 2, clientWidth: () => 4 },
      })
      return wrapper.vm.$nextTick().then(() => {
        expect(wrapper.text()).toEqual("ABCD...")
      })
    })
  })
  describe("fontSize", () => {
    test("textのfontSize + 1を取得する", async () => {
      jest.spyOn(window, "getComputedStyle").mockImplementation(() => {
        return {
          getPropertyValue: jest.fn(() => ({ replace: jest.fn(() => 12) })),
        }
      })
      const wrapper = await mount(EllipsisText)
      expect(wrapper.vm.fontSize).toEqual(13)
    })
  })
  describe("clientWidth", () => {
    test("要素のclientWidthを取得する", () => {
      const wrapper = mount(EllipsisText)
      jest.spyOn(wrapper.vm, "clientWidth", "get").mockReturnValue(300)
      expect(wrapper.vm.clientWidth).toEqual(300)
    })
  })
})
```

## おわりに
以上で良い感じの実装ができました。
意外にjsでなんとかなりましたね。ご参考になれば幸いです。