Back to "Utilizando SweetAlert2 para indicadores de carga HTMX (indicador hx)"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

HTMX Javascript

Utilizando SweetAlert2 para indicadores de carga HTMX (indicador hx)

Monday, 21 April 2025

Introducción

En un proyecto de trabajo he estado usando y abusando de HTMX para construir una interfaz de usuario de administración. Como parte de esto estoy usando la encantadora SweetAlert2 Biblioteca Javascript para mis diálogos de confirmación. Funciona muy bien, pero también quería usarlos para reemplazar mis indicadores de carga HTMX.

Esto resultó ser un reto así que pensé en documentarlo aquí para salvarte el mismo dolor.

Warning I'm a C# coder my Javascript is likely horrible.

[TOC]

El problema

Así que HTMX es muy inteligente, es hx-indicator normalmente le permite establecer un indicador de carga para sus solicitudes HTMX. Normalmente esto es un elemento HTML en su página como


<div id="loading-modal" class="modal htmx-indicator">
    <div class="modal-box flex flex-col items-center justify-center bg-base-200 border border-base-300 shadow-xl rounded-xl text-base-content dark text-center ">
        <div class="flex flex-col items-center space-y-4">
            <h2 class="text-lg font-semibold tracking-wide">Loading...</h2>
            <span class="loading loading-dots loading-xl text-4xl text-stone-200"></span>
        </div>
    </div>
</div>

Luego, cuando quieras usarlo, decorarías tu petición HTMX con hx-indicator="#loading-modal" y mostrará el modal cuando la solicitud esté en curso (ver aquí para más detalles).

Ahora HTMX hace magia inteligente usando un request objeto se rastrea internamente

  function addRequestIndicatorClasses(elt) {
    let indicators = /** @type Element[] */ (findAttributeTargets(elt, 'hx-indicator'))
    if (indicators == null) {
      indicators = [elt]
    }
    forEach(indicators, function(ic) {
      const internalData = getInternalData(ic)
      internalData.requestCount = (internalData.requestCount || 0) + 1
      ic.classList.add.call(ic.classList, htmx.config.requestClass)
    })
    return indicators
  }

Por lo tanto, la sustitución de estos es un poco un reto. ¿Cómo hacer un seguimiento de las solicitudes y luego mostrar el modal SweetAlert2 cuando la solicitud está en curso y ocultarlo cuando se ha terminado.

Una solución

Así que me puse a trabajar (no porque tenía que hacerlo, porque NECESITÉ :)) para reemplazar el indicador de carga HTMX con un modal SweetAlert2. De todos modos, aquí está el código que se me ocurrió.

Comenzarías importando SweetAlert2 en tu HTML (como script y etiquetas de estilo) / importarlo para webpack o similar (ver sus documentos para esto).

Después de la instalación de npm puede importarlo así en su archivo JS.

import Swal from 'sweetalert2';

Entonces mi código principal se ve así:

import Swal from 'sweetalert2';

const SWEETALERT_PATH_KEY = 'swal-active-path'; // Stores the path of the current SweetAlert
const SWEETALERT_HISTORY_RESTORED_KEY = 'swal-just-restored'; // Flag for navigation from browser history
const SWEETALERT_TIMEOUT_MS = 10000; // Timeout for automatically closing the loader

let swalTimeoutHandle = null;

export function registerSweetAlertHxIndicator() {
    document.body.addEventListener('htmx:configRequest', function (evt) {
        const trigger = evt.detail.elt;
        const indicatorAttrSource = getIndicatorSource(trigger);
        if (!indicatorAttrSource) return;

        const path = getRequestPath(evt.detail);
        if (!path) return;

        const currentPath = sessionStorage.getItem(SWEETALERT_PATH_KEY);

        // Show SweetAlert only if the current request path differs from the previous one
        if (currentPath !== path) {
            closeSweetAlertLoader();
            sessionStorage.setItem(SWEETALERT_PATH_KEY, path);
            evt.detail.indicator = null; // Disable HTMX's default indicator behavior

            Swal.fire({
                title: 'Loading...',
                allowOutsideClick: false,
                allowEscapeKey: false,
                showConfirmButton: false,
                theme: 'dark',
                didOpen: () => {
                    // Cancel immediately if restored from browser history
                    if (sessionStorage.getItem(SWEETALERT_HISTORY_RESTORED_KEY) === 'true') {
                        sessionStorage.removeItem(SWEETALERT_HISTORY_RESTORED_KEY);
                        Swal.close();
                        return;
                    }

                    Swal.showLoading();
                    document.dispatchEvent(new CustomEvent('sweetalert:opened'));

                    // Set timeout to auto-close if something hangs
                    clearTimeout(swalTimeoutHandle);
                    swalTimeoutHandle = setTimeout(() => {
                        if (Swal.isVisible()) {
                            console.warn('SweetAlert loading modal closed after timeout.');
                            closeSweetAlertLoader();
                        }
                    }, SWEETALERT_TIMEOUT_MS);
                },
                didClose: () => {
                    document.dispatchEvent(new CustomEvent('sweetalert:closed'));
                    sessionStorage.removeItem(SWEETALERT_PATH_KEY);
                    clearTimeout(swalTimeoutHandle);
                    swalTimeoutHandle = null;
                }
            });
        } else {
            // Suppress HTMX indicator if the path is already being handled
            evt.detail.indicator = null;
        }
    });

    //Add events to close the loader
    document.body.addEventListener('htmx:afterRequest', maybeClose);
    document.body.addEventListener('htmx:responseError', maybeClose);
    document.body.addEventListener('sweetalert:close', closeSweetAlertLoader);

    // Set a flag so we can suppress the loader immediately if navigating via browser history
    document.body.addEventListener('htmx:historyRestore', () => {
        sessionStorage.setItem(SWEETALERT_HISTORY_RESTORED_KEY, 'true');
    });

    window.addEventListener('popstate', () => {
        sessionStorage.setItem(SWEETALERT_HISTORY_RESTORED_KEY, 'true');
    });
}

// Returns the closest element with an indicator attribute
function getIndicatorSource(el) {
    return el.closest('[hx-indicator], [data-hx-indicator]');
}

// Determines the request path, including query string if appropriate
function getRequestPath(detail) {
    const responsePath =
        typeof detail?.pathInfo?.responsePath === 'string'
            ? detail.pathInfo.responsePath
            : (typeof detail?.pathInfo?.path === 'string'
                    ? detail.pathInfo.path
                    : (typeof detail?.path === 'string' ? detail.path : '')
            );

    const elt = detail.elt;

    // If not a form and has an hx-indicator, use the raw path
    if (elt.hasAttribute("hx-indicator") && elt.tagName !== "FORM") {
        return responsePath;
    }

    const isGet = (detail.verb ?? '').toUpperCase() === 'GET';
    const form = elt.closest('form');

    // Append query params for GET form submissions
    if (isGet && form) {
        const params = new URLSearchParams();

        for (const el of form.elements) {
            if (!el.name || el.disabled) continue;

            const type = el.type;
            if ((type === 'checkbox' || type === 'radio') && !el.checked) continue;
            if (type === 'submit') continue;

            params.append(el.name, el.value);
        }

        const queryString = params.toString();
        return queryString ? `${responsePath}?${queryString}` : responsePath;
    }

    return responsePath;
}

// Closes the SweetAlert loader if the path matches
function maybeClose(evt) {
    const activePath = sessionStorage.getItem(SWEETALERT_PATH_KEY);
    const path = getRequestPath(evt.detail);

    if (activePath && path && activePath === path) {
        closeSweetAlertLoader();
    }
}

// Close and clean up SweetAlert loader state
function closeSweetAlertLoader() {
    if (Swal.getPopup()) {
        Swal.close();
        document.dispatchEvent(new CustomEvent('sweetalert:closed'));
        sessionStorage.removeItem(SWEETALERT_PATH_KEY);
        clearTimeout(swalTimeoutHandle);
        swalTimeoutHandle = null;
    }
}

Configure esto (si está usando ESM) en su main.js archivo como este


import { registerSweetAlertHxIndicator } from './hx-sweetalert-indicator.js';
registerSweetAlertHxIndicator();

Encontrar nuestros elementos

Ya verás que uso el getIndicatorSource función para encontrar el elemento que desencadenó la petición HTMX. Esto es importante, ya que necesitamos saber qué elemento desencadenó la petición para que podamos cerrar el modal cuando esté terminado. Esto es importante ya que HTMX tiene 'herencia' por lo que es necesario escalar el árbol para encontrar el elemento que desencadenó la solicitud.

function getIndicatorSource(el) {
    return el.closest('[hx-indicator], [data-hx-indicator]');
}

A continuación, en cualquier solicitud HTMX (así que hx-get o hx-post) se puede utilizar el hx-indicator atributo para especificar el modal de SweetAlert2. Ni siquiera necesitas especificar la clase como antes, solo el parámetro existente funciona.

Repasemos cómo funciona todo esto:

Conectándolo con registerSweetAlertHxIndicator()

Esto actúa como punto de entrada. Puedes ver que se engancha en el htmx:configRequest evento. Esto se dispara cuando HTMX está a punto de hacer una solicitud.

Luego obtiene el elemento que desencadenó el evento en evt.detail.elt y comprueba si tiene un hx-indicator atributo.

Por último, muestra el modal SweetAlert2 utilizando Swal.fire().

rt function registerSweetAlertHxIndicator() {
    document.body.addEventListener('htmx:configRequest', function (evt) {
        const trigger = evt.detail.elt;
        const indicatorAttrSource = getIndicatorSource(trigger);
        if (!indicatorAttrSource) return;
        

Obtener la ruta de solicitud

Si lo hace, obtiene la ruta de solicitud usando getRequestPath(evt.detail) y lo almacena en el almacenamiento de sesión. Niw HTMX es un enculado complicado, almacena el camino en diferentes lugares dependiendo de dónde estés en el ciclo de vida. Así que en mi código hago todo lo demás. con detail?.pathInfo?.path ?? detail?.path ?? '';

Resulta que HTMX almacenaba el solicitud entrada detail.path y la ruta de respuesta (para document.body.addEventListener('htmx:afterRequest', maybeClose); document.body.addEventListener('htmx:responseError', maybeClose);) en detail.PathInfo.responsePath Así que necesitamos manejar ambas cosas.

También tenemos que manejar GET formularios; ya que su respuesta probablemente incluirá los elementos URL pasados como <input > valores para que la url de respuesta pueda terminar siendo diferente.

// Returns the closest element with an indicator attribute
function getIndicatorSource(el) {
    return el.closest('[hx-indicator], [data-hx-indicator]');
}

// Determines the request path, including query string if appropriate
function getRequestPath(detail) {
    const responsePath =
        typeof detail?.pathInfo?.responsePath === 'string'
            ? detail.pathInfo.responsePath
            : (typeof detail?.pathInfo?.path === 'string'
                    ? detail.pathInfo.path
                    : (typeof detail?.path === 'string' ? detail.path : '')
            );

    const elt = detail.elt;

    // If not a form and has an hx-indicator, use the raw path
    if (elt.hasAttribute("hx-indicator") && elt.tagName !== "FORM") {
        return responsePath;
    }

    const isGet = (detail.verb ?? '').toUpperCase() === 'GET';
    const form = elt.closest('form');

    // Append query params for GET form submissions
    if (isGet && form) {
        const params = new URLSearchParams();

        for (const el of form.elements) {
            if (!el.name || el.disabled) continue;

            const type = el.type;
            if ((type === 'checkbox' || type === 'radio') && !el.checked) continue;
            if (type === 'submit') continue;

            params.append(el.name, el.value);
        }

        const queryString = params.toString();
        return queryString ? `${responsePath}?${queryString}` : responsePath;
    }

    return responsePath;
}

NOTA: Este es especialmente el caso si se utiliza la HX-Push-Url encabezado para cambiar la URL de la solicitud que HTMX almacena para History.

El formulario

HttpGet los formularios son un poco complicados por lo que tenemos un pedazo de código que detectará si usted ha hecho clic en un submit botón dentro de un formulario y añadir los parámetros de la cadena de consulta causados por esas entradas molestas para comparar con la URL de respuesta.

const isGet = (detail.verb ?? '').toUpperCase() === 'GET';
    const form = elt.closest('form');

    // Append query params for GET form submissions
    if (isGet && form) {
        const params = new URLSearchParams();

        for (const el of form.elements) {
            if (!el.name || el.disabled) continue;

            const type = el.type;
            if ((type === 'checkbox' || type === 'radio') && !el.checked) continue;
            if (type === 'submit') continue;

            params.append(el.name, el.value);
        }

        const queryString = params.toString();
        return queryString ? `${responsePath}?${queryString}` : responsePath;
    }

    return responsePath;
    ```
    
This is important as HTMX will use the response URL to determine if the request is the same as the previous one. So we need to ensure we have the same URL in both places.

### Extensions
I use this little `Response` extension method to set the `HX-Push-Url` header in my ASP.NET Core app. I also added a second extension which will immediately close the modal (useful if you mess with the request and need to close it immediately). 
```csharp
public static class ResponseExtensions
{
    public static void PushUrl(this HttpResponse response, HttpRequest request)
    {
        response.Headers["HX-Push-Url"] = request.GetEncodedUrl();
    }
}
    public static void CloseSweetAlert(this HttpResponse response)
    {
        response.Headers.Append("HX-Trigger" , JsonSerializer.Serialize(new
        {
            sweetalert = "closed"
        }));

    }
}

Este segundo se maneja aquí:

    document.body.addEventListener('sweetalert:close', closeSweetAlertLoader);

Almacenar el camino

Bien, ahora que tenemos el camino, ¿qué hacemos con él? Bueno, para hacer un seguimiento de qué petición se disparó el modal SweetAlert2 lo almacenamos en sessionStorage utilizando sessionStorage.setItem(SWEETALERT_PATH_KEY, path);.

(De nuevo puede hacer esto más complejo y asegurarse de que sólo tiene uno si lo necesita.)

Mostrar el modal

A continuación, simplemente mostrar el modal SweetAlert2 utilizando Swal.fire(). nota que tenemos un montón de opciones aquí.

Al abrirlo comprueba una clave de almacenamiento de sesión SWEETALERT_HISTORY_RESTORED_KEY que se establece cuando la historia es restaurada. Si lo es, cerramos el modal de inmediato (salva a HTMX estropeándonos con su extraña gestión de la historia).

También disparamos un evento. sweetalert:opened que puedes usar para hacer cualquier lógica personalizada que necesites.

Swal.fire({
    title: 'Loading...',
    allowOutsideClick: false,
    allowEscapeKey: false,
    showConfirmButton: false,
    theme: 'dark',
    didOpen: () => {
        // Cancel immediately if restored from browser history
        if (sessionStorage.getItem(SWEETALERT_HISTORY_RESTORED_KEY) === 'true') {
            sessionStorage.removeItem(SWEETALERT_HISTORY_RESTORED_KEY);
            Swal.close();
            return;
        }

        Swal.showLoading();
        document.dispatchEvent(new CustomEvent('sweetalert:opened'));

        // Set timeout to auto-close if something hangs
        clearTimeout(swalTimeoutHandle);
        swalTimeoutHandle = setTimeout(() => {
            if (Swal.isVisible()) {
                console.warn('SweetAlert loading modal closed after timeout.');
                closeSweetAlertLoader();
            }
        }, SWEETALERT_TIMEOUT_MS);
    },
    didClose: () => {
        document.dispatchEvent(new CustomEvent('sweetalert:closed'));
        sessionStorage.removeItem(SWEETALERT_PATH_KEY);
        clearTimeout(swalTimeoutHandle);
        swalTimeoutHandle = null;
    }
});

Además, establecemos un tiempo de espera para manejar los casos en los que la solicitud se cuelga. Esto es importante ya que HTMX no siempre cierra el modal si la solicitud falla (especialmente si utiliza hx-boost). Esto está aquí. const SWEETALERT_TIMEOUT_MS = 10000; // Timeout for automatically closing the loader así que podemos cerrar el modal si algo sale mal (también se registrará en la consola).

Cerrándolo.

Así que ahora tenemos el modal abierto, tenemos que cerrarlo cuando la solicitud esté terminada. Para hacer esto llamamos a la maybeClose función. Esto se llama cuando la petición está terminada (con éxito o con un error). Uso htmx:afterRequest y htmx:responseError Acontecimientos. Estos eventos se disparan una vez que HTMX ha terminado una solicitud (nota, estos son importantes, especial para HX-Boost que puede ser un poco divertido acerca de los eventos que dispara.)

    document.body.addEventListener('htmx:afterRequest', maybeClose);
    document.body.addEventListener('htmx:responseError', maybeClose);
    function maybeClose(evt) {
        const activePath = sessionStorage.getItem(SWEETALERT_PATH_KEY);
        const path = getRequestPath(evt.detail);

        if (activePath && path && activePath === path) {
            Swal.close();
            sessionStorage.removeItem(SWEETALERT_PATH_KEY);
        }
    }

Verá que esta función comprueba si la ruta en el almacenamiento de sesión es la misma que la ruta de la solicitud. Si lo es, cierra el modal y elimina la ruta del almacenamiento de sesión.

Es historia.

HTMX tiene una forma fiddly de manejar la historia que podría dejar el modal 'atascado' abierto en hacer una página trasera. Así que agregamos un par de eventos más para atrapar esto (la mayoría de las veces sólo necesitamos uno, pero correa y aparatos ortopédicos).

    //Add events to close the loader
    document.body.addEventListener('htmx:afterRequest', maybeClose);
    document.body.addEventListener('htmx:responseError', maybeClose);
    document.body.addEventListener('sweetalert:close', closeSweetAlertLoader);

    // Set a flag so we can suppress the loader immediately if navigating via browser history
    document.body.addEventListener('htmx:historyRestore', () => {
        sessionStorage.setItem(SWEETALERT_HISTORY_RESTORED_KEY, 'true');
    });

    window.addEventListener('popstate', () => {
        sessionStorage.setItem(SWEETALERT_HISTORY_RESTORED_KEY, 'true');
    });`

Verá que también establecemos el sessionStorage.setItem(SWEETALERT_HISTORY_RESTORED_KEY, 'true'); que comprobamos en el didOpen evento:

           didOpen: () => {
    // Cancel immediately if restored from browser history
    if (sessionStorage.getItem(SWEETALERT_HISTORY_RESTORED_KEY) === 'true') {
        sessionStorage.removeItem(SWEETALERT_HISTORY_RESTORED_KEY);
        Swal.close();
        return;
    }

Lo hacemos en caso de que el modal no abra inmediatamente. popstate \ htmx:historyRestore (especialmente si tienes mucha historia). Así que tenemos que comprobarlo en el didOpen evento (de ahí que esté en clave de sesión, a veces esto puede recargar, etc... así que tenemos que ser conscientes de eso).

BONUS - Para mi Ayudante de PagingTag

Si estás usando mi PagingTagHelper Hay un problema con el PageSize y el uso de SweetAlert; esto se debe a que TAMBIÉN enganchar el htmx:configRequest` event. Así que tenemos que añadir un poco de código para asegurarnos de que no entra en conflicto con el modal SweetAlert2.

(() => {
    if (window.__pageSizeListenerAdded) return;

    document.addEventListener('htmx:configRequest', event => {
        const { elt } = event.detail;
        if (elt?.matches('[name="pageSize"]')) {
            const params = new URLSearchParams(window.location.search);
            params.set('pageSize', elt.value);

            const paramObj = Object.fromEntries(params.entries());
            event.detail.parameters = paramObj;
            
            const pageSizeEvent = new CustomEvent('pagesize:updated', {
                detail: {
                    params: paramObj,
                    elt,
                },
            });

            document.dispatchEvent(pageSizeEvent);
        }
    });

    window.__pageSizeListenerAdded = true;
})();

Para solucionar esto es necesario añadir una pequeña función:

/**
 * Appends the current pageSize from the browser's URL to the HTMX request path,
 * modifying event.detail.path directly.
 *
 * @param {CustomEvent['detail']} detail - The HTMX event.detail object
 */
export function getPathWithPageSize(detail) {
    const originalPath = detail.path;
    if (!originalPath) return null;

    const selectedValue = detail.elt?.value ?? '';
    const url = new URL(originalPath, window.location.origin);

    // Use the current location's query string, update pageSize
    const locationParams = new URLSearchParams(window.location.search);
    locationParams.set('pageSize', selectedValue);
    url.search = locationParams.toString();

    return url.pathname + url.search;
}

Esto sólo recibe la petición y añade el pagesize parámetro a él. A continuación, importar esta función

import { getPathWithPageSize } from './pagesize-sweetalert'

export function registerSweetAlertHxIndicator() {
    document.body.addEventListener('htmx:configRequest', function (evt) {
        const trigger = evt.detail.elt;

        const indicatorAttrSource = getIndicatorSource(trigger);
        if (!indicatorAttrSource) return;

        // ✅ If this is a pageSize-triggered request, use our custom path
        let path;
        if (evt.detail.headers?.['HX-Trigger-Name'] === 'pageSize') {
            path = getPathWithPageSize(evt.detail);
            console.debug('[SweetAlert] Using custom path with updated pageSize:', path);
        } else {
            path = getRequestPath(evt.detail);
        }
        ...rest of code

Se puede ver aquí que detectamos el pageSize activador y cuando lo encuentra (por lo que cambió el tamaño de las páginas) utiliza el getPathWithPageSize función para obtener el nuevo camino. Los pageSize código reemplazará esto más adelante en la tubería, pero esto le permite detectar correctamente por lo que se cierra como se esperaba.

Conclusión

Así es como puedes usar SweetAlert2 como un indicador de carga HTMX. Es un poco de un truco, pero funciona y es una buena manera de utilizar la misma biblioteca para cargar indicadores y diálogos de confirmación.9

logo

©2024 Scott Galloway