|
|
import { Controller } from "@hotwired/stimulus"
|
|
|
|
|
|
export default class extends Controller {
|
|
|
static targets = ["dialog"]
|
|
|
|
|
|
connect() {
|
|
|
this.#openWhenSettled()
|
|
|
}
|
|
|
|
|
|
close() {
|
|
|
this.dialogTarget.close()
|
|
|
}
|
|
|
|
|
|
// A click whose target is the <dialog> itself landed on the backdrop
|
|
|
// (the panel fills the content box), so dismiss.
|
|
|
backdropClose(event) {
|
|
|
if (event.target === this.dialogTarget) this.close()
|
|
|
}
|
|
|
|
|
|
remove() {
|
|
|
this.dispatch("closed", { target: window })
|
|
|
this.element.remove()
|
|
|
}
|
|
|
|
|
|
// The screen underneath may still be animating out. The wave is the last
|
|
|
// thing to leave, so waiting on it holds the modal until the exit is done.
|
|
|
async #openWhenSettled() {
|
|
|
const wave = document.querySelector(".is-exiting .hero-container svg.ico-wave")
|
|
|
if (wave) await this.#settle(wave, "animationend")
|
|
|
|
|
|
if (!this.element.isConnected) return
|
|
|
|
|
|
this.dialogTarget.showModal()
|
|
|
this.dispatch("opened", { target: window })
|
|
|
this.#playEntrance()
|
|
|
}
|
|
|
|
|
|
// The hero opens full-bleed, pushing the copy and the markers off the bottom.
|
|
|
// Once the wave has landed, the carousel and the hero shrink back to their
|
|
|
// measured resting heights, which carries everything below up into view.
|
|
|
// Sizing both is what hides the slide's own copy: the li clips whatever the
|
|
|
// hero pushes past it. The wave rides the same shrink from the screen edge
|
|
|
// up to the seam, since it no longer sits inside the hero it draws under.
|
|
|
async #playEntrance() {
|
|
|
const panel = this.element.querySelector(".modal-panel")
|
|
|
const frame = panel?.querySelector(".carousel-frame")
|
|
|
const carousel = frame?.querySelector(".carousel")
|
|
|
if (!carousel) return
|
|
|
|
|
|
const heroes = Array.from(carousel.querySelectorAll(":scope > li > .hero-container"))
|
|
|
const boxes = [carousel, ...heroes]
|
|
|
const rest = new Map(boxes.map((box) => [box, box.offsetHeight]))
|
|
|
// Border-box, not clientHeight: a scrollbar must not shorten the hero and
|
|
|
// leave a strip of panel showing under it.
|
|
|
const full = this.dialogTarget.getBoundingClientRect().height
|
|
|
const wave = frame.querySelector(":scope > svg.ico-wave")
|
|
|
|
|
|
panel.classList.add("is-entering")
|
|
|
boxes.forEach((box) => {
|
|
|
box.style.transition = "none"
|
|
|
box.style.height = `${full}px`
|
|
|
})
|
|
|
if (wave) {
|
|
|
wave.style.transition = "none"
|
|
|
wave.style.bottom = "0px"
|
|
|
}
|
|
|
getComputedStyle(carousel).height
|
|
|
boxes.forEach((box) => (box.style.transition = ""))
|
|
|
if (wave) wave.style.transition = ""
|
|
|
|
|
|
if (wave) await this.#settle(wave, "animationend")
|
|
|
|
|
|
boxes.forEach((box) => (box.style.height = `${rest.get(box)}px`))
|
|
|
if (wave) wave.style.bottom = ""
|
|
|
await this.#settle(carousel, "transitionend")
|
|
|
|
|
|
panel.classList.remove("is-entering")
|
|
|
boxes.forEach((box) => (box.style.height = ""))
|
|
|
}
|
|
|
|
|
|
// Resolves when the element's animation or transition ends. Safari doesn't
|
|
|
// reliably have one registered at the moment we ask for it, so the declared
|
|
|
// timing is used as a fallback rather than getAnimations().
|
|
|
#settle(element, eventName) {
|
|
|
return new Promise((resolve) => {
|
|
|
const style = getComputedStyle(element)
|
|
|
const seconds =
|
|
|
eventName === "animationend"
|
|
|
? parseFloat(style.animationDuration) + parseFloat(style.animationDelay)
|
|
|
: parseFloat(style.transitionDuration) + parseFloat(style.transitionDelay)
|
|
|
|
|
|
const finish = () => {
|
|
|
element.removeEventListener(eventName, finish)
|
|
|
clearTimeout(timer)
|
|
|
resolve()
|
|
|
}
|
|
|
|
|
|
const timer = setTimeout(finish, (seconds || 0) * 1000 + 50)
|
|
|
element.addEventListener(eventName, finish)
|
|
|
})
|
|
|
}
|
|
|
}
|