UNPKG

@hotwired/turbo

Version:

The speed of a single-page web application without having to write any JavaScript

1,821 lines (1,490 loc) 214 kB
/*! Turbo 8.0.13 Copyright © 2025 37signals LLC */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Turbo = {})); })(this, (function (exports) { 'use strict'; /** * The MIT License (MIT) * * Copyright (c) 2019 Javan Makhmali * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ (function (prototype) { if (typeof prototype.requestSubmit == "function") return prototype.requestSubmit = function (submitter) { if (submitter) { validateSubmitter(submitter, this); submitter.click(); } else { submitter = document.createElement("input"); submitter.type = "submit"; submitter.hidden = true; this.appendChild(submitter); submitter.click(); this.removeChild(submitter); } }; function validateSubmitter(submitter, form) { submitter instanceof HTMLElement || raise(TypeError, "parameter 1 is not of type 'HTMLElement'"); submitter.type == "submit" || raise(TypeError, "The specified element is not a submit button"); submitter.form == form || raise(DOMException, "The specified element is not owned by this form element", "NotFoundError"); } function raise(errorConstructor, message, name) { throw new errorConstructor("Failed to execute 'requestSubmit' on 'HTMLFormElement': " + message + ".", name) } })(HTMLFormElement.prototype); const submittersByForm = new WeakMap(); function findSubmitterFromClickTarget(target) { const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null; const candidate = element ? element.closest("input, button") : null; return candidate?.type == "submit" ? candidate : null } function clickCaptured(event) { const submitter = findSubmitterFromClickTarget(event.target); if (submitter && submitter.form) { submittersByForm.set(submitter.form, submitter); } } (function () { if ("submitter" in Event.prototype) return let prototype = window.Event.prototype; // Certain versions of Safari 15 have a bug where they won't // populate the submitter. This hurts TurboDrive's enable/disable detection. // See https://bugs.webkit.org/show_bug.cgi?id=229660 if ("SubmitEvent" in window) { const prototypeOfSubmitEvent = window.SubmitEvent.prototype; if (/Apple Computer/.test(navigator.vendor) && !("submitter" in prototypeOfSubmitEvent)) { prototype = prototypeOfSubmitEvent; } else { return // polyfill not needed } } addEventListener("click", clickCaptured, true); Object.defineProperty(prototype, "submitter", { get() { if (this.type == "submit" && this.target instanceof HTMLFormElement) { return submittersByForm.get(this.target) } } }); })(); const FrameLoadingStyle = { eager: "eager", lazy: "lazy" }; /** * Contains a fragment of HTML which is updated based on navigation within * it (e.g. via links or form submissions). * * @customElement turbo-frame * @example * <turbo-frame id="messages"> * <a href="/messages/expanded"> * Show all expanded messages in this frame. * </a> * * <form action="/messages"> * Show response from this form within this frame. * </form> * </turbo-frame> */ class FrameElement extends HTMLElement { static delegateConstructor = undefined loaded = Promise.resolve() static get observedAttributes() { return ["disabled", "loading", "src"] } constructor() { super(); this.delegate = new FrameElement.delegateConstructor(this); } connectedCallback() { this.delegate.connect(); } disconnectedCallback() { this.delegate.disconnect(); } reload() { return this.delegate.sourceURLReloaded() } attributeChangedCallback(name) { if (name == "loading") { this.delegate.loadingStyleChanged(); } else if (name == "src") { this.delegate.sourceURLChanged(); } else if (name == "disabled") { this.delegate.disabledChanged(); } } /** * Gets the URL to lazily load source HTML from */ get src() { return this.getAttribute("src") } /** * Sets the URL to lazily load source HTML from */ set src(value) { if (value) { this.setAttribute("src", value); } else { this.removeAttribute("src"); } } /** * Gets the refresh mode for the frame. */ get refresh() { return this.getAttribute("refresh") } /** * Sets the refresh mode for the frame. */ set refresh(value) { if (value) { this.setAttribute("refresh", value); } else { this.removeAttribute("refresh"); } } get shouldReloadWithMorph() { return this.src && this.refresh === "morph" } /** * Determines if the element is loading */ get loading() { return frameLoadingStyleFromString(this.getAttribute("loading") || "") } /** * Sets the value of if the element is loading */ set loading(value) { if (value) { this.setAttribute("loading", value); } else { this.removeAttribute("loading"); } } /** * Gets the disabled state of the frame. * * If disabled, no requests will be intercepted by the frame. */ get disabled() { return this.hasAttribute("disabled") } /** * Sets the disabled state of the frame. * * If disabled, no requests will be intercepted by the frame. */ set disabled(value) { if (value) { this.setAttribute("disabled", ""); } else { this.removeAttribute("disabled"); } } /** * Gets the autoscroll state of the frame. * * If true, the frame will be scrolled into view automatically on update. */ get autoscroll() { return this.hasAttribute("autoscroll") } /** * Sets the autoscroll state of the frame. * * If true, the frame will be scrolled into view automatically on update. */ set autoscroll(value) { if (value) { this.setAttribute("autoscroll", ""); } else { this.removeAttribute("autoscroll"); } } /** * Determines if the element has finished loading */ get complete() { return !this.delegate.isLoading } /** * Gets the active state of the frame. * * If inactive, source changes will not be observed. */ get isActive() { return this.ownerDocument === document && !this.isPreview } /** * Sets the active state of the frame. * * If inactive, source changes will not be observed. */ get isPreview() { return this.ownerDocument?.documentElement?.hasAttribute("data-turbo-preview") } } function frameLoadingStyleFromString(style) { switch (style.toLowerCase()) { case "lazy": return FrameLoadingStyle.lazy default: return FrameLoadingStyle.eager } } const drive = { enabled: true, progressBarDelay: 500, unvisitableExtensions: new Set( [ ".7z", ".aac", ".apk", ".avi", ".bmp", ".bz2", ".css", ".csv", ".deb", ".dmg", ".doc", ".docx", ".exe", ".gif", ".gz", ".heic", ".heif", ".ico", ".iso", ".jpeg", ".jpg", ".js", ".json", ".m4a", ".mkv", ".mov", ".mp3", ".mp4", ".mpeg", ".mpg", ".msi", ".ogg", ".ogv", ".pdf", ".pkg", ".png", ".ppt", ".pptx", ".rar", ".rtf", ".svg", ".tar", ".tif", ".tiff", ".txt", ".wav", ".webm", ".webp", ".wma", ".wmv", ".xls", ".xlsx", ".xml", ".zip" ] ) }; function activateScriptElement(element) { if (element.getAttribute("data-turbo-eval") == "false") { return element } else { const createdScriptElement = document.createElement("script"); const cspNonce = getCspNonce(); if (cspNonce) { createdScriptElement.nonce = cspNonce; } createdScriptElement.textContent = element.textContent; createdScriptElement.async = false; copyElementAttributes(createdScriptElement, element); return createdScriptElement } } function copyElementAttributes(destinationElement, sourceElement) { for (const { name, value } of sourceElement.attributes) { destinationElement.setAttribute(name, value); } } function createDocumentFragment(html) { const template = document.createElement("template"); template.innerHTML = html; return template.content } function dispatch(eventName, { target, cancelable, detail } = {}) { const event = new CustomEvent(eventName, { cancelable, bubbles: true, composed: true, detail }); if (target && target.isConnected) { target.dispatchEvent(event); } else { document.documentElement.dispatchEvent(event); } return event } function cancelEvent(event) { event.preventDefault(); event.stopImmediatePropagation(); } function nextRepaint() { if (document.visibilityState === "hidden") { return nextEventLoopTick() } else { return nextAnimationFrame() } } function nextAnimationFrame() { return new Promise((resolve) => requestAnimationFrame(() => resolve())) } function nextEventLoopTick() { return new Promise((resolve) => setTimeout(() => resolve(), 0)) } function nextMicrotask() { return Promise.resolve() } function parseHTMLDocument(html = "") { return new DOMParser().parseFromString(html, "text/html") } function unindent(strings, ...values) { const lines = interpolate(strings, values).replace(/^\n/, "").split("\n"); const match = lines[0].match(/^\s+/); const indent = match ? match[0].length : 0; return lines.map((line) => line.slice(indent)).join("\n") } function interpolate(strings, values) { return strings.reduce((result, string, i) => { const value = values[i] == undefined ? "" : values[i]; return result + string + value }, "") } function uuid() { return Array.from({ length: 36 }) .map((_, i) => { if (i == 8 || i == 13 || i == 18 || i == 23) { return "-" } else if (i == 14) { return "4" } else if (i == 19) { return (Math.floor(Math.random() * 4) + 8).toString(16) } else { return Math.floor(Math.random() * 15).toString(16) } }) .join("") } function getAttribute(attributeName, ...elements) { for (const value of elements.map((element) => element?.getAttribute(attributeName))) { if (typeof value == "string") return value } return null } function hasAttribute(attributeName, ...elements) { return elements.some((element) => element && element.hasAttribute(attributeName)) } function markAsBusy(...elements) { for (const element of elements) { if (element.localName == "turbo-frame") { element.setAttribute("busy", ""); } element.setAttribute("aria-busy", "true"); } } function clearBusyState(...elements) { for (const element of elements) { if (element.localName == "turbo-frame") { element.removeAttribute("busy"); } element.removeAttribute("aria-busy"); } } function waitForLoad(element, timeoutInMilliseconds = 2000) { return new Promise((resolve) => { const onComplete = () => { element.removeEventListener("error", onComplete); element.removeEventListener("load", onComplete); resolve(); }; element.addEventListener("load", onComplete, { once: true }); element.addEventListener("error", onComplete, { once: true }); setTimeout(resolve, timeoutInMilliseconds); }) } function getHistoryMethodForAction(action) { switch (action) { case "replace": return history.replaceState case "advance": case "restore": return history.pushState } } function isAction(action) { return action == "advance" || action == "replace" || action == "restore" } function getVisitAction(...elements) { const action = getAttribute("data-turbo-action", ...elements); return isAction(action) ? action : null } function getMetaElement(name) { return document.querySelector(`meta[name="${name}"]`) } function getMetaContent(name) { const element = getMetaElement(name); return element && element.content } function getCspNonce() { const element = getMetaElement("csp-nonce"); if (element) { const { nonce, content } = element; return nonce == "" ? content : nonce } } function setMetaContent(name, content) { let element = getMetaElement(name); if (!element) { element = document.createElement("meta"); element.setAttribute("name", name); document.head.appendChild(element); } element.setAttribute("content", content); return element } function findClosestRecursively(element, selector) { if (element instanceof Element) { return ( element.closest(selector) || findClosestRecursively(element.assignedSlot || element.getRootNode()?.host, selector) ) } } function elementIsFocusable(element) { const inertDisabledOrHidden = "[inert], :disabled, [hidden], details:not([open]), dialog:not([open])"; return !!element && element.closest(inertDisabledOrHidden) == null && typeof element.focus == "function" } function queryAutofocusableElement(elementOrDocumentFragment) { return Array.from(elementOrDocumentFragment.querySelectorAll("[autofocus]")).find(elementIsFocusable) } async function around(callback, reader) { const before = reader(); callback(); await nextAnimationFrame(); const after = reader(); return [before, after] } function doesNotTargetIFrame(name) { if (name === "_blank") { return false } else if (name) { for (const element of document.getElementsByName(name)) { if (element instanceof HTMLIFrameElement) return false } return true } else { return true } } function findLinkFromClickTarget(target) { return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])") } function getLocationForLink(link) { return expandURL(link.getAttribute("href") || "") } function debounce(fn, delay) { let timeoutId = null; return (...args) => { const callback = () => fn.apply(this, args); clearTimeout(timeoutId); timeoutId = setTimeout(callback, delay); } } const submitter = { "aria-disabled": { beforeSubmit: submitter => { submitter.setAttribute("aria-disabled", "true"); submitter.addEventListener("click", cancelEvent); }, afterSubmit: submitter => { submitter.removeAttribute("aria-disabled"); submitter.removeEventListener("click", cancelEvent); } }, "disabled": { beforeSubmit: submitter => submitter.disabled = true, afterSubmit: submitter => submitter.disabled = false } }; class Config { #submitter = null constructor(config) { Object.assign(this, config); } get submitter() { return this.#submitter } set submitter(value) { this.#submitter = submitter[value] || value; } } const forms = new Config({ mode: "on", submitter: "disabled" }); const config = { drive, forms }; function expandURL(locatable) { return new URL(locatable.toString(), document.baseURI) } function getAnchor(url) { let anchorMatch; if (url.hash) { return url.hash.slice(1) // eslint-disable-next-line no-cond-assign } else if ((anchorMatch = url.href.match(/#(.*)$/))) { return anchorMatch[1] } } function getAction$1(form, submitter) { const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action; return expandURL(action) } function getExtension(url) { return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || "" } function isPrefixedBy(baseURL, url) { const prefix = getPrefix(url); return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix) } function locationIsVisitable(location, rootLocation) { return isPrefixedBy(location, rootLocation) && !config.drive.unvisitableExtensions.has(getExtension(location)) } function getRequestURL(url) { const anchor = getAnchor(url); return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href } function toCacheKey(url) { return getRequestURL(url) } function urlsAreEqual(left, right) { return expandURL(left).href == expandURL(right).href } function getPathComponents(url) { return url.pathname.split("/").slice(1) } function getLastPathComponent(url) { return getPathComponents(url).slice(-1)[0] } function getPrefix(url) { return addTrailingSlash(url.origin + url.pathname) } function addTrailingSlash(value) { return value.endsWith("/") ? value : value + "/" } class FetchResponse { constructor(response) { this.response = response; } get succeeded() { return this.response.ok } get failed() { return !this.succeeded } get clientError() { return this.statusCode >= 400 && this.statusCode <= 499 } get serverError() { return this.statusCode >= 500 && this.statusCode <= 599 } get redirected() { return this.response.redirected } get location() { return expandURL(this.response.url) } get isHTML() { return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/) } get statusCode() { return this.response.status } get contentType() { return this.header("Content-Type") } get responseText() { return this.response.clone().text() } get responseHTML() { if (this.isHTML) { return this.response.clone().text() } else { return Promise.resolve(undefined) } } header(name) { return this.response.headers.get(name) } } class LimitedSet extends Set { constructor(maxSize) { super(); this.maxSize = maxSize; } add(value) { if (this.size >= this.maxSize) { const iterator = this.values(); const oldestValue = iterator.next().value; this.delete(oldestValue); } super.add(value); } } const recentRequests = new LimitedSet(20); const nativeFetch = window.fetch; function fetchWithTurboHeaders(url, options = {}) { const modifiedHeaders = new Headers(options.headers || {}); const requestUID = uuid(); recentRequests.add(requestUID); modifiedHeaders.append("X-Turbo-Request-Id", requestUID); return nativeFetch(url, { ...options, headers: modifiedHeaders }) } function fetchMethodFromString(method) { switch (method.toLowerCase()) { case "get": return FetchMethod.get case "post": return FetchMethod.post case "put": return FetchMethod.put case "patch": return FetchMethod.patch case "delete": return FetchMethod.delete } } const FetchMethod = { get: "get", post: "post", put: "put", patch: "patch", delete: "delete" }; function fetchEnctypeFromString(encoding) { switch (encoding.toLowerCase()) { case FetchEnctype.multipart: return FetchEnctype.multipart case FetchEnctype.plain: return FetchEnctype.plain default: return FetchEnctype.urlEncoded } } const FetchEnctype = { urlEncoded: "application/x-www-form-urlencoded", multipart: "multipart/form-data", plain: "text/plain" }; class FetchRequest { abortController = new AbortController() #resolveRequestPromise = (_value) => {} constructor(delegate, method, location, requestBody = new URLSearchParams(), target = null, enctype = FetchEnctype.urlEncoded) { const [url, body] = buildResourceAndBody(expandURL(location), method, requestBody, enctype); this.delegate = delegate; this.url = url; this.target = target; this.fetchOptions = { credentials: "same-origin", redirect: "follow", method: method.toUpperCase(), headers: { ...this.defaultHeaders }, body: body, signal: this.abortSignal, referrer: this.delegate.referrer?.href }; this.enctype = enctype; } get method() { return this.fetchOptions.method } set method(value) { const fetchBody = this.isSafe ? this.url.searchParams : this.fetchOptions.body || new FormData(); const fetchMethod = fetchMethodFromString(value) || FetchMethod.get; this.url.search = ""; const [url, body] = buildResourceAndBody(this.url, fetchMethod, fetchBody, this.enctype); this.url = url; this.fetchOptions.body = body; this.fetchOptions.method = fetchMethod.toUpperCase(); } get headers() { return this.fetchOptions.headers } set headers(value) { this.fetchOptions.headers = value; } get body() { if (this.isSafe) { return this.url.searchParams } else { return this.fetchOptions.body } } set body(value) { this.fetchOptions.body = value; } get location() { return this.url } get params() { return this.url.searchParams } get entries() { return this.body ? Array.from(this.body.entries()) : [] } cancel() { this.abortController.abort(); } async perform() { const { fetchOptions } = this; this.delegate.prepareRequest(this); const event = await this.#allowRequestToBeIntercepted(fetchOptions); try { this.delegate.requestStarted(this); if (event.detail.fetchRequest) { this.response = event.detail.fetchRequest.response; } else { this.response = fetchWithTurboHeaders(this.url.href, fetchOptions); } const response = await this.response; return await this.receive(response) } catch (error) { if (error.name !== "AbortError") { if (this.#willDelegateErrorHandling(error)) { this.delegate.requestErrored(this, error); } throw error } } finally { this.delegate.requestFinished(this); } } async receive(response) { const fetchResponse = new FetchResponse(response); const event = dispatch("turbo:before-fetch-response", { cancelable: true, detail: { fetchResponse }, target: this.target }); if (event.defaultPrevented) { this.delegate.requestPreventedHandlingResponse(this, fetchResponse); } else if (fetchResponse.succeeded) { this.delegate.requestSucceededWithResponse(this, fetchResponse); } else { this.delegate.requestFailedWithResponse(this, fetchResponse); } return fetchResponse } get defaultHeaders() { return { Accept: "text/html, application/xhtml+xml" } } get isSafe() { return isSafe(this.method) } get abortSignal() { return this.abortController.signal } acceptResponseType(mimeType) { this.headers["Accept"] = [mimeType, this.headers["Accept"]].join(", "); } async #allowRequestToBeIntercepted(fetchOptions) { const requestInterception = new Promise((resolve) => (this.#resolveRequestPromise = resolve)); const event = dispatch("turbo:before-fetch-request", { cancelable: true, detail: { fetchOptions, url: this.url, resume: this.#resolveRequestPromise }, target: this.target }); this.url = event.detail.url; if (event.defaultPrevented) await requestInterception; return event } #willDelegateErrorHandling(error) { const event = dispatch("turbo:fetch-request-error", { target: this.target, cancelable: true, detail: { request: this, error: error } }); return !event.defaultPrevented } } function isSafe(fetchMethod) { return fetchMethodFromString(fetchMethod) == FetchMethod.get } function buildResourceAndBody(resource, method, requestBody, enctype) { const searchParams = Array.from(requestBody).length > 0 ? new URLSearchParams(entriesExcludingFiles(requestBody)) : resource.searchParams; if (isSafe(method)) { return [mergeIntoURLSearchParams(resource, searchParams), null] } else if (enctype == FetchEnctype.urlEncoded) { return [resource, searchParams] } else { return [resource, requestBody] } } function entriesExcludingFiles(requestBody) { const entries = []; for (const [name, value] of requestBody) { if (value instanceof File) continue else entries.push([name, value]); } return entries } function mergeIntoURLSearchParams(url, requestBody) { const searchParams = new URLSearchParams(entriesExcludingFiles(requestBody)); url.search = searchParams.toString(); return url } class AppearanceObserver { started = false constructor(delegate, element) { this.delegate = delegate; this.element = element; this.intersectionObserver = new IntersectionObserver(this.intersect); } start() { if (!this.started) { this.started = true; this.intersectionObserver.observe(this.element); } } stop() { if (this.started) { this.started = false; this.intersectionObserver.unobserve(this.element); } } intersect = (entries) => { const lastEntry = entries.slice(-1)[0]; if (lastEntry?.isIntersecting) { this.delegate.elementAppearedInViewport(this.element); } } } class StreamMessage { static contentType = "text/vnd.turbo-stream.html" static wrap(message) { if (typeof message == "string") { return new this(createDocumentFragment(message)) } else { return message } } constructor(fragment) { this.fragment = importStreamElements(fragment); } } function importStreamElements(fragment) { for (const element of fragment.querySelectorAll("turbo-stream")) { const streamElement = document.importNode(element, true); for (const inertScriptElement of streamElement.templateElement.content.querySelectorAll("script")) { inertScriptElement.replaceWith(activateScriptElement(inertScriptElement)); } element.replaceWith(streamElement); } return fragment } const PREFETCH_DELAY = 100; class PrefetchCache { #prefetchTimeout = null #prefetched = null get(url) { if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) { return this.#prefetched.request } } setLater(url, request, ttl) { this.clear(); this.#prefetchTimeout = setTimeout(() => { request.perform(); this.set(url, request, ttl); this.#prefetchTimeout = null; }, PREFETCH_DELAY); } set(url, request, ttl) { this.#prefetched = { url, request, expire: new Date(new Date().getTime() + ttl) }; } clear() { if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout); this.#prefetched = null; } } const cacheTtl = 10 * 1000; const prefetchCache = new PrefetchCache(); const FormSubmissionState = { initialized: "initialized", requesting: "requesting", waiting: "waiting", receiving: "receiving", stopping: "stopping", stopped: "stopped" }; class FormSubmission { state = FormSubmissionState.initialized static confirmMethod(message) { return Promise.resolve(confirm(message)) } constructor(delegate, formElement, submitter, mustRedirect = false) { const method = getMethod(formElement, submitter); const action = getAction(getFormAction(formElement, submitter), method); const body = buildFormData(formElement, submitter); const enctype = getEnctype(formElement, submitter); this.delegate = delegate; this.formElement = formElement; this.submitter = submitter; this.fetchRequest = new FetchRequest(this, method, action, body, formElement, enctype); this.mustRedirect = mustRedirect; } get method() { return this.fetchRequest.method } set method(value) { this.fetchRequest.method = value; } get action() { return this.fetchRequest.url.toString() } set action(value) { this.fetchRequest.url = expandURL(value); } get body() { return this.fetchRequest.body } get enctype() { return this.fetchRequest.enctype } get isSafe() { return this.fetchRequest.isSafe } get location() { return this.fetchRequest.url } // The submission process async start() { const { initialized, requesting } = FormSubmissionState; const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement); if (typeof confirmationMessage === "string") { const confirmMethod = typeof config.forms.confirm === "function" ? config.forms.confirm : FormSubmission.confirmMethod; const answer = await confirmMethod(confirmationMessage, this.formElement, this.submitter); if (!answer) { return } } if (this.state == initialized) { this.state = requesting; return this.fetchRequest.perform() } } stop() { const { stopping, stopped } = FormSubmissionState; if (this.state != stopping && this.state != stopped) { this.state = stopping; this.fetchRequest.cancel(); return true } } // Fetch request delegate prepareRequest(request) { if (!request.isSafe) { const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token"); if (token) { request.headers["X-CSRF-Token"] = token; } } if (this.requestAcceptsTurboStreamResponse(request)) { request.acceptResponseType(StreamMessage.contentType); } } requestStarted(_request) { this.state = FormSubmissionState.waiting; if (this.submitter) config.forms.submitter.beforeSubmit(this.submitter); this.setSubmitsWith(); markAsBusy(this.formElement); dispatch("turbo:submit-start", { target: this.formElement, detail: { formSubmission: this } }); this.delegate.formSubmissionStarted(this); } requestPreventedHandlingResponse(request, response) { prefetchCache.clear(); this.result = { success: response.succeeded, fetchResponse: response }; } requestSucceededWithResponse(request, response) { if (response.clientError || response.serverError) { this.delegate.formSubmissionFailedWithResponse(this, response); return } prefetchCache.clear(); if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) { const error = new Error("Form responses must redirect to another location"); this.delegate.formSubmissionErrored(this, error); } else { this.state = FormSubmissionState.receiving; this.result = { success: true, fetchResponse: response }; this.delegate.formSubmissionSucceededWithResponse(this, response); } } requestFailedWithResponse(request, response) { this.result = { success: false, fetchResponse: response }; this.delegate.formSubmissionFailedWithResponse(this, response); } requestErrored(request, error) { this.result = { success: false, error }; this.delegate.formSubmissionErrored(this, error); } requestFinished(_request) { this.state = FormSubmissionState.stopped; if (this.submitter) config.forms.submitter.afterSubmit(this.submitter); this.resetSubmitterText(); clearBusyState(this.formElement); dispatch("turbo:submit-end", { target: this.formElement, detail: { formSubmission: this, ...this.result } }); this.delegate.formSubmissionFinished(this); } // Private setSubmitsWith() { if (!this.submitter || !this.submitsWith) return if (this.submitter.matches("button")) { this.originalSubmitText = this.submitter.innerHTML; this.submitter.innerHTML = this.submitsWith; } else if (this.submitter.matches("input")) { const input = this.submitter; this.originalSubmitText = input.value; input.value = this.submitsWith; } } resetSubmitterText() { if (!this.submitter || !this.originalSubmitText) return if (this.submitter.matches("button")) { this.submitter.innerHTML = this.originalSubmitText; } else if (this.submitter.matches("input")) { const input = this.submitter; input.value = this.originalSubmitText; } } requestMustRedirect(request) { return !request.isSafe && this.mustRedirect } requestAcceptsTurboStreamResponse(request) { return !request.isSafe || hasAttribute("data-turbo-stream", this.submitter, this.formElement) } get submitsWith() { return this.submitter?.getAttribute("data-turbo-submits-with") } } function buildFormData(formElement, submitter) { const formData = new FormData(formElement); const name = submitter?.getAttribute("name"); const value = submitter?.getAttribute("value"); if (name) { formData.append(name, value || ""); } return formData } function getCookieValue(cookieName) { if (cookieName != null) { const cookies = document.cookie ? document.cookie.split("; ") : []; const cookie = cookies.find((cookie) => cookie.startsWith(cookieName)); if (cookie) { const value = cookie.split("=").slice(1).join("="); return value ? decodeURIComponent(value) : undefined } } } function responseSucceededWithoutRedirect(response) { return response.statusCode == 200 && !response.redirected } function getFormAction(formElement, submitter) { const formElementAction = typeof formElement.action === "string" ? formElement.action : null; if (submitter?.hasAttribute("formaction")) { return submitter.getAttribute("formaction") || "" } else { return formElement.getAttribute("action") || formElementAction || "" } } function getAction(formAction, fetchMethod) { const action = expandURL(formAction); if (isSafe(fetchMethod)) { action.search = ""; } return action } function getMethod(formElement, submitter) { const method = submitter?.getAttribute("formmethod") || formElement.getAttribute("method") || ""; return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get } function getEnctype(formElement, submitter) { return fetchEnctypeFromString(submitter?.getAttribute("formenctype") || formElement.enctype) } class Snapshot { constructor(element) { this.element = element; } get activeElement() { return this.element.ownerDocument.activeElement } get children() { return [...this.element.children] } hasAnchor(anchor) { return this.getElementForAnchor(anchor) != null } getElementForAnchor(anchor) { return anchor ? this.element.querySelector(`[id='${anchor}'], a[name='${anchor}']`) : null } get isConnected() { return this.element.isConnected } get firstAutofocusableElement() { return queryAutofocusableElement(this.element) } get permanentElements() { return queryPermanentElementsAll(this.element) } getPermanentElementById(id) { return getPermanentElementById(this.element, id) } getPermanentElementMapForSnapshot(snapshot) { const permanentElementMap = {}; for (const currentPermanentElement of this.permanentElements) { const { id } = currentPermanentElement; const newPermanentElement = snapshot.getPermanentElementById(id); if (newPermanentElement) { permanentElementMap[id] = [currentPermanentElement, newPermanentElement]; } } return permanentElementMap } } function getPermanentElementById(node, id) { return node.querySelector(`#${id}[data-turbo-permanent]`) } function queryPermanentElementsAll(node) { return node.querySelectorAll("[id][data-turbo-permanent]") } class FormSubmitObserver { started = false constructor(delegate, eventTarget) { this.delegate = delegate; this.eventTarget = eventTarget; } start() { if (!this.started) { this.eventTarget.addEventListener("submit", this.submitCaptured, true); this.started = true; } } stop() { if (this.started) { this.eventTarget.removeEventListener("submit", this.submitCaptured, true); this.started = false; } } submitCaptured = () => { this.eventTarget.removeEventListener("submit", this.submitBubbled, false); this.eventTarget.addEventListener("submit", this.submitBubbled, false); } submitBubbled = (event) => { if (!event.defaultPrevented) { const form = event.target instanceof HTMLFormElement ? event.target : undefined; const submitter = event.submitter || undefined; if ( form && submissionDoesNotDismissDialog(form, submitter) && submissionDoesNotTargetIFrame(form, submitter) && this.delegate.willSubmitForm(form, submitter) ) { event.preventDefault(); event.stopImmediatePropagation(); this.delegate.formSubmitted(form, submitter); } } } } function submissionDoesNotDismissDialog(form, submitter) { const method = submitter?.getAttribute("formmethod") || form.getAttribute("method"); return method != "dialog" } function submissionDoesNotTargetIFrame(form, submitter) { const target = submitter?.getAttribute("formtarget") || form.getAttribute("target"); return doesNotTargetIFrame(target) } class View { #resolveRenderPromise = (_value) => {} #resolveInterceptionPromise = (_value) => {} constructor(delegate, element) { this.delegate = delegate; this.element = element; } // Scrolling scrollToAnchor(anchor) { const element = this.snapshot.getElementForAnchor(anchor); if (element) { this.scrollToElement(element); this.focusElement(element); } else { this.scrollToPosition({ x: 0, y: 0 }); } } scrollToAnchorFromLocation(location) { this.scrollToAnchor(getAnchor(location)); } scrollToElement(element) { element.scrollIntoView(); } focusElement(element) { if (element instanceof HTMLElement) { if (element.hasAttribute("tabindex")) { element.focus(); } else { element.setAttribute("tabindex", "-1"); element.focus(); element.removeAttribute("tabindex"); } } } scrollToPosition({ x, y }) { this.scrollRoot.scrollTo(x, y); } scrollToTop() { this.scrollToPosition({ x: 0, y: 0 }); } get scrollRoot() { return window } // Rendering async render(renderer) { const { isPreview, shouldRender, willRender, newSnapshot: snapshot } = renderer; // A workaround to ignore tracked element mismatch reloads when performing // a promoted Visit from a frame navigation const shouldInvalidate = willRender; if (shouldRender) { try { this.renderPromise = new Promise((resolve) => (this.#resolveRenderPromise = resolve)); this.renderer = renderer; await this.prepareToRenderSnapshot(renderer); const renderInterception = new Promise((resolve) => (this.#resolveInterceptionPromise = resolve)); const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement, renderMethod: this.renderer.renderMethod }; const immediateRender = this.delegate.allowsImmediateRender(snapshot, options); if (!immediateRender) await renderInterception; await this.renderSnapshot(renderer); this.delegate.viewRenderedSnapshot(snapshot, isPreview, this.renderer.renderMethod); this.delegate.preloadOnLoadLinksForView(this.element); this.finishRenderingSnapshot(renderer); } finally { delete this.renderer; this.#resolveRenderPromise(undefined); delete this.renderPromise; } } else if (shouldInvalidate) { this.invalidate(renderer.reloadReason); } } invalidate(reason) { this.delegate.viewInvalidated(reason); } async prepareToRenderSnapshot(renderer) { this.markAsPreview(renderer.isPreview); await renderer.prepareToRender(); } markAsPreview(isPreview) { if (isPreview) { this.element.setAttribute("data-turbo-preview", ""); } else { this.element.removeAttribute("data-turbo-preview"); } } markVisitDirection(direction) { this.element.setAttribute("data-turbo-visit-direction", direction); } unmarkVisitDirection() { this.element.removeAttribute("data-turbo-visit-direction"); } async renderSnapshot(renderer) { await renderer.render(); } finishRenderingSnapshot(renderer) { renderer.finishRendering(); } } class FrameView extends View { missing() { this.element.innerHTML = `<strong class="turbo-frame-error">Content missing</strong>`; } get snapshot() { return new Snapshot(this.element) } } class LinkInterceptor { constructor(delegate, element) { this.delegate = delegate; this.element = element; } start() { this.element.addEventListener("click", this.clickBubbled); document.addEventListener("turbo:click", this.linkClicked); document.addEventListener("turbo:before-visit", this.willVisit); } stop() { this.element.removeEventListener("click", this.clickBubbled); document.removeEventListener("turbo:click", this.linkClicked); document.removeEventListener("turbo:before-visit", this.willVisit); } clickBubbled = (event) => { if (this.clickEventIsSignificant(event)) { this.clickEvent = event; } else { delete this.clickEvent; } } linkClicked = (event) => { if (this.clickEvent && this.clickEventIsSignificant(event)) { if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) { this.clickEvent.preventDefault(); event.preventDefault(); this.delegate.linkClickIntercepted(event.target, event.detail.url, event.detail.originalEvent); } } delete this.clickEvent; } willVisit = (_event) => { delete this.clickEvent; } clickEventIsSignificant(event) { const target = event.composed ? event.target?.parentElement : event.target; const element = findLinkFromClickTarget(target) || target; return element instanceof Element && element.closest("turbo-frame, html") == this.element } } class LinkClickObserver { started = false constructor(delegate, eventTarget) { this.delegate = delegate; this.eventTarget = eventTarget; } start() { if (!this.started) { this.eventTarget.addEventListener("click", this.clickCaptured, true); this.started = true; } } stop() { if (this.started) { this.eventTarget.removeEventListener("click", this.clickCaptured, true); this.started = false; } } clickCaptured = () => { this.eventTarget.removeEventListener("click", this.clickBubbled, false); this.eventTarget.addEventListener("click", this.clickBubbled, false); } clickBubbled = (event) => { if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) { const target = (event.composedPath && event.composedPath()[0]) || event.target; const link = findLinkFromClickTarget(target); if (link && doesNotTargetIFrame(link.target)) { const location = getLocationForLink(link); if (this.delegate.willFollowLinkToLocation(link, location, event)) { event.preventDefault(); this.delegate.followedLinkToLocation(link, location); } } } } clickEventIsSignificant(event) { return !( (event.target && event.target.isContentEditable) || event.defaultPrevented || event.which > 1 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ) } } class FormLinkClickObserver { constructor(delegate, element) { this.delegate = delegate; this.linkInterceptor = new LinkClickObserver(this, element); } start() { this.linkInterceptor.start(); } stop() { this.linkInterceptor.stop(); } // Link hover observer delegate canPrefetchRequestToLocation(link, location) { return false } prefetchAndCacheRequestToLocation(link, location) { return } // Link click observer delegate willFollowLinkToLocation(link, location, originalEvent) { return ( this.delegate.willSubmitFormLinkToLocation(link, location, originalEvent) && (link.hasAttribute("data-turbo-method") || link.hasAttribute("data-turbo-stream")) ) } followedLinkToLocation(link, location) { const form = document.createElement("form"); const type = "hidden"; for (const [name, value] of location.searchParams) { form.append(Object.assign(document.createElement("input"), { type, name, value })); } const action = Object.assign(location, { search: "" }); form.setAttribute("data-turbo", "true"); form.setAttribute("action", action.href); form.setAttribute("hidden", ""); const method = link.getAttribute("data-turbo-method"); if (method) form.setAttribute("method", method); const turboFrame = link.getAttribute("data-turbo-frame"); if (turboFrame) form.setAttribute("data-turbo-frame", turboFrame); const turboAction = getVisitAction(link); if (turboAction) form.setAttribute("data-turbo-action", turboAction); const turboConfirm = link.getAttribute("data-turbo-confirm"); if (turboConfirm) form.setAttribute("data-turbo-confirm", turboConfirm); const turboStream = link.hasAttribute("data-turbo-stream"); if (turboStream) form.setAttribute("data-turbo-stream", ""); this.delegate.submittedFormLinkToLocation(link, location, form); document.body.appendChild(form); form.addEventListener("turbo:submit-end", () => form.remove(), { once: true }); requestAnimationFrame(() => form.requestSubmit()); } } class Bardo { static async preservingPermanentElements(delegate, permanentElementMap, callback) { const bardo = new this(delegate, permanentElementMap); bardo.enter(); await callback(); bardo.leave(); } constructor(delegate, permanentElementMap) { this.delegate = delegate; this.permanentElementMap = permanentElementMap; } enter() { for (const id in this.permanentElementMap) { const [currentPermanentElement, newPermanentElement] = this.permanentElementMap[id]; this.delegate.enteringBardo(currentPermanentElement, newPermanentElement); this.replaceNewPermanentElementWithPlaceholder(newPermanentElement); } } leave() { for (const id in this.permanentElementMap) { const [currentPermanentElement] = this.permanentElementMap[id]; t