Back to "Utilisation de SweetAlert2 pour les indicateurs de chargement HTMX (indicateur 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

Utilisation de SweetAlert2 pour les indicateurs de chargement HTMX (indicateur hx)

Monday, 21 April 2025

Présentation

Sur un projet de travail, j'ai utilisé et abusé de HTMX pour construire une interface utilisateur admin. Dans le cadre de ce que j'utilise la belle SweetAlert2 Bibliothèque Javascript pour mes dialogues de confirmationC'est ce que j'ai dit. Il fonctionne bien, mais je voulais aussi les utiliser pour remplacer mes indicateurs de chargement HTMX.

Cela s'est avéré être un défi donc je pensais le documenter ici pour vous sauver la même douleur.

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

[TOC]

Le problème

Donc HTMX est très intelligent, c'est hx-indicator normalement vous permet de définir un indicateur de chargement pour vos demandes HTMX. Normalement, c'est un élément HTML dans votre page comme


<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>

Ensuite, lorsque vous voulez l'utiliser, vous décoreriez votre demande HTMX avec hx-indicator="#loading-modal" et il montrera le modal lorsque la demande est en cours (Voir ici pour plus de détails).

Maintenant HTMX fait une magie intelligente à l'aide d'un request objet il suit en interne

  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
  }

Il est donc difficile de les remplacer. Comment suivre les demandes et ensuite afficher le mode SweetAlert2 lorsque la demande est en cours et le cacher quand elle est terminée.

Une solution

J'ai donc mis sur le point (pas parce que je devais, parce que j'avais besoin de :)) de remplacer l'indicateur de chargement HTMX par un modal SweetAlert2. Quoi qu'il en soit, voici le code que j'ai trouvé.

Vous commenceriez par importer SweetAlert2 dans votre HTML (comme tags script & style) / l'importer pour le webpack ou similaire (voir leurs documents pour cela).

Après l'installation de npm, vous pouvez l'importer comme ça dans votre fichier JS.

import Swal from 'sweetalert2';

Alors mon code principal ressemble à ceci:

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;
    }
}

Vous configurez ceci (si vous utilisez ESM) dans votre main.js fichier comme ceci


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

Trouver nos éléments

Tu verras que j'utilise le getIndicatorSource fonction pour trouver l'élément qui a déclenché la requête HTMX. C'est important car nous avons besoin de savoir quel élément a déclenché la demande afin que nous puissions fermer le modal quand il est terminé. C'est important car HTMX a 'héritage' pour que vous ayez besoin d'escalader l'arbre pour trouver l'élément qui a déclenché la requête.

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

Puis sur n'importe quelle demande HTMX (donc hx-get ou hx-post) vous pouvez utiliser le hx-indicator attribut pour spécifier le mode SweetAlert2. Vous n'avez même pas besoin de spécifier la classe comme avant, juste le paramètre existant fonctionne.

Examinons comment tout cela fonctionne :

Je l'accroche avec. registerSweetAlertHxIndicator()

Cela sert de point d'entrée. Vous pouvez voir qu'il s'accroche à la htmx:configRequest l'événement. Ceci est renvoyé lorsque HTMX est sur le point de faire une demande.

Il obtient alors l'élément qui a déclenché l'événement dans evt.detail.elt et vérifie si elle a un hx-indicator attribut.

Enfin, il montre le mode SweetAlert2 en utilisant Swal.fire().

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

Obtenir le chemin de requête

Si c'est le cas, il obtient le chemin de requête en utilisant getRequestPath(evt.detail) et le stocke dans le stockage de session. Niw HTMX est un bugger délicat, il stocke le chemin dans différents endroits en fonction de l'endroit où vous êtes dans le cycle de vie. Donc dans mon code, je fais tout le monde. avec detail?.pathInfo?.path ?? detail?.path ?? '';

Il s'avère que HTMX a stocké les demande chemin dans detail.path et le chemin de réponse (pour document.body.addEventListener('htmx:afterRequest', maybeClose); document.body.addEventListener('htmx:responseError', maybeClose);) dans detail.PathInfo.responsePath Donc nous avons besoin de gérer les deux.

Nous devons aussi gérer GET les formulaires; comme leur réponse comprendra probablement les éléments d'URL transmis comme <input > valeurs afin que l'url de réponse puisse finir par être différent.

// 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;
}

REMARQUE: Ceci est particulièrement le cas si vous utilisez le HX-Push-Url header pour modifier l'URL de la requête que HTMX stocke pour History.

Le formulaire

HttpGet les formulaires sont un peu délicats donc nous avons un morceau de code qui va détecter si vous avez cliqué sur un submit bouton à l'intérieur d'un formulaire et ajouter les paramètres de la chaîne de requête causés par ces entrées pesky à comparer à l'URL de réponse.

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"
        }));

    }
}

Cette seconde est traitée ici :

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

Stocker le chemin

Ok donc maintenant nous avons le chemin, qu'est-ce qu'on en fait? Eh bien, pour garder la trace de la requête qui a déclenché le modal SweetAlert2 nous le stockons dans sessionStorage utilisant sessionStorage.setItem(SWEETALERT_PATH_KEY, path);.

(De plus, vous pouvez rendre cela plus complexe et vous assurer que vous n'en avez qu'un si vous en avez besoin.)

Affichage du mode

Nous montrons ensuite simplement le mode SweetAlert2 en utilisant Swal.fire()C'est ce que j'ai dit. Notez que nous avons un tas d'options ici.

En l'ouvrant, il vérifie une clé de stockage de session SWEETALERT_HISTORY_RESTORED_KEY qui est fixé quand l'histoire est restaurée. Si c'est le cas, nous fermons le modal immédiatement (il sauve HTMX nous gâchant avec sa gestion de l'historique bizarre).

Nous tirons aussi un événement sweetalert:opened que vous pouvez utiliser pour faire n'importe quelle logique personnalisée dont vous avez besoin.

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;
    }
});

En outre, nous définissons un délai pour traiter les cas où la demande est suspendue. Ceci est important car HTMX ne ferme pas toujours le modal si la requête échoue (surtout si vous utilisez hx-boost)............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................. C'est réglé ici. const SWEETALERT_TIMEOUT_MS = 10000; // Timeout for automatically closing the loader donc nous pouvons fermer le modal si quelque chose tourne mal (il se connectera aussi à la console).

C'est fini.

Donc maintenant nous avons le mode ouvert, nous devons le fermer quand la demande est terminée. Pour ce faire, nous appelons le maybeClose fonction. Ceci est appelé lorsque la requête est terminée (avec succès ou avec une erreur). Utilisation htmx:afterRequest et htmx:responseError les événements. Ces événements font feu une fois que HTMX a terminé une demande (note, ceux-ci sont importants, HX-Boost Ce qui peut être un peu drôle à propos des événements qu'il allume.)

    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);
        }
    }

Vous verrez cette fonction vérifier si le chemin dans le stockage de session est le même que le chemin de la requête. S'il l'est, il ferme le modal et supprime le chemin du stockage de session.

C'est l'Histoire

HTMX dispose d'une façon fictive de gérer l'historique qui pourrait laisser le modal'stuck' ouvert en faisant une page arrière. Nous ajoutons donc quelques événements supplémentaires pour attraper cela (la plupart du temps, nous n'avons besoin que d'un seul mais ceinture & appareillage).

    //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');
    });`

Vous verrez que nous avons aussi mis le sessionStorage.setItem(SWEETALERT_HISTORY_RESTORED_KEY, 'true'); que nous vérifions dans le didOpen événement:

           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;
    }

Nous le faisons dans le cas où le modal ne s'ouvre pas immédiatement popstate \ htmx:historyRestore (surtout si vous avez beaucoup d'histoire). Donc, nous devons vérifier dans le didOpen event (d'où qu'il soit dans la clé de session, parfois cela peut recharger etc... donc nous devons être conscients de cela).

BONUS - Pour mon assistant PagingTag

Si vous utilisez mon PagingTagHelper Il y a un problème avec le PageSize et en utilisant SweetAlert; cela est dû à lui ALSO accrochant la htmx:configRequest` événement. Nous devons donc ajouter un petit code pour nous assurer qu'il ne soit pas en conflit avec le model 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;
})();

Pour corriger cela, vous devez ajouter une petite fonction :

/**
 * 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;
}

Cela obtient juste la demande et ajoute le pagesize Paramètre à celui-ci. Importer ensuite cette fonction

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

Vous pouvez voir ici que nous détectons le pageSize trigger et quand il le trouve (de sorte que vous avez changé de pagesize) il utilise le getPathWithPageSize fonction pour obtenir le nouveau chemin. Les pageSize le code remplacera cela plus tard dans le pipeline, mais cela lui permet de détecter correctement afin qu'il se ferme comme prévu.

En conclusion

C'est ainsi que vous pouvez utiliser SweetAlert2 comme indicateur de chargement HTMX. C'est un peu un hack mais il fonctionne et c'est une bonne façon d'utiliser la même bibliothèque pour les indicateurs de chargement et les dialogues de confirmation.9

logo

©2024 Scott Galloway