getBoundingClientRectとツールチップ

# この記事は
JavaScriptの`getBoundingClientRect`を用いたツールチップについての解説です。

# 環境

Vue2
TypeScript（decorator使用）

# 背景
`?`アイコンにカーソルを当てた時だけに表示させる汎用的なツールチップを作りたい。

# 課題
`?`アイコンの直下に表示させたい。
画面をスクロールしてもそれに追従させたい。

# 解決
## ツールチップコンポーネントtemplate
### ソース

```vue
<template>
  <span
    @mouseover="activate"
    @mouseleave="deactivate"
  >
    <slot name="activator"></slot>
    <div
      :class="{'tool-tip__content--active': isActive}"
      :style="contentStyleObj"
      ref="content"
    >
      <slot></slot>
    </div>
  </span>
</template>
```

### 解説
mouseoverとmouseleaveでツールチップの表示有無処理を発火します。

```vue

<span
  @mouseover="activate"
  @mouseleave="deactivate"
>
```

`?`アイコンは親コンポーネントからslotしてきます。

```vue

<slot name="activator"></slot>
```

表示フラグが立っている時にツールチップ本体を表示します。

```vue
:class="{'tool-tip__content--active': isActive}"
```

script部で算出した座標をスタイルに反映します。

```vue

:style="contentStyleObj"
```


## ツールチップコンポーネントscript

TypeScriptを用いています。

```vue
<script lang="ts">
import { Component, Prop } from 'vue-property-decorator';
import BaseView from '@/views/Base';

@Component
export default class ToolTipComponent extends BaseView {
  // ツールチップ表示フラグ
  isActive = false;

  // 表示位置
  contentPosition = {
    top: 0,
    left: 0
  }

  // ツールチップを表示する基準位置
  defaultPosition = {
    top: 40,
    left: 100
  }

  // ?アイコンにマウスオーバーした時の表示処理
  activate() {
    const target = event!.target as any;
    const rect = target!.getBoundingClientRect();
    this.contentPosition.top = rect.top + window.pageYOffset + this.defaultPosition.top;
    this.contentPosition.left = rect.left - this.defaultPosition.left;

    this.isActive = true;
  }

  // マウスオーバーの解除で非表示
  deactivate() {
    this.isActive = false;
  }

  // 座標をスタイルに指定
  get contentStyleObj() {
    return {
      top: `${this.contentPosition.top}px`,
      left: `${this.contentPosition.left}px`,
    }
  }

  // 一番上の親要素の直下にツールチップを配置する
  mounted() {
    const app = document.getElementById('app');
    app!.appendChild(this.$refs.content as any);
  }

  // コンポーネント破壊時にツールチップを削除する
  beforeDestroy() {
    const app = document.getElementById('app');
    app!.removeChild(this.$refs.content as any);
  }
}
</script>
```

### 解説

表示処理では、`getBoundingClientRect()`を用いてビューポートからの座標を取得します。
ツールチップの表示位置をデフォルトポジションとして調節し、表示座標を算出します。
その後、表示スタイルを有効にするためのフラグを立てます。

```typescript

activate() {
  const target = event!.target as any;
  const rect = target!.getBoundingClientRect();
  this.contentPosition.top = rect.top + window.pageYOffset + this.defaultPosition.top;
  this.contentPosition.left = rect.left - this.defaultPosition.left;

  this.isActive = true;
}
```

そして算出された座標をスタイルに渡すゲッターを定義します。

```typescript

get contentStyleObj() {
  return {
    top: `${this.contentPosition.top}px`,
    left: `${this.contentPosition.left}px`,
  }
}
```

ツールチップは画面描画時には非表示ですが、DOM内には配置しておきます。
一番外側の要素の子要素として配置します。
画面遷移時等のコンポーネント破壊時には配置した要素も忘れず削除するようにしましょう。

```typescript

// 一番上の親要素の直下にツールチップを配置する
mounted() {
  const app = document.getElementById('app');
  app!.appendChild(this.$refs.content as any);
}

// コンポーネント破壊時にツールチップを削除する
beforeDestroy() {
  const app = document.getElementById('app');
  app!.removeChild(this.$refs.content as any);
}
```

## 利用コンポーネント

親コンポーネントでは`?`アイコンを`activator`スロットに配置。
またツールチップ内の文章はデフォルトスロットに配置します。

```vue
<ToolTip class="tool-tip" v-show="toolTipMessage">
  <template slot="activator">
    <img src="@/assets/img/icon/icon-help.png" alt="help">
  </template>
  <div class="tool-tip__help-text">
    {{ toolTipMessage }}
  </div>
</ToolTip>
```

# まとめ
少々トリッキーなコンポーネントとなってしまいましたが、`getBoundingClientRect`はこういう使い方もできる点は参考になりますね。
応用もできると思うので、座標を取って何かするような動作のコンポーネントに使うと良いかもしれません。
間違っている点、改善点あればご教授頂けますと幸いです。