Använda SweetAlert2 för HTMX Loading indikatorer (hx-indikator) (Svenska (Swedish))

Använda SweetAlert2 för HTMX Loading indikatorer (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

Inledning

På ett arbete projekt jag har använt och missbrukar HTMX för att bygga en admin UI. Som en del av detta använder jag den underbara SötaAlert2 Javascript- bibliotek för mina bekräftelsedialogrutor....................................... Det fungerar bra men jag ville också använda dem för att byta ut mina HTMX belastningsindikatorer.

Detta visade sig vara en UTMANING så jag tänkte dokumentera det här för att bespara dig samma smärta.

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

[TOC]

Problemet

Så HTMX är väldigt smart, det är hx-indicator normalt låter dig ställa in en belastningsindikator för dina HTMX-förfrågningar. Vanligtvis är detta ett HTML-element på din sida som


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

När du sedan vill använda den skulle du dekorera din HTMX-förfrågan med hx-indicator="#loading-modal" och det kommer att visa modalen när begäran pågår (se här för mer information).

Nu HTMX gör några smarta magi med hjälp av en request objekt Det spårar internt

  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
  }

Därför är det en utmaning att ersätta dessa. Hur spårar du förfrågningar och sedan visa SweetAlert2 modal när begäran pågår och dölja den när den är klar.

En lösning

Så jag satte om (inte för att jag var tvungen, eftersom jag behövde :)) att ersätta HTMX lastning indikator med en SweetAlert2 modal. Här är koden jag kom på.

Du skulle börja med att antingen importera SweetAlert2 i din HTML (som skript och stil taggar) / importera det för webpack eller liknande (se deras läkare för detta).

Efter att npm installerat den kan du importera den så här i din JS-fil.

import Swal from 'sweetalert2';

Då ser min huvudkod ut så här:

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

Du konfigurerar detta (om du använder ESM) i din main.js fil som den här


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

Hitta våra element

Du kommer att se att jag använder getIndicatorSource Funktion för att hitta det element som utlöste HTMX-begäran. Detta är viktigt eftersom vi behöver veta vilket element som utlöste begäran så att vi kan stänga modalen när den är klar. Detta är viktigt eftersom HTMX har "ärvande" så du måste klättra upp i trädet för att hitta det element som utlöste begäran.

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

Sedan på någon HTMX begäran (så hx-get eller hx-post) du kan använda hx-indicator attribut för att ange SweetAlert2-modalen. Du behöver inte ens ange klassen som tidigare, bara parametern befintliga verk.

Låt oss gå igenom hur allt detta fungerar:

Att koppla ihop det med registerSweetAlertHxIndicator()

Detta fungerar som en ingångspunkt. Du kan se det krokar in i htmx:configRequest händelse. Detta avfyras när HTMX är på väg att göra en begäran.

Det får sedan elementet som utlöste händelsen i evt.detail.elt och kontrollera om den har en hx-indicator Egenskap.

Slutligen visar den SweetAlert2 modal med hjälp av Swal.fire().

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

Hämtar sökvägen för begäran

Om det gör det, det får begäran sökväg med hjälp av getRequestPath(evt.detail) och förvarar den i sessionsförvaring. Niw HTMX är en knepig jävel, den lagrar vägen på olika platser beroende på var du befinner dig i livscykeln. Så i min kod gör jag alla fel. där detail?.pathInfo?.path ?? detail?.path ?? '';

Det visade sig att HTMX lagrade begäran sökväg in detail.path och svarsvägen (för document.body.addEventListener('htmx:afterRequest', maybeClose); document.body.addEventListener('htmx:responseError', maybeClose);) för detail.PathInfo.responsePath Så vi behöver också hantera båda.

Vi måste också hantera GET formulär; eftersom deras svar sannolikt kommer att innehålla URL-element som skickas in som <input > värden så att svaret url kan sluta vara annorlunda.

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

ANMÄRKNING: Detta är särskilt fallet om du använder HX-Push-Url header för att ändra webbadressen för begäran som HTMX lagrar för historik.

Formuläret

HttpGet blanketter är lite knepiga så vi har en kod som kommer att upptäcka om du har klickat på en submit knapp inne i ett formulär och lägg till söksträngsparametrarna som orsakas av dessa pesky-ingångar för att jämföra med svarswebbadressen.

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

    }
}

Denna andra handhas här:

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

Lagra sökvägen

Okej, nu har vi vägen, vad gör vi med den? Väl att hålla reda på vilken begäran utlöste SweetAlert2 modal vi lagrar den i sessionStorage användning sessionStorage.setItem(SWEETALERT_PATH_KEY, path);.

(Återigen kan du göra detta mer komplicerat och se till att du bara har en om du behöver.)

Visa modal

Vi visar sedan bara SweetAlert2 modal med hjälp av Swal.fire()....................................... Notera att vi har en massa alternativ här.

När den öppnas kontrollerar den för en sessionslagringsnyckel SWEETALERT_HISTORY_RESTORED_KEY vilket är bestämt när historien är återställd. Om det är det, stänger vi modalen omedelbart (det sparar HTMX förstöra oss med det är udda historiehantering).

Vi avfyrar också en händelse sweetalert:opened som du kan använda för att göra någon anpassad logik du behöver.

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

Dessutom sätter vi en tidsgräns för att hantera ärenden där begäran hänger. Detta är viktigt eftersom HTMX inte alltid stänger modalen om begäran misslyckas (särskilt om du använder hx-boost).............................................................................................. Det här är satt här const SWEETALERT_TIMEOUT_MS = 10000; // Timeout for automatically closing the loader så att vi kan stänga modalen om något går fel (det kommer också att logga till konsolen).

Stänga av den

Så nu har vi modalen öppen, vi måste stänga den när begäran är klar. För att göra detta kallar vi maybeClose Funktion. Detta kallas när begäran är klar (antingen med lyckat resultat eller med ett fel). Användning htmx:afterRequest och htmx:responseError händelser. Dessa händelser brand när HTMX har avslutat en begäran (observera, dessa är viktiga, especialy för HX-Boost vilket kan vara lite roligt om vilka händelser den skjuter.)

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

Du kommer att se denna funktion kontrollera om sökvägen i sessionslagringen är densamma som sökvägen för begäran. Om den är det, stänger den modalen och tar bort sökvägen från sessionslagringen.

Det är historia.

HTMX har ett fiffigt sätt att hantera historik som kan lämna modalen "stuck" öppen på att göra en bakgrundssida. Så vi lägger till ett par fler händelser för att fånga detta (för det mesta behöver vi bara en men bälte & tandställning).

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

Du kommer att se att vi också ställa in sessionStorage.setItem(SWEETALERT_HISTORY_RESTORED_KEY, 'true'); som vi kontrollerar i didOpen händelse:

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

Vi gör det i händelse som modalen inte alwasy öppna omedelbart på popstate \ htmx:historyRestore (speciellt om du har en hel del historia). Så vi måste kolla upp det i didOpen händelse (därav att det är i sessionsnyckel, ibland kan detta ladda om etc... så vi måste vara medvetna om det).

Slutsatser

Så det är så du kan använda SweetAlert2 som en HTMX-inmatningsindikator. Det är lite av en hacka men det fungerar och det är ett trevligt sätt att använda samma bibliotek för både lastning indikatorer och bekräftelse dialogrutor.9

logo

©2024 Scott Galloway