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 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 panel sits above the game frame rather than inside it, so the next // screen renders behind it. Once it is there, the panel drops off the bottom // and leaves the intro playing underneath. leave() { if (this.leaving) return this.leaving = true document.addEventListener("turbo:frame-render", this.#slideAway, { once: true }) } #slideAway = async () => { this.element.classList.add("is-leaving") await this.#settle(this.element, "transitionend") this.close() } // 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) // Descendants and ::backdrop send their own events up through the same // element, and either would settle this early. const finish = (event) => { if (event && (event.target !== element || event.pseudoElement)) return element.removeEventListener(eventName, finish) clearTimeout(timer) resolve() } const timer = setTimeout(finish, (seconds || 0) * 1000 + 50) element.addEventListener(eventName, finish) }) } }