ドラッグ可能な複数のポップアップウィンドウをPortalVue(Teleport)で制御する話


この記事は「弁護士ドットコム Advent Calendar 2020」の 7 日目の記事です。
昨日は@matsuyoshi30の[git switch からはじめる CLI ツール作成 ](https://blog.matsuyoshi30.net/2020/12/06/create-cli-tool-starting-from-git-switch/)でした。

## はじめに

弁護士向けのアプリケーション(SPA)のクライアント側をVueで開発しています。
今回、[PortalVue](https://portal-vue.linusb.org/)というライブラリを用いて複数のドラッグ可能なポップアップウィンドウを制御する開発を行ったので、考えたことや得た知見を書きます。
([※余談ですが、PortalVueはVue3でteleportという名称でおおよそ同じ機能がビルトインされています。](https://v3.vuejs.org/guide/teleport.html))

一応デモを用意したのでだいたいこんな感じのものを作る解説記事にもなってます。
(色付き要素はそれぞれドラッグができて、最後に触った要素が最前面に来る実装になっています。)
https://portal-sample.vercel.app/

[ソース(GitHub)はこちら](https://github.com/shotanue/portal-sample)


## PortalVueを使うに至った背景
現在携わっているアプリケーションでは、フローティング状態でドラッグ&ドロップで移動できるコンポーネントが１画面に複数存在するような機能が組まれています。

(例えばGmailのPC画面では右ペインでGoogleCalendarやGoogleTodoを操作できますが、これの小さいものがドラッグとリサイズ可能なウィンドウとして存在しているイメージです。)

![e0457f65-ed63-4bf0-8431-a3b4f78d3381.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144581/aae7b696-9495-6b16-bab2-35de580dfa46.png)


ドラッグ可能という振る舞いを持った機能を作り始めた当初は、同時にフローティングする要素は最大で一つだけでした。しかしながらアプリケーションの開発が進むにつれ、最大で二つ三つと増える状況になりました。

ドラッグ可能な要素が複数存在していると、それぞれのコンポーネントで重なり合う状態が発生してきます。このような状況だと、クリックやドラッグといったイベントに応じて最前面に操作対象のコンポーネントが出てきて欲しいですが、実現するにはz軸上での前後関係を制御しなければなりません。

もしコンポーネントがすべて兄弟要素であれば、コンポーネントごとにz-indexを振る実装をしても良いかもしれません。ですがフローティングさせるコンポーネントは必ずしも兄弟要素でなないという状況や、スタッキングコンテキストの関係もありz-indexのみで前後関係を制御しようとすると実装が複雑になるため、この辺りの設計を考えることになりました。

## 目指した設計イメージ
結論としては、「フローティング対象の複数コンポーネントを特定のDOMの直下に兄弟要素として並べる」という方向の実装を試みました。

例えば、重なるようなスタイルが画像のように当てられている要素があったとして、

![3716b596-b36c-4c3b-a2cd-eabe47f11338.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144581/9f8d1966-9eb0-2583-e5ae-420fdeb404b8.jpeg)


以下のように全て兄弟要素だとします。

```html
<div>
    <A/>
    <B/>
    <C/>
    <D/>
</div>
```

画面の描画としては、兄弟要素がそれぞれ同じz-indexの場合だと、始めの要素より最後の要素の方が上に重なりあう形になります。
つまり同じz-indexの兄弟要素を並べると、**DOMの順番だけでフローティングの前後関係が表現できる**ことになるため、この方向を目指しました。


## PortalVue(teleport)について
「特定のコンポーネントを全て実DOMとしては、同じ親の直下に並べたい」という課題に対して、Vueの実装で助けになったのが、[PortalVue](https://portal-vue.linusb.org/)というライブラリでした。


![d552c7bb-5b4f-455f-befb-eb0181f71dd2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144581/f9dc3d15-2459-45ff-2ac3-57fb44c2618a.gif)



PortalVueが提供する機能の大きなところとしては以下になります。

- 特定コンポーネントのマウント先DOMを指定できるようにする
  - マウント先DOMは別コンポーネントのどこにいても良い（多分）
    - (これはちょっと試していないのですが、CSSセレクタも使えるようなのでVueアプリの外側のDOMでもマウントできるかもしれません)
- 複数のコンポーネントのマウント先を一箇所に集約できる

これらの機能を使うことで、先に説明した複数のフローティングさせたいにコンポーネントを実質的に兄弟要素として扱えるようになります。

## PortalVueの使い方
ここでは複数のコンポーネントを兄弟要素にするサンプルをざっくり記述します。
(単一コンポーネントの転送など、もっと基本的な使い方は(PortalVueのドキュメントに記載があるのでそちらを参照していただけたらと思います。)

このライブラリのインターフェースとして、まずPortalTargetとPortalというコンポーネントが用意されています。

```js
import { PortalTarget, Portal } from 'portal-vue'
// (ちなみに他にはMountingPortal・WormHoleといったコンポーネントなどもあるようです。今回は使いませんが。)
```

---

PortalTargetというコンポーネントはマウント先の場所のプレースホルダの役割として振る舞います。
nameというプロパティは識別子になっており、後述するPortalコンポーネントでこの識別子を用いてマウントするPortalTargetを指定します。
ここではdestinationという文字列を渡しています。
multipleというオプションは、複数のコンポーネントをPortalTargetに渡す際に必要なオプションです。

また、このコード(index.vue)ではA・Bというコンポーネントを呼び出しており、コンポーネントの詳細は以下に続きます。

```vue:index.vue
<div>
    <div>
        <A/>
    </div>
    <B/>
<div/>
<PortalTarget name="destination" multiple />
```

---

Portalというコンポーネントはマウント先を変更したいコンポーネントをラップして使うものです。
ここではA・B各コンポーネントのpタグを移動させようと思うので、それぞれpタグをラップさせています。

その他のオプションとして、orderがあり、これはマウント先での順番を指定するパラメータで複数のコンポーネントをPortalTargetに渡す際に必要です。
（なお、指定するパラメータは1始まりにする必要があり、注意が必要かもしれません）

以下のサンプルではAというコンポーネントに2を付与して、Bというコンポーネントに1を付与しています。
最終的にB→Aという順番でコンポーネントを並べるイメージです。

```vue:A.vue
<Portal to="destination" :order="2">
  <p>component-A</p>
</Portal>
```

```vue:B.vue
<Portal to="destination" :order="1">
  <p>component-B</p>
</Portal>
```

---

ここまで踏まえて、index.vueが実行されると、最終的に作られるDOMは以下の形になります。

- A・B が本来存在していた箇所にはクラスに'v-portal'が付与された空divが置かれる
- PortalTargetコンポーネントは'vue-portal-target'というクラスが付与されたdivになる
- div.vue-portal-targetの配下に、pタグがそれぞれ並ぶ
- 並び順はorderというオプションでした数値の順番で並び、大きい数字の要素が後ろに並ぶ


```html:結果
<div>
    <div>
        <div class="v-portal"><div/>
    </div>
    <div class="v-portal"><div/>
</div>
<div class="vue-portal-target">
    <p>component-B</p>
    <p>component-A</p>
</div>
```


## 実際に作ってみる

以上を踏まえて、アプリケーションではラップするとフローティングウィンドウで表示される以下のようなコンポーネントを作りました。実際のアプリケーションでもおおよそ似たようなコンポーネントを作ってあります。

```vue
<floatable>
    // フローティングでドラッグされる何かが入る
</floatable>
```

実際のアプリケーションコードは持ってこれないので、デモを用意しました。

機能としては、以下です。

- 複数コンポーネントがフローティング可能
- フローティング対象は子要素としてslotで受け取る
- (実際のアプリケーションではウィンドウがリサイズ可能だったりします)



## 複数の前後制御の状態管理
フローティングさせたいコンポーネントを識別するために、それら識別子を保持するシンプルなリストを用意し、このリストで保持している順番がそのままDOMの順番として扱われる形にしました。

イメージとしては以下の画像になります。
初期状態でfloatingOrderで[a,b,c]というリストを持っていますが、aのコンポーネントをクリックするとこのリストの順番が[b,c,a]に変化してaが一番最後に移動します。
するとこの際、リストの順番に対応する形で描画されるコンポーネントの順番も変わるイメージです。


![8419b1e4-76d9-4040-91c6-00a7f3413542.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144581/8b02dcf0-10be-14b5-1719-32349aeb0507.jpeg)


## デモコード
デモの具体的な実装としてはおおよそ以下の形になっており、[index.vue](https://github.com/shotanue/portal-sample/blob/main/components/floatable.vue)のdataにfloatingOrderという変数を用意して、ここで順番を保持しています。
'floating-objects'として名前付けしたPortalTargetを配置しており、floatableコンポーネントに関数を経由してfloatingOrderから各コンポーネントのorder値を渡しています。

bringToFrontという関数は指定したコンポーネントのidをfloatingOrderの最後に移動する処理です。
フローティングしている各コンポーネントが操作されたタイミング（ここではonDragStart）で呼び出されます。

```vue:index.vue
<template>
  <main class="relative w-screen h-screen">
    <floatable
      v-for="(item, index) in items"
      :key="index"
      :item="item"
      :order="getOrder(item.id)"
      @onDragStart="bringToFront(item.id)"
    >
      <div>{{ item.text }}</div>
    </floatable>
    <PortalTarget name="floating-objects" multiple />
  </main>
</template>

<script>
import { PortalTarget } from 'portal-vue'
import floatable from '~/components/floatable.vue'
export default {
  components: {
    floatable,
    PortalTarget,
  },
  data() {
    return {
      items: [
        { id: 'a', top: 0, left: 0, text: 'A', color: 'green' },
        { id: 'b', top: 50, left: 50, text: 'B', color: 'red' },
        { id: 'c', top: 100, left: 100, text: 'C', color: 'blue' },
      ],
      floatingOrder: ['a', 'b', 'c'],
    }
  },
  methods: {
    bringToFront(identifier) {...},
    getOrder(identifier) {...},
  },
}
</script>
```

続いてfloatableコンポーネントは以下のようになっています。
portalコンポーネントのtoでfloatable-objectsを指定することでPortalTargetと紐づけています。
同時にpropsで受けたorder値も渡しています。


```vue:floatable.vue
<template>
  <portal to="floating-objects" :order="order">
    <vue-draggable-resizable
      :key="item.id"
      class="absolute flex justify-center items-center shadow-md"
      :class="getColor(item.color)"
      :x="item.top"
      :y="item.left"
      :w="100"
      :h="100"
      @activated="$emit('onDragStart')"
    >
      <slot />
    </vue-draggable-resizable>
  </portal>
</template>

<script>
import VueDraggableResizable from 'vue-draggable-resizable'
import { Portal } from 'portal-vue'
export default {
  components: {
    VueDraggableResizable,
    Portal,
  },
  props: {
    item: {
      type: Object,
      required: true,
    },
    order: {
      type: Number,
      required: true,
    },
  },
  methods: {
    getColor(color) {...},
  },
}
</script>
```

ここまでの実装で、おおよそ「フローティングしたいコンポーネントを実DOMとしては同じ親の直下に並べる」という希望を満たせる想定です。また、適切なタイミングでorder値を変更する処理を加えると「特定のコンポーネントを最前面に出す」という実装ができます。ここではbringToFrontを呼び出す箇所が該当します。


デモの解説は一旦ここまでですが、実際のアプリケーションではもちろん細かい制御の部分だったり、デモにはない仕様で差異があるものの、前後関係の制御周りなどの基本的な考え方自体はデモと同じです。

記事頭にも記載したリンクと同じですが、[実際に動くデモはこちら](https://portal-sample.vercel.app/)に置いてあります。
[ソース(GitHub)はこちら](https://github.com/shotanue/portal-sample)です。

## おわりに
アプリケーション内で複数のコンポーネントをフローティングさせる仕様はあまりないかもしれませんが、[PortalVue公式ドキュメントのUseCases](https://portal-vue.linusb.org/guide/getting-started.html#use-cases)にある通り、小さいところだと、例えばモーダルやオーバーレイの制御に取り入れるといったユースケースでもマッチするかもしれません。

補足としては、記事で詳細な書きませんでしたが、実際のアプリケーションではモバイルでは一切フローティングをさせていない点を加えておきます。Portalコンポーネントにdisableオプションがあるため、モバイルではPortalTargetに転送させない(フローティングさせない)ようにする制御を加えていたりします。

その他の開発上の注意点として、order値が重複やスタイルの当て方に気をつける必要がありました。

- order値が重複すると、例えば「AをクリックしたがBにクリック判定が入る」、「AをドラッグしようとしたがBがドラッグされる」といったイベント関連で意図しない挙動をする可能性がある
- Portalで飛ばされた要素は実DOMとして親要素が変わるので、スタイル周りの注意点として、Potalコンポーネントの子要素から先祖要素の仮装DOMに依存したCSSを当てると、転送先で想定した先祖要素が存在せずスタイルが崩れる恐れがある

こうしたリスクや扱いづらい点もあるため、実DOMを触る実装はなるべく避けるべきという見方もあると思いますが、今回のような実装を取り入れた結果として、スタッキングコンテキストの考慮やz-indexの細かな計算が不要になった点がありました。他にも、前後関係の状態がシンプルなリストで表現・保持できるようになった点はメリットとして感じたところもあり、状況によっては今回のような選択肢もありかもしれません。

明日(12/8)の[弁護士ドットコム Advent Calendar 2020](https://qiita.com/advent-calendar/2020/bengo4com)は [@t_odash](https://qiita.com/t_odash)さんです。