diff --git a/src/ext/loading-states.js b/src/ext/loading-states.js new file mode 100644 index 00000000..773e6f21 --- /dev/null +++ b/src/ext/loading-states.js @@ -0,0 +1,165 @@ +;(function () { + let loadingStatesUndoQueue = [] + + function loadingStateContainer(target) { + return htmx.closest(target, '[data-loading-states]') || document.body + } + + function mayProcessUndoCallback(target, callback) { + if (document.body.contains(target)) { + callback() + } + } + + function mayProcessLoadingStateByPath(elt, requestPath) { + const pathElt = htmx.closest(elt, '[data-loading-path]') + if (!pathElt) { + return true + } + + return pathElt.getAttribute('data-loading-path') === requestPath + } + + function queueLoadingState(sourceElt, targetElt, doCallback, undoCallback) { + const delayElt = htmx.closest(sourceElt, '[data-loading-delay]') + if (delayElt) { + const delayInMilliseconds = + delayElt.getAttribute('data-loading-delay') || 200 + const timeout = setTimeout(() => { + doCallback() + + loadingStatesUndoQueue.push(() => { + mayProcessUndoCallback(targetElt, () => undoCallback()) + }) + }, delayInMilliseconds) + + loadingStatesUndoQueue.push(() => { + mayProcessUndoCallback(targetElt, () => clearTimeout(timeout)) + }) + } else { + doCallback() + loadingStatesUndoQueue.push(() => { + mayProcessUndoCallback(targetElt, () => undoCallback()) + }) + } + } + + function getLoadingStateElts(loadingScope, type, path) { + return Array.from(htmx.findAll(loadingScope, `[${type}]`)).filter( + (elt) => mayProcessLoadingStateByPath(elt, path) + ) + } + + function getLoadingTarget(elt) { + if (elt.getAttribute('data-loading-target')) { + return Array.from( + htmx.findAll(elt.getAttribute('data-loading-target')) + ) + } + return [elt] + } + + htmx.defineExtension('loading-states', { + onEvent: function (name, evt) { + if (name === 'htmx:beforeRequest') { + const container = loadingStateContainer(evt.target) + + const loadingStateTypes = [ + 'data-loading', + 'data-loading-class', + 'data-loading-class-remove', + 'data-loading-disable', + ] + + let loadingStateEltsByType = {} + + loadingStateTypes.forEach((type) => { + loadingStateEltsByType[type] = getLoadingStateElts( + container, + type, + evt.detail.pathInfo.path + ) + }) + + loadingStateEltsByType['data-loading'].forEach((sourceElt) => { + getLoadingTarget(sourceElt).forEach((targetElt) => { + queueLoadingState( + sourceElt, + targetElt, + () => + (targetElt.style.display = + sourceElt.getAttribute('data-loading') || + 'inline-block'), + () => (targetElt.style.display = 'none') + ) + }) + }) + + loadingStateEltsByType['data-loading-class'].forEach( + (sourceElt) => { + const classNames = sourceElt + .getAttribute('data-loading-class') + .split(' ') + + getLoadingTarget(sourceElt).forEach((targetElt) => { + queueLoadingState( + sourceElt, + targetElt, + () => + classNames.forEach((className) => + targetElt.classList.add(className) + ), + () => + classNames.forEach((className) => + targetElt.classList.remove(className) + ) + ) + }) + } + ) + + loadingStateEltsByType['data-loading-class-remove'].forEach( + (sourceElt) => { + const classNames = sourceElt + .getAttribute('data-loading-class-remove') + .split(' ') + + getLoadingTarget(sourceElt).forEach((targetElt) => { + queueLoadingState( + sourceElt, + targetElt, + () => + classNames.forEach((className) => + targetElt.classList.remove(className) + ), + () => + classNames.forEach((className) => + targetElt.classList.add(className) + ) + ) + }) + } + ) + + loadingStateEltsByType['data-loading-disable'].forEach( + (sourceElt) => { + getLoadingTarget(sourceElt).forEach((targetElt) => { + queueLoadingState( + sourceElt, + targetElt, + () => (targetElt.disabled = true), + () => (targetElt.disabled = false) + ) + }) + } + ) + } + + if (name === 'htmx:afterOnLoad') { + while (loadingStatesUndoQueue.length > 0) { + loadingStatesUndoQueue.shift()() + } + } + }, + }) +})() diff --git a/www/extensions.md b/www/extensions.md index db137412..3a2ebe9a 100644 --- a/www/extensions.md +++ b/www/extensions.md @@ -64,10 +64,9 @@ against `htmx` in each distribution | [`event-header`](/extensions/event-header) | includes a JSON serialized version of the triggering event, if any | [`include-vals`](/extensions/include-vals) | allows you to include additional values in a request | [`json-enc`](/extensions/json-enc) | use JSON encoding in the body of requests, rather than the default `x-www-form-urlencoded` +| [`loading-states`](/extensions/loading-states) | allows you to disable inputs, add and remove CSS classes to any element while a request is in-flight. | [`method-override`](/extensions/method-override) | use the `X-HTTP-Method-Override` header for non-`GET` and `POST` requests | [`morphdom-swap`](/extensions/morphdom-swap) | an extension for using the [morphdom](https://github.com/patrick-steele-idem/morphdom) library as the swapping mechanism in htmx. -| [`client-side-templates`](/extensions/client-side-templates) | support for client side template processing of JSON responses -| [`debug`](/extensions/debug) | an extension for debugging of a particular element using htmx | [`path-deps`](/extensions/path-deps) | an extension for expressing path-based dependencies [similar to intercoolerjs](http://intercoolerjs.org/docs.html#dependencies) | [`preload`](/extensions/preload) | preloads selected `href` and `hx-get` targets based on rules you control. | [`remove-me`](/extensions/remove-me) | allows you to remove an element after a given amount of time diff --git a/www/extensions/loading-states.md b/www/extensions/loading-states.md new file mode 100644 index 00000000..9bfda023 --- /dev/null +++ b/www/extensions/loading-states.md @@ -0,0 +1,129 @@ +--- +layout: layout.njk +title: > htmx - high power tools for html +--- + +## The `loading-states` Extension + +This extension allows you to easily manage loading states while a request is in flight, including disabling elements, and adding and removing CSS classes. + +### Usage + +Add the `hx-ext="loading-states"` attribute to the body tag or to any parent element containing your htmx attributes. + +Add the following class to your stylesheet to make sure elements are hidden by default: + +```css +[data-loading] { + display: none; +} +``` + +### Supported attributes + +- `data-loading` + + Shows the element. The default style is `inline-block`, but it's possible to use any display style by specifying it in the attribute value. + + ```html +