Verwendung von SweetAlert2 für HTMX Ladeindikatoren (hx-Indikator) (Deutsch (German))

Verwendung von SweetAlert2 für HTMX Ladeindikatoren (hx-Indikator)

Comments

NOTE: Apart from English (and even then it's questionable, I'm Scottish). These are machine translated in languages I don't read. If they're terrible please contact me.
You can see how this translation was done in this article.

Monday, 21 April 2025

//

Less than a minute

Einleitung

Bei einem Arbeitsprojekt habe ich HTMX benutzt und missbraucht, um eine Admin-UI zu erstellen. Als Teil davon benutze ich die schöne SüßerAlert2 Javascript-Bibliothek für meine Bestätigungsdialoge......................................................................................................... Es funktioniert super, aber ich wollte sie auch nutzen, um meine HTMX Ladeindikatoren zu ersetzen.

Dies erwies sich als eine HERAUSFORDERUNG, also dachte ich, ich würde es hier dokumentieren, um Ihnen den gleichen Schmerz zu ersparen.

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

[TOC]

Das Problem

Also ist HTMX sehr clever, es ist hx-indicator Normalerweise können Sie eine Ladeanzeige für Ihre HTMX-Anfragen einstellen. Normalerweise ist dies ein HTML-Element in Ihrer Seite wie


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

Dann, wenn Sie es verwenden möchten, würden Sie Ihre HTMX-Anfrage mit dekorieren hx-indicator="#loading-modal" und es wird zeigen, die Modal, wenn die Anfrage im Gange ist (siehe hier für Details).

Jetzt macht HTMX einige clevere Magie mit einem request Objekt es trackt innerlich

  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
  }

Diese zu ersetzen, ist daher eine Herausforderung. Wie verfolgen Sie die Anfragen und zeigen dann die SweetAlert2 Modal, wenn die Anfrage im Gange ist, und verstecken Sie sie, wenn sie fertig ist.

Eine Lösung

Also habe ich (nicht, weil ich musste, weil ich brauchte :)) die HTMX Ladeanzeige durch einen SweetAlert2 Modal zu ersetzen. Wie auch immer, hier ist der Code, den ich erfunden habe.

Sie würden beginnen, indem Sie entweder SweetAlert2 in Ihrem HTML importieren (als Skript & Stil Tags) / importieren Sie es für Webpack oder ähnliches ((Das Parlament nimmt den Entwurf der legislativen Entschließung an.)

Nach der Installation von npm können Sie es so in Ihre JS-Datei importieren.

import Swal from 'sweetalert2';

Dann sieht mein Hauptcode so aus:

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

Sie konfigurieren dies (wenn Sie ESM verwenden) in Ihrem main.js Datei wie diese


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

Unsere Elemente finden

Du wirst sehen, ich benutze die getIndicatorSource Funktion, um das Element zu finden, das die HTMX-Anfrage ausgelöst hat. Dies ist wichtig, da wir wissen müssen, welches Element die Anfrage ausgelöst hat, damit wir den Modal schließen können, wenn er fertig ist. Dies ist wichtig, da HTMX 'Vererbung' hat, so dass Sie den Baum klettern müssen, um das Element zu finden, das die Anfrage ausgelöst hat.

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

Dann auf jede HTMX-Anfrage (so hx-get oder hx-post) können Sie die hx-indicator Attribut zur Angabe der SweetAlert2-Modalität. Sie müssen nicht einmal die Klasse wie vorher angeben, nur der Parameter vorhanden funktioniert.

Gehen wir durch, wie all das funktioniert:

Ich hänge es mit registerSweetAlertHxIndicator()

Dies gilt als Out-Entry-Point. Sie können sehen, dass es Haken in die htmx:configRequest .......................................................................................................................................... Dies wird abgefeuert, wenn HTMX eine Anfrage stellen wird.

Es erhält dann das Element, das das Ereignis ausgelöst in evt.detail.elt und prüft, ob es eine hx-indicator Attribut.

Schließlich zeigt es die SweetAlert2 Modal mit Swal.fire().

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

Den Request-Pfad finden

Wenn ja, erhält es den Request-Pfad mit getRequestPath(evt.detail) und speichert es im Sitzungsspeicher. Niw HTMX ist ein kniffliger Bugger, er speichert den Pfad an verschiedenen Orten, je nachdem, wo Sie sich im Lebenszyklus befinden. Also in meinem Code mache ich das ganze Above. mit detail?.pathInfo?.path ?? detail?.path ?? '';

Es stellt sich heraus, dass HTMX die Antrag Pfad in detail.path und dem Antwortweg (für document.body.addEventListener('htmx:afterRequest', maybeClose); document.body.addEventListener('htmx:responseError', maybeClose);) in der detail.PathInfo.responsePath Also müssen wir auch beides regeln.

Wir müssen uns auch darum kümmern. GET Form; da ihre Antwort wird wahrscheinlich die URL-Elemente in wie übergeben <input > Werte, so dass die Antwort Url kann sich als anders.

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

HINWEIS: Dies ist besonders der Fall, wenn Sie die HX-Push-Url header, um die URL der Anfrage zu ändern, die HTMX für History speichert.

Das Formular

HttpGet Formulare sind ein wenig schwierig, so haben wir ein Stück Code, die erkennen, wenn Sie geklickt haben eine submit Schaltfläche innerhalb eines Formulars und fügen Sie die Abfrage String-Parameter durch diese lästigen Eingaben verursacht, um mit der Antwort-URL zu vergleichen.

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

    }
}

Diese zweite wird hier behandelt:

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

Speichern des Pfades

Ok, jetzt haben wir den Weg, was machen wir damit? Gut zu verfolgen, welche Anfrage ausgelöst die SweetAlert2 Modal speichern wir es in sessionStorage Verwendung sessionStorage.setItem(SWEETALERT_PATH_KEY, path);.

(Wieder können Sie diese komplexer machen und sicherstellen, dass Sie nur eine haben, wenn Sie brauchen.)

Zeigt den Modal

Dann zeigen wir einfach den SweetAlert2 Modal mit Swal.fire()......................................................................................................... Beachten Sie, dass wir hier eine Reihe von Optionen haben.

Beim Öffnen prüft es nach einem Sitzungsspeicherschlüssel SWEETALERT_HISTORY_RESTORED_KEY die gesetzt wird, wenn die Geschichte wiederhergestellt wird. Wenn ja, schließen wir den Modal sofort (es spart HTMX uns mit seinem ungeraden Historienmanagement durcheinander zu bringen).

Wir feuern auch ein Ereignis sweetalert:opened die Sie verwenden können, um jede benutzerdefinierte Logik zu tun, die Sie brauchen.

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

Zusätzlich setzen wir einen Timeout, um Fälle zu behandeln, in denen die Anfrage hängt. Dies ist wichtig, da HTMX nicht immer den Modal schließt, wenn die Anfrage fehlschlägt (besonders wenn Sie hx-boost)== Einzelnachweise == Das ist hier festgelegt. const SWEETALERT_TIMEOUT_MS = 10000; // Timeout for automatically closing the loader so können wir den Modal schließen, wenn etwas schief geht (es wird auch an der Konsole loggen).

Mach es zu.

Jetzt haben wir also die Modal-Open, wir müssen sie schließen, wenn die Anfrage fertig ist. Um dies zu tun, nennen wir die maybeClose Funktion. Dies wird aufgerufen, wenn die Anfrage beendet ist (entweder erfolgreich oder mit einem Fehler). Verwendung htmx:afterRequest und htmx:responseError Veranstaltungen. Diese Ereignisse feuern, sobald HTMX eine Anfrage abgeschlossen hat (Anm., diese sind wichtig, speziell für HX-Boost was ein bisschen lustig über die Ereignisse sein kann, die es feuert.)

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

Sie werden sehen, dass diese Funktion überprüft, ob der Pfad im Sitzungsspeicher der gleiche ist wie der Pfad der Anfrage. Wenn ja, schließt es den Modal und entfernt den Pfad aus dem Sitzungsspeicher.

Das ist Geschichte.

HTMX hat eine fiddly Weise des Umgangs mit Geschichte, die das modale 'Stuck' offen lassen könnte, wenn sie eine Rückseite tut. So fügen wir ein paar weitere Ereignisse hinzu, um dies zu fangen (die meiste Zeit brauchen wir nur eins, aber Gürtel & Zahnspangen).

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

Sie werden sehen, dass wir auch die sessionStorage.setItem(SWEETALERT_HISTORY_RESTORED_KEY, 'true'); die wir in der didOpen Ereignis:

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

Wir tun es in dem Fall, da der Modal nicht alwasy öffnet sofort auf popstate \ htmx:historyRestore (vor allem, wenn Sie eine Menge Geschichte haben). Also müssen wir es in der didOpen Event (daher ist es in Sitzungsschlüssel, manchmal kann dies neu laden etc... also müssen wir uns dessen bewusst sein).

Schlussfolgerung

So können Sie also SweetAlert2 als HTMX-Ladeanzeige verwenden. Es ist ein bisschen ein Hack, aber es funktioniert und es ist eine schöne Möglichkeit, die gleiche Bibliothek für das Laden von Indikatoren und Bestätigungsdialoge zu verwenden.9

logo

©2024 Scott Galloway