HTMX + Deps Update

This commit is contained in:
2025-01-12 14:51:43 -06:00
parent 38dc30fb0f
commit 44c84ced2e
17 changed files with 470 additions and 193 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,141 +1,388 @@
// This adds the "preload" extension to htmx. By default, this will
// preload the targets of any tags with `href` or `hx-get` attributes
// if they also have a `preload` attribute as well. See documentation
// for more details
htmx.defineExtension('preload', {
(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/
*/
onEvent: function(name, event) {
// Only take actions on "htmx:afterProcessNode"
if (name !== 'htmx:afterProcessNode') {
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
}
// SOME HELPER FUNCTIONS WE'LL NEED ALONG THE WAY
// attr gets the closest non-empty value from the attribute.
var attr = function(node, property) {
if (node == undefined) { return undefined }
return node.getAttribute(property) || node.getAttribute('data-' + property) || attr(node.parentElement, property)
if (!isValidNodeForPreloading(node)) {
return
}
// load handles the actual HTTP fetch, and uses htmx.ajax in cases where we're
// preloading an htmx resource (this sends the same HTTP headers as a regular htmx request)
var load = function(node) {
// Called after a successful AJAX request, to mark the
// content as loaded (and prevent additional AJAX calls.)
var done = function(html) {
if (!node.preloadAlways) {
node.preloadState = 'DONE'
}
if (attr(node, 'preload-images') == 'true') {
document.createElement('div').innerHTML = html // create and populate a node to load linked resources, too.
}
// 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
}
return function() {
// If this value has already been loaded, then do not try again.
if (node.preloadState !== 'READY') {
return
}
// Special handling for HX-GET - use built-in htmx.ajax function
// so that headers match other htmx requests, then set
// node.preloadState = TRUE so that requests are not duplicated
// in the future
var hxGet = node.getAttribute('hx-get') || node.getAttribute('data-hx-get')
if (hxGet) {
htmx.ajax('GET', hxGet, {
source: node,
handler: function(elt, info) {
done(info.xhr.responseText)
}
})
return
}
// Otherwise, perform a standard xhr request, then set
// node.preloadState = TRUE so that requests are not duplicated
// in the future.
if (node.getAttribute('href')) {
var r = new XMLHttpRequest()
r.open('GET', node.getAttribute('href'))
r.onload = function() { done(r.responseText) }
r.send()
}
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))
}
// This function processes a specific node and sets up event handlers.
// We'll search for nodes and use it below.
var init = function(node) {
// If this node DOES NOT include a "GET" transaction, then there's nothing to do here.
if (node.getAttribute('href') + node.getAttribute('hx-get') + node.getAttribute('data-hx-get') == '') {
return
}
// Guarantee that we only initialize each node once.
if (node.preloadState !== undefined) {
return
}
// Get event name from config.
var on = attr(node, 'preload') || 'mousedown'
const always = on.indexOf('always') !== -1
if (always) {
on = on.replace('always', '').trim()
}
// FALL THROUGH to here means we need to add an EventListener
// Apply the listener to the node
node.addEventListener(on, function(evt) {
if (node.preloadState === 'PAUSE') { // Only add one event listener
node.preloadState = 'READY' // Required for the `load` function to trigger
// Special handling for "mouseover" events. Wait 100ms before triggering load.
if (on === 'mouseover') {
window.setTimeout(load(node), 100)
} else {
load(node)() // all other events trigger immediately.
}
// 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'
}
})
// Special handling for certain built-in event handlers
switch (on) {
case 'mouseover':
// Mirror `touchstart` events (fires immediately)
node.addEventListener('touchstart', load(node))
// WHhen the mouse leaves, immediately disable the preload
node.addEventListener('mouseout', function(evt) {
if ((evt.target === node) && (node.preloadState === 'READY')) {
node.preloadState = 'PAUSE'
}
})
break
case 'mousedown':
// Mirror `touchstart` events (fires immediately)
node.addEventListener('touchstart', load(node))
break
}
// Mark the node as ready to run.
node.preloadState = 'PAUSE'
node.preloadAlways = always
htmx.trigger(node, 'preload:init') // This event can be used to load content immediately.
}
// Search for all child nodes that have a "preload" attribute
const parent = event.target || event.detail.elt;
parent.querySelectorAll("[preload]").forEach(function(node) {
// Initialize the node with the "preload" attribute
init(node)
// Mark the node as ready to be preloaded
node.preloadState = 'READY'
// Initialize all child elements that are anchors or have `hx-get` (use with care)
node.querySelectorAll('a,[hx-get],[data-hx-get]').forEach(init)
})
// 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 <input> elements contained in <label>
// to prevent sending two requests. Interaction on <input> in a
// <label><input></input></label> situation activates <label> too.
if (node instanceof HTMLInputElement && node.closest('label')) {
return false
}
return true
}
/**
* Determine if node is a form element which can be preloaded,
* i.e., `radio`, `checkbox`, `select` or `submit` button
* or a `label` of a form element which can be preloaded.
* @param {Node} node
* @returns {boolean}
*/
function isPreloadableFormElement(node) {
if (node instanceof HTMLInputElement || node instanceof HTMLButtonElement) {
const type = node.getAttribute('type');
return ['checkbox', 'radio', 'submit'].includes(type);
}
if (node instanceof HTMLLabelElement) {
return node.control && isPreloadableFormElement(node.control);
}
return node instanceof HTMLSelectElement;
}
})()

View File

@ -411,8 +411,12 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
*/
function maybeCloseWebSocketSource(elt) {
if (!api.bodyContains(elt)) {
api.getInternalData(elt).webSocket.close()
return true
var internalData = api.getInternalData(elt)
if (internalData.webSocket) {
internalData.webSocket.close()
return true
}
return false
}
return false
}