import { Controller } from "@hotwired/stimulus" // Neither layout animates for someone who asked for less motion; the two that // do part ways on the video, which the narrow screen grows into place and the // wide one carries in on the screen's own slide. const MOTION = "(prefers-reduced-motion: no-preference)" const NARROW = "(max-width: 1023.98px)" export default class extends Controller { static targets = ["video"] // Tapping through to the next stage hands over to the stage's own video: the // copy leaves, the hero grows into the space it frees, and the video fades up // behind the result. The next screen then arrives over the video's tail. exit(event) { if (this.leaving) return if (!this.hasVideoTarget) return if (!window.matchMedia(MOTION).matches) return this.narrow = window.matchMedia(NARROW).matches event.preventDefault() this.link = event.currentTarget if (this.narrow) { const answers = this.element.querySelector(".answers-container") if (answers) this.element.style.setProperty("--exit-shift", `${answers.offsetHeight}px`) // The last video hands over to the results rather than to another stage, so // the header leaves with the copy and the video takes the whole screen. if (event.params.full) { const header = this.element.querySelector(".stage-header-container") if (header) this.element.style.setProperty("--header-shift", `${header.offsetHeight}px`) this.element.classList.add("is-finishing") } } this.element.classList.add("is-exiting") if (!this.narrow) this.element.addEventListener("animationend", this.land) this.videoTarget.addEventListener("timeupdate", this.checkTail) this.videoTarget.addEventListener("ended", this.follow, { once: true }) this.videoTarget.addEventListener("error", this.follow, { once: true }) this.videoTarget.play().catch(this.follow) } // The screen is pushed off to the left with the video riding in beside it, so // once that lands the push is undone: the video ends up square in the viewport // and the screen back under it, which is the frame the next transition // captures and holds still while the stage slides in over it. land = (event) => { if (event && event.target !== this.element) return this.element.removeEventListener("animationend", this.land) this.element.classList.replace("is-exiting", "is-playing") } // The next stage assembles itself over the tail of the video rather than // after it, so the last frame is still moving while the new screen arrives. checkTail = () => { const { duration, currentTime } = this.videoTarget if (!duration || duration === Infinity) return if ((duration - currentTime) * 1000 <= this.#lead()) this.follow() } follow = () => { if (this.leaving) return this.leaving = true this.videoTarget.removeEventListener("timeupdate", this.checkTail) // A video shorter than the slide would otherwise be captured mid-push. if (!this.narrow) this.land() this.link?.click() } // How much of the video the incoming screen overlaps -- the same length the // transition itself runs for. #lead() { const declared = getComputedStyle(document.documentElement).getPropertyValue("--dur-base") return parseFloat(declared) * (declared.includes("ms") ? 1 : 1000) || 600 } }