👻

Turbo Streamsでの削除前にアニメーションを入れる

2021/07/18に公開

Turbo Sreamsでは action="remove" で要素を削除することができます。
これは例えば DELETE /resources/:id へのHTTPリクエストのレスポンスで使うと便利です。
この記事ではStimulusを利用して、Turbo Streamsでの要素削除時にアニメーションを入れる方法について書きたいと思います。

以下は

  • @hotwired/turbo: 7.0.0-beta.8
  • stimulus: 2.0.0

が前提です。

どうやるか

Stimulusで、以下のように使える leave_animation_controller.ts を書きます。

<li 
  id="resource-1" 
  data-controller="leave-animation" 
  data-leave-animation-base-class="fade-out"
>
  <!-- ... -->
</li>		
.fade-out {
  transition: opacity 200ms;
  opacity: 1;
}

.fade-out-active {
  opacity: 0;
}

leave_animation_controller.ts は以下のようにしてみました。

leave_animation_controller.ts
import { Controller } from "stimulus";
import { StreamElement } from "@hotwired/turbo/dist/types/elements";

function nextAnimationFrame() {
  return new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
}

const DEFAULT_TRANSITION = "all 0s ease 0s"
const DEFAULT_ANIMATION = "none 0s ease 0s 1 normal none running"

export default class extends Controller {
  static classes = ["base", "active"]
  readonly baseClass!: string
  readonly activeClass!: string
  readonly hasActiveClass!: boolean

  connect() {
    document.addEventListener("turbo:before-stream-render", this.hook)
  }

  disconnect() {
    document.removeEventListener("turbo:before-stream-render", this.hook)
  }

  hook = async (e: CustomEvent) => {
    const stream = e.target as StreamElement;
    if (stream.action === "remove" && stream.targetElements.includes(this.element)) {
      e.preventDefault();
      await this.leaveAnimation();
      stream.performAction()
    }
  }

  leaveAnimation = () => new Promise<void>(resolve => {
    const activeClass = this.hasActiveClass ? this.activeClass : `${this.baseClass}-active`
    this.element.addEventListener('transitionend', (event) => {
      if (event.target === this.element) resolve()
    })
    this.element.addEventListener('animationend', (event) => {
      if (event.target === this.element) resolve()
    })
    this.element.classList.add(this.baseClass)
    const style = window.getComputedStyle(this.element)
    if (style.getPropertyValue("transition") === DEFAULT_TRANSITION && style.getPropertyValue("animation") === DEFAULT_ANIMATION) {
      console.warn("No leave animation style found", this.element)
      resolve()
    } else {
      nextAnimationFrame()
        .then(() => this.element.classList.add(activeClass))
    }
  })
}

解説

Turboには様々なイベントがありますが、このうち turbo:before-stream-render を利用します。
このイベントは、Turbo Streamでの更新が起きる前に document に対してdispatchされて、

  • event.preventDefault() によって更新を止めることができる
  • event.target には StreamElement が入っており、 StreamElement#performAction によって更新を実行することができる

となっています。
なので、このイベントを利用して transitionendanimationend を待ってから削除するようにしています。

Controller#connect 内でイベントリスナーを追加していますが、これは
data-action="turbo:before-stream-render@document->leave-animation#hook"
とさせることもできました。ただ、必ず毎回指定するものですし、毎回明示的に書くメリットがないように感じたので Controller#connectで登録する形にしました。

実際動かせるものを置いておきます。
動作イメージ

https://github.com/en30/turbo-stream-leave-animation

参考

Discussion