(function () { /** * This adds the "preload" extension to htmx. The extension will * preload the targets of elements with "preload" attribute if: * - they also have `href`, `hx-get` or `data-hx-get` attributes * - they are radio buttons, checkboxes, select elements and submit * buttons of forms with `method="get"` or `hx-get` attributes * The extension relies on browser cache and for it to work * server response must include `Cache-Control` header * e.g. `Cache-Control: private, max-age=60`. * For more details @see https://htmx.org/extensions/preload/ */ htmx.defineExtension("preload", { onEvent: function (name, event) { // Process preload attributes on `htmx:afterProcessNode` if (name === "htmx:afterProcessNode") { // Initialize all nodes with `preload` attribute const parent = event.target || event.detail.elt; const preloadNodes = [ ...(parent.hasAttribute("preload") ? [parent] : []), ...parent.querySelectorAll("[preload]"), ]; preloadNodes.forEach(function (node) { // Initialize the node with the `preload` attribute init(node); // Initialize all child elements which has // `href`, `hx-get` or `data-hx-get` attributes node.querySelectorAll("[href],[hx-get],[data-hx-get]").forEach(init); }); return; } // Intercept HTMX preload requests on `htmx:beforeRequest` and // send them as XHR requests instead to avoid side-effects, // such as showing loading indicators while preloading data. if (name === "htmx:beforeRequest") { const requestHeaders = event.detail.requestConfig.headers; if ( !( "HX-Preloaded" in requestHeaders && requestHeaders["HX-Preloaded"] === "true" ) ) { return; } event.preventDefault(); // Reuse XHR created by HTMX with replaced callbacks const xhr = event.detail.xhr; xhr.onload = function () { processResponse(event.detail.elt, xhr.responseText); }; xhr.onerror = null; xhr.onabort = null; xhr.ontimeout = null; xhr.send(); } }, }); /** * Initialize `node`, set up event handlers based on own or inherited * `preload` attributes and set `node.preloadState` to `READY`. * * `node.preloadState` can have these values: * - `READY` - event handlers have been set up and node is ready to preload * - `TIMEOUT` - a triggering event has been fired, but `node` is not * yet being loaded because some time need to pass first e.g. user * has to keep hovering over an element for 100ms for preload to start * - `LOADING` means that `node` is in the process of being preloaded * - `DONE` means that the preloading process is complete and `node` * doesn't need a repeated preload (indicated by preload="always") * @param {Node} node */ function init(node) { // Guarantee that each node is initialized only once if (node.preloadState !== undefined) { return; } if (!isValidNodeForPreloading(node)) { return; } // Initialize form element preloading if (node instanceof HTMLFormElement) { const form = node; // Only initialize forms with `method="get"` or `hx-get` attributes if ( !( (form.hasAttribute("method") && form.method === "get") || form.hasAttribute("hx-get") || form.hasAttribute("hx-data-get") ) ) { return; } for (let i = 0; i < form.elements.length; i++) { const element = form.elements.item(i); init(element); element.labels.forEach(init); } return; } // Process node configuration from preload attribute let preloadAttr = getClosestAttribute(node, "preload"); node.preloadAlways = preloadAttr && preloadAttr.includes("always"); if (node.preloadAlways) { preloadAttr = preloadAttr.replace("always", "").trim(); } let triggerEventName = preloadAttr || "mousedown"; // Set up event handlers listening for triggering events const needsTimeout = triggerEventName === "mouseover"; node.addEventListener( triggerEventName, getEventHandler(node, needsTimeout), ); // Add `touchstart` listener for touchscreen support // if `mousedown` or `mouseover` is used if (triggerEventName === "mousedown" || triggerEventName === "mouseover") { node.addEventListener("touchstart", getEventHandler(node)); } // If `mouseover` is used, set up `mouseout` listener, // which will abort preloading if user moves mouse outside // the element in less than 100ms after hovering over it if (triggerEventName === "mouseover") { node.addEventListener("mouseout", function (evt) { if (evt.target === node && node.preloadState === "TIMEOUT") { node.preloadState = "READY"; } }); } // Mark the node as ready to be preloaded node.preloadState = "READY"; // This event can be used to load content immediately htmx.trigger(node, "preload:init"); } /** * Return event handler which can be called by event listener to start * the preloading process of `node` with or without a timeout * @param {Node} node * @param {boolean=} needsTimeout * @returns {function(): void} */ function getEventHandler(node, needsTimeout = false) { return function () { // Do not preload uninitialized nodes, nodes which are in process // of being preloaded or have been preloaded and don't need repeat if (node.preloadState !== "READY") { return; } if (needsTimeout) { node.preloadState = "TIMEOUT"; const timeoutMs = 100; window.setTimeout(function () { if (node.preloadState === "TIMEOUT") { node.preloadState = "READY"; load(node); } }, timeoutMs); return; } load(node); }; } /** * Preload the target of node, which can be: * - hx-get or data-hx-get attribute * - href or form action attribute * @param {Node} node */ function load(node) { // Do not preload uninitialized nodes, nodes which are in process // of being preloaded or have been preloaded and don't need repeat if (node.preloadState !== "READY") { return; } node.preloadState = "LOADING"; // Load nodes with `hx-get` or `data-hx-get` attribute // Forms don't reach this because only their elements are initialized const hxGet = node.getAttribute("hx-get") || node.getAttribute("data-hx-get"); if (hxGet) { sendHxGetRequest(hxGet, node); return; } // Load nodes with `href` attribute const hxBoost = getClosestAttribute(node, "hx-boost") === "true"; if (node.hasAttribute("href")) { const url = node.getAttribute("href"); if (hxBoost) { sendHxGetRequest(url, node); } else { sendXmlGetRequest(url, node); } return; } // Load form elements if (isPreloadableFormElement(node)) { const url = node.form.getAttribute("action") || node.form.getAttribute("hx-get") || node.form.getAttribute("data-hx-get"); const formData = htmx.values(node.form); const isStandardForm = !( node.form.getAttribute("hx-get") || node.form.getAttribute("data-hx-get") || hxBoost ); const sendGetRequest = isStandardForm ? sendXmlGetRequest : sendHxGetRequest; // submit button if (node.type === "submit") { sendGetRequest(url, node.form, formData); return; } // select const inputName = node.name || node.control.name; if (node.tagName === "SELECT") { Array.from(node.options).forEach((option) => { if (option.selected) return; formData.set(inputName, option.value); const formDataOrdered = forceFormDataInOrder(node.form, formData); sendGetRequest(url, node.form, formDataOrdered); }); return; } // radio and checkbox const inputType = node.getAttribute("type") || node.control.getAttribute("type"); const nodeValue = node.value || node.control?.value; if (inputType === "radio") { formData.set(inputName, nodeValue); } else if (inputType === "checkbox") { const inputValues = formData.getAll(inputName); if (inputValues.includes(nodeValue)) { formData[inputName] = inputValues.filter( (value) => value !== nodeValue, ); } else { formData.append(inputName, nodeValue); } } const formDataOrdered = forceFormDataInOrder(node.form, formData); sendGetRequest(url, node.form, formDataOrdered); return; } } /** * Force formData values to be in the order of form elements. * This is useful to apply after alternating formData values * and before passing them to a HTTP request because cache is * sensitive to GET parameter order e.g., cached `/link?a=1&b=2` * will not be used for `/link?b=2&a=1`. * @param {HTMLFormElement} form * @param {FormData} formData * @returns {FormData} */ function forceFormDataInOrder(form, formData) { const formElements = form.elements; const orderedFormData = new FormData(); for (let i = 0; i < formElements.length; i++) { const element = formElements.item(i); if (formData.has(element.name) && element.tagName === "SELECT") { orderedFormData.append(element.name, formData.get(element.name)); continue; } if ( formData.has(element.name) && formData.getAll(element.name).includes(element.value) ) { orderedFormData.append(element.name, element.value); } } return orderedFormData; } /** * Send GET request with `hx-request` headers as if `sourceNode` * target was loaded. Send alternated values if `formData` is set. * * Note that this request is intercepted and sent as XMLHttpRequest. * It is necessary to use `htmx.ajax` to acquire correct headers which * HTMX and extensions add based on `sourceNode`. But it cannot be used * to perform the request due to side-effects e.g. loading indicators. * @param {string} url * @param {Node} sourceNode * @param {FormData=} formData */ function sendHxGetRequest(url, sourceNode, formData = undefined) { htmx.ajax("GET", url, { source: sourceNode, values: formData, headers: { "HX-Preloaded": "true" }, }); } /** * Send XML GET request to `url`. Send `formData` as URL params if set. * @param {string} url * @param {Node} sourceNode * @param {FormData=} formData */ function sendXmlGetRequest(url, sourceNode, formData = undefined) { const xhr = new XMLHttpRequest(); if (formData) { url += "?" + new URLSearchParams(formData.entries()).toString(); } xhr.open("GET", url); xhr.setRequestHeader("HX-Preloaded", "true"); xhr.onload = function () { processResponse(sourceNode, xhr.responseText); }; xhr.send(); } /** * Process request response by marking node `DONE` to prevent repeated * requests, except if preload attribute contains `always`, * and load linked resources (e.g. images) returned in the response * if `preload-images` attribute is `true` * @param {Node} node * @param {string} responseText */ function processResponse(node, responseText) { node.preloadState = node.preloadAlways ? "READY" : "DONE"; if (getClosestAttribute(node, "preload-images") === "true") { // Load linked resources document.createElement("div").innerHTML = responseText; } } /** * Gets attribute value from node or one of its parents * @param {Node} node * @param {string} attribute * @returns { string | undefined } */ function getClosestAttribute(node, attribute) { if (node == undefined) { return undefined; } return ( node.getAttribute(attribute) || node.getAttribute("data-" + attribute) || getClosestAttribute(node.parentElement, attribute) ); } /** * Determines if node is valid for preloading and should be * initialized by setting up event listeners and handlers * @param {Node} node * @returns {boolean} */ function isValidNodeForPreloading(node) { // Add listeners only to nodes which include "GET" transactions // or preloadable "GET" form elements const getReqAttrs = ["href", "hx-get", "data-hx-get"]; const includesGetRequest = (node) => getReqAttrs.some((a) => node.hasAttribute(a)) || node.method === "get"; const isPreloadableGetFormElement = node.form instanceof HTMLFormElement && includesGetRequest(node.form) && isPreloadableFormElement(node); if (!includesGetRequest(node) && !isPreloadableGetFormElement) { return false; } // Don't preload elements contained in