import { Controller } from '@hotwired/stimulus'; import { Turbo } from '@hotwired/turbo-rails'; class Modal { constructor(id) { this.id = id; } async open() { this.debug("open"); } async dismiss() { this.debug(`dismiss`); } beforeVisit(frame, e) { this.debug(`before-visit`, e.detail.url); } popstate(frame, e) { this.debug(`popstate`, e.state); } async pop(event, callback) { this.debug(`pop`); const promise = new Promise((resolve) => { window.addEventListener( event, () => { resolve(); }, { once: true }, ); }); callback(); return promise; } get frameElement() { return document.getElementById(this.id); } get controller() { return this.frameElement?.kpop; } get modalElement() { return this.frameElement?.querySelector("[data-controller*='kpop--modal']"); } get currentLocationValue() { return this.modalElement?.dataset["kpop-ModalCurrentLocationValue"] || "/"; } get fallbackLocationValue() { return this.modalElement?.dataset["kpop-ModalFallbackLocationValue"]; } get isCurrentLocation() { return ( window.history.state?.turbo && Turbo.session.location.href === this.src ); } static debug(event, ...args) { } debug(event, ...args) { } } class ContentModal extends Modal { static connect(frame, element) { frame.open(new ContentModal(element.id), { animate: false }); } constructor(id, src = null) { super(id); if (src) this.src = src; } /** * When the modal is dismissed we can't rely on a back navigation to close the * modal as the user may have navigated to a different location. Instead we * remove the content from the dom and replace the current history state with * the fallback location, if set. * * If there is no fallback location, we may be showing a stream modal that was * injected and cached by turbo. In this case, we clear the frame element and * do not change history. * * @returns {Promise} */ async dismiss() { const fallbackLocation = this.fallbackLocationValue; await super.dismiss(); if (this.visitStarted) { this.debug("skipping dismiss, visit started"); return; } if (!this.isCurrentLocation) { this.debug("skipping dismiss, not current location"); return; } this.frameElement.innerHTML = ""; if (fallbackLocation) { window.history.replaceState(window.history.state, "", fallbackLocation); } } beforeVisit(frame, e) { super.beforeVisit(frame, e); this.visitStarted = true; frame.scrimOutlet.hide({ animate: false }); } get src() { return new URL( this.currentLocationValue.toString(), document.baseURI, ).toString(); } } class FrameModal extends Modal { /** * When the FrameController detects a frame element on connect, it runs this * method to sanity check the frame src and restore the modal state. * * @param frame FrameController * @param element TurboFrame element */ static connect(frame, element) { const modal = new FrameModal(element.id, element.src); // state reconciliation for turbo restore of invalid frames if (modal.isCurrentLocation) { // restoration visit this.debug("restore", element.src); return frame.open(modal, { animate: false }); } else { console.warn( "kpop: restored frame src doesn't match window href", modal.src, window.location.href, ); return frame.clear(); } } /** * When a user clicks a kpop link, turbo intercepts the click and calls * navigateFrame on the turbo frame controller before setting the TurboFrame * element's src attribute. KPOP intercepts this call and calls this method * first so we cancel problematic navigations that might cache invalid states. * * @param location URL requested by turbo * @param frame FrameController * @param element TurboFrame element * @param resolve continuation chain */ static visit(location, frame, element, resolve) { // Ensure that turbo doesn't cache the frame in a loading state by cancelling // the current request (if any) by clearing the src. // Known issue: this won't work if the frame was previously rendering a useful src. if (element.hasAttribute("busy")) { this.debug("clearing src to cancel turbo request"); element.src = ""; } if (element.src === location) { this.debug("skipping navigate as already on location"); return; } if (element.src && element.src !== window.location.href) { console.warn( "kpop: frame src doesn't match window", element.src, window.location.href, location, ); frame.clear(); } this.debug("navigate to", location); resolve(); } constructor(id, src) { super(id); this.src = src; } /** * FrameModals are closed by running pop state and awaiting the turbo:load * event that follows on history restoration. * * @returns {Promise} */ async dismiss() { await super.dismiss(); if (!this.isCurrentLocation) { this.debug("skipping dismiss, not current location"); } else { await this.pop("turbo:load", () => window.history.back()); } // no specific close action required, this is turbo's responsibility } /** * When user navigates from inside a Frame modal, dismiss the modal first so * that the modal does not appear in the history stack. * * @param frame FrameController * @param e Turbo navigation event */ beforeVisit(frame, e) { super.beforeVisit(frame, e); e.preventDefault(); frame.dismiss({ animate: false }).then(() => { Turbo.visit(e.detail.url); this.debug("before-visit-end"); }); } } class Kpop__FrameController extends Controller { static outlets = ["scrim"]; static targets = ["modal"]; static values = { open: Boolean, }; connect() { this.debug("connect", this.element.src); this.element.kpop = this; // allow our code to intercept frame navigation requests before dom changes installNavigationInterception(this); if (this.element.src && this.element.complete) { this.debug("new frame modal", this.element.src); FrameModal.connect(this, this.element); } else if (this.modalElements.length > 0) { this.debug("new content modal", window.location.pathname); ContentModal.connect(this, this.element); } else { this.debug("no modal"); this.clear(); } } disconnect() { this.debug("disconnect"); delete this.element.kpop; delete this.modal; } scrimOutletConnected(scrim) { this.debug("scrim-connected"); this.scrimConnected = true; if (this.openValue) { scrim.show({ animate: false }); } else { scrim.hide({ animate: false }); } } openValueChanged(open) { this.debug("open-changed", open); this.element.parentElement.style.display = open ? "flex" : "none"; } async open(modal, { animate = true } = {}) { if (this.isOpen) { this.debug("skip open as already open"); this.modal ||= modal; return false; } await this.dismissing; return (this.opening ||= this.#nextFrame(() => this.#open(modal, { animate }), )); } async dismiss({ animate = true, reason = "" } = {}) { if (!this.isOpen) { this.debug("skip dismiss as already closed"); return false; } await this.opening; return (this.dismissing ||= this.#nextFrame(() => this.#dismiss({ animate, reason }), )); } async clear() { // clear the src from the frame (if any) this.element.src = ""; // remove any open modal(s) this.modalElements.forEach((element) => element.remove()); // mark the modal as hidden (will hide scrim on connect) this.openValue = false; // close the scrim, if connected if (this.scrimConnected) { return this.scrimOutlet.hide({ animate: false }); } // unset modal this.modal = null; } // EVENTS popstate(event) { this.modal?.popstate(this, event); } /** * Incoming frame render, dismiss the current modal (if any) first. * * We're starting the actual visit * * @param event turbo:before-render */ beforeFrameRender(event) { this.debug("before-frame-render", event.detail.newFrame.baseURI); event.preventDefault(); this.dismiss({ animate: true, reason: "before-frame-render" }).then(() => { this.debug("resume-frame-render", event.detail.newFrame.baseURI); event.detail.resume(); }); } beforeStreamRender(event) { this.debug("before-stream-render", event.detail); const resume = event.detail.render; // Defer rendering until dismiss is complete. // Dismiss may change history so we need to wait for it to complete to avoid // losing DOM changes on restoration visits. event.detail.render = (stream) => { (this.dismissing || Promise.resolve()).then(() => { this.debug("stream-render", stream); resume(stream); }); }; } beforeVisit(e) { this.debug("before-visit", e.detail.url); // ignore visits to the current frame, these fire when the frame navigates if (e.detail.url === this.element.src) return; // ignore unless we're open if (!this.isOpen) return; this.modal.beforeVisit(this, e); } frameLoad(event) { this.debug("frame-load"); const modal = new FrameModal(this.element.id, this.element.src); window.addEventListener( "turbo:visit", (e) => { this.open(modal, { animate: true }); }, { once: true }, ); } get isOpen() { return this.openValue && !this.dismissing; } get modalElements() { return this.element.querySelectorAll("[data-controller*='kpop--modal']"); } async #open(modal, { animate = true } = {}) { this.debug("open-start", { animate }); const scrim = this.scrimConnected && this.scrimOutlet; this.modal = modal; this.openValue = true; await modal.open({ animate }); await scrim?.show({ animate }); delete this.opening; this.debug("open-end"); } async #dismiss({ animate = true, reason = "" } = {}) { this.debug("dismiss-start", { animate, reason }); // if this element is detached then we've experienced a turbo navigation if (!this.element.isConnected) { this.debug("skip dismiss, element detached"); return; } if (!this.modal) { console.warn("modal missing on dismiss"); } await this.scrimOutlet.hide({ animate }); await this.modal?.dismiss(); this.openValue = false; this.modal = null; delete this.dismissing; this.debug("dismiss-end"); } async #nextFrame(callback) { return new Promise(window.requestAnimationFrame).then(callback); } debug(event, ...args) { } } /** * Monkey patch for Turbo#FrameController. * * Intercept calls to navigateFrame(element, location) and ensures that src is * cleared if the frame is busy so that we don't restore an in-progress src on * restoration visits. * * See Turbo issue: https://github.com/hotwired/turbo/issues/1055 * * @param controller FrameController */ function installNavigationInterception(controller) { const TurboFrameController = controller.element.delegate.constructor.prototype; if (TurboFrameController._navigateFrame) return; TurboFrameController._navigateFrame = TurboFrameController.navigateFrame; TurboFrameController.navigateFrame = function (element, url, submitter) { const frame = this.findFrameElement(element, submitter); if (frame.kpop) { FrameModal.visit(url, frame.kpop, frame, () => { TurboFrameController._navigateFrame.call(this, element, url, submitter); }); } else { TurboFrameController._navigateFrame.call(this, element, url, submitter); } }; } class Kpop__ModalController extends Controller { static values = { fallback_location: String, layout: String, }; connect() { this.debug("connect"); if (this.layoutValue) { document.querySelector("#kpop").classList.toggle(this.layoutValue, true); } } disconnect() { this.debug("disconnect"); if (this.layoutValue) { document.querySelector("#kpop").classList.toggle(this.layoutValue, false); } } debug(event, ...args) { } } /** * Scrim controller wraps an element that creates a whole page layer. * It is intended to be used behind a modal or nav drawer. * * If the Scrim element receives a click event, it automatically triggers "scrim:hide". * * You can show and hide the scrim programmatically by calling show/hide on the controller, e.g. using an outlet. * * If you need to respond to the scrim showing or hiding you should subscribe to "scrim:show" and "scrim:hide". */ class ScrimController extends Controller { static values = { open: Boolean, captive: Boolean, zIndex: Number, }; connect() { this.defaultZIndexValue = this.zIndexValue; this.defaultCaptiveValue = this.captiveValue; this.element.scrim = this; } disconnect() { delete this.element.scrim; } async show({ captive = this.defaultCaptiveValue, zIndex = this.defaultZIndexValue, top = window.scrollY, animate = true, } = {}) { // hide the scrim before opening the new one if it's already open if (this.openValue) { await this.hide({ animate }); } // update internal state this.openValue = true; // notify listeners of pending request this.dispatch("show", { bubbles: true }); // update state, perform style updates this.#show(captive, zIndex, top); if (animate) { // animate opening // this will trigger an animationEnd event via CSS that completes the open this.element.dataset.showAnimating = ""; await new Promise((resolve) => { this.element.addEventListener("animationend", () => resolve(), { once: true, }); }); delete this.element.dataset.showAnimating; } } async hide({ animate = true } = {}) { if (!this.openValue || this.element.dataset.hideAnimating) return; // notify listeners of pending request this.dispatch("hide", { bubbles: true }); if (animate) { // set animation state // this will trigger an animationEnd event via CSS that completes the hide this.element.dataset.hideAnimating = ""; await new Promise((resolve) => { this.element.addEventListener("animationend", () => resolve(), { once: true, }); }); delete this.element.dataset.hideAnimating; } this.#hide(); this.openValue = false; } dismiss(event) { if (!this.captiveValue) this.dispatch("dismiss", { bubbles: true }); } escape(event) { if ( event.key === "Escape" && !this.captiveValue && !event.defaultPrevented ) { this.dispatch("dismiss", { bubbles: true }); } } /** * Clips body to viewport size and sets the z-index */ #show(captive, zIndex, top) { this.captiveValue = captive; this.zIndexValue = zIndex; this.scrollY = top; this.previousPosition = document.body.style.position; this.previousTop = document.body.style.top; this.element.style.zIndex = this.zIndexValue; document.body.style.top = `-${top}px`; document.body.style.position = "fixed"; } /** * Unclips body from viewport size and unsets the z-index */ #hide() { this.captiveValue = this.defaultCaptiveValue; this.zIndexValue = this.defaultZIndexValue; resetStyle(this.element, "z-index", null); resetStyle(document.body, "position", null); resetStyle(document.body, "top", null); window.scrollTo({ left: 0, top: this.scrollY, behavior: "instant" }); delete this.scrollY; delete this.previousPosition; delete this.previousTop; } } function resetStyle(element, property, previousValue) { if (previousValue) { element.style.setProperty(property, previousValue); } else { element.style.removeProperty(property); } } class StreamModal extends Modal { constructor(id, action) { super(id); this.action = action; } /** * When the modal opens, push a state event for the current location so that * the user can dismiss the modal by navigating back. * * @returns {Promise} */ async open() { await super.open(); window.history.pushState({ kpop: true, id: this.id }, "", window.location); } /** * On dismiss, pop the state event that was pushed when the modal opened, * then clear any modals from the turbo frame element. * * @returns {Promise} */ async dismiss() { await super.dismiss(); if (this.isCurrentLocation) { await this.pop("popstate", () => window.history.back()); } this.frameElement.innerHTML = ""; } /** * On navigation from inside the modal, dismiss the modal first so that the * modal does not appear in the history stack. * * @param frame TurboFrame element * @param e Turbo navigation event */ beforeVisit(frame, e) { super.beforeVisit(frame, e); e.preventDefault(); frame.dismiss({ animate: false }).then(() => { Turbo.visit(e.detail.url); this.debug("before-visit-end"); }); } /** * If the user pops state, dismiss the modal. * * @param frame FrameController * @param e history event */ popstate(frame, e) { super.popstate(frame, e); frame.dismiss({ animate: true, reason: "popstate" }); } get isCurrentLocation() { return window.history.state?.kpop && window.history.state?.id === this.id; } } class StreamRenderer { constructor(frame, action) { this.frame = frame; this.action = action; } render() { this.frame.src = ""; this.frame.innerHTML = ""; this.frame.append(this.action.templateContent); } } function kpop(action) { return action.targetElements[0]?.kpop; } Turbo.StreamActions.kpop_open = function () { const animate = !kpop(this).openValue; kpop(this) ?.dismiss({ animate, reason: "before-turbo-stream" }) .then(() => { new StreamRenderer(this.targetElements[0], this).render(); kpop(this)?.open(new StreamModal(this.target, this), { animate }); }); }; Turbo.StreamActions.kpop_dismiss = function () { kpop(this)?.dismiss({ reason: "turbo_stream.kpop.dismiss" }); }; Turbo.StreamActions.kpop_redirect_to = function () { if (this.dataset.turboFrame === this.target) { const a = document.createElement("A"); a.setAttribute("data-turbo-action", "replace"); this.targetElements[0].delegate.navigateFrame(a, this.getAttribute("href")); } else { Turbo.visit(this.getAttribute("href"), { action: this.dataset.turboAction, }); } }; const Definitions = [ { identifier: "kpop--frame", controllerConstructor: Kpop__FrameController }, { identifier: "kpop--modal", controllerConstructor: Kpop__ModalController }, { identifier: "scrim", controllerConstructor: ScrimController }, ]; export { Definitions as default };