Back to "Використання SweetAlert2 для індикаторів завантаження HTMX (hx- indicator)"

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

Використання SweetAlert2 для індикаторів завантаження HTMX (hx- indicator)

Monday, 21 April 2025

Вступ

В проекте, который я использовал и изучал HTMX для построения административного инспектора. В рамках цього я використовую прекрасні SweetAlert2 Бібліотека Javascript за моїми діалоговими вікнами підтвердження. Це чудово працює, але я також хотів використати їх для заміни моїх показників завантаження HTMX.

Это оказалось проблемой, и я решила документировать его здесь, чтобы спасти тебя от того же боли.

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

[TOC]

Проблема

Отже, HTMX дуже розумний, це hx-indicator Зазвичай, надає вам змогу встановити індикатор завантаження для ваших запитів HTMX. Ordinidial this is an HTML element in your page like


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

Потім, якщо ви хочете використовувати його, ви б прикрасили ваш запит HTMX за допомогою hx-indicator="#loading-modal" і покаже модуль, коли виконується запит (тут наведено подробиці).

Тепер HTMX робить деякі розумні чари, використовуючи a request об' єкт і слідує за внутрішнім

  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
  }

Отже, це трохи складно. Визначає спосіб, у який ви слідкуєте за запитами, а потім показуєте модуль SweetAlert2, коли виконується запит і ховає його після завершення роботи.

Розв'язання

Отже, я почав (не тому, що я мусив, бо мені було потрібно:) замінити навантажувальний індикатор HTMX на SweetAlert2 модуль. У будь-якому разі, ось код, який я придумав.

Ви можете запустити або імпортуванням SweetAlert2 у ваш HTML (як теґи скриптів і стилів) / імпортуванням їх для webpack або подібним (Дивіться на їхні документи про це).

Після встановлення npm ви можете імпортувати його таким чином до вашого файла JS.

import Swal from 'sweetalert2';

Тоді мій головний код виглядає так:

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

Ви налаштуєте це (якщо використовуєте ESM) у вашій системі main.js файл, схожий на цей


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

Знаходження наших елементів

Ви побачите, що я використовую getIndicatorSource function to find the елемента, який увімкнув запит HTMX. Це важливо, оскільки нам потрібно знати, який елемент активував запит, щоб ми могли закрити модуль, коли він закінчений. Це важливо, оскільки HTMX має "спадщину," тому вам потрібно піднятися на дерево, щоб знайти елемент, який викликав запит.

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

Потім за будь- яким запитом HTMX ( so) hx-get або hx-post) ви можете використовувати hx-indicator атрибут визначення модуля SweetAlert2. Вам навіть не потрібно вказувати клас, як раніше, просто параметр, що вже існує.

Давайте розглянемо, як це все працює:

Заряджаючи його registerSweetAlertHxIndicator()

Це діє як вхідна точка. Ви можете побачити, як це зв'язується в htmx:configRequest Замечательно. Його запущено, коли HTMX збирається зробити запит.

Потім він отримує елемент, який активував подію в evt.detail.elt і перевіряти, чи має він hx-indicator атрибут.

Нарешті, тут показано модуль SweetAlert2 з використанням Swal.fire().

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

Отримання шляху запиту

Якщо буде позначено цей пункт, програма отримуватиме адресу запиту за допомогою getRequestPath(evt.detail) і зберігати його у сховищі сеансів. Niw HTMX - хитра штука, вона зберігає шлях у різних місцях залежно від того, де ви знаходитесь на життєвому циклі. Отже, в моєму коді я роблю все, що хочу. на detail?.pathInfo?.path ?? detail?.path ?? '';

Виявляється, що HTMX зберіг запит шлях у detail.path і шлях відповіді (на document.body.addEventListener('htmx:afterRequest', maybeClose); document.body.addEventListener('htmx:responseError', maybeClose);) в detail.PathInfo.responsePath так что нам нужно слишком уладить обеих.

Нам також треба впоратися з цим. GET форми; оскільки їх відповідь, ймовірно, міститиме елементи URL, які передаються як <input > Значення так, щоб адреса відповіді могла бути іншою.

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

ЩО МОЖНА ЗРОБИТИ. HX-Push-Url заголовок для зміни адреси URL запиту, який HTMX зберігає для журналу.

Форма

HttpGet форми трохи складні, так що ми маємо шматок коду, який буде виявлятися, якщо ви натиснули submit button всередині форми і додати параметри рядка запиту, які спричиняються цими песиними входами для порівняння з адресою URL відповіді.

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

    }
}

Ось ще один.

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

Стеж за стежкою

Отже, тепер у нас є шлях, що з ним робити? Для того, щоб стежити за тим, у якому запиті було увімкнено модуль SweetAlert2, ми зберігаємо його у sessionStorage за допомогою sessionStorage.setItem(SWEETALERT_PATH_KEY, path);.

(Знову ви можете зробити це більш складним і запевнити, що маєте його лише в разі потреби).

Показ модуля

Ми просто показуємо модаль SweetAlert2 за допомогою Swal.fire(). Помітьте, у нас є багато варіантів.

Після відкриття програма перевіряє наявність ключа сховища сеансу SWEETALERT_HISTORY_RESTORED_KEY який буде встановлено, коли буде відновлено історію. Якщо це так, ми негайно закриваємо модуль (це зберігає HTMX перемішуючи нас з дивним управлінням історії).

Ми також активуємо події. sweetalert:opened яку можна використати для виконання будь-якої потрібної вам логіки.

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

Крім того, ми встановили час, коли буде розв'язуватися справи, коли прохання вішає. Це важливо, оскільки HTMX не завжди закриває модуль, якщо запит зазнає невдачі (особливо, якщо ви використовуєте hx-boost). Це множина. const SWEETALERT_TIMEOUT_MS = 10000; // Timeout for automatically closing the loader так що ми можемо закрити модуль, якщо щось піде не так (також буде включено до консолі).

Закриття

Тепер ми маємо відкрити модуль, нам потрібно закрити його, коли прохання буде завершено. Для цього ми називаємо maybeClose функція. Цей пункт викликається під час завершення запиту (вдалого або з помилкою). Користування htmx:afterRequest і htmx:responseError події. Ці події спалахують після того, як HTMX завершив запит (зауважте, що ці події важливі, а особливо для HX-Boost що може бути трохи смішним про те які події воно розпалює.)

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

Ви побачите, що ця функція перевіряє, чи є шлях у сховищі сеансів тим самим, що і шлях запиту. Якщо буде позначено цей пункт, програма закриє модуль і вилучить шлях до нього зі сховища сеансів.

Це історія

У HTMX є непоганий спосіб обробки історії, який може залишити модуль відкритим під час виконання резервної сторінки. Отже, ми додаємо ще декілька подій, щоб їх вловити (більшого часу нам потрібно лише одну, окрім & підготівки пояса).

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

Ви побачите, що ми також встановлюємо sessionStorage.setItem(SWEETALERT_HISTORY_RESTORED_KEY, 'true'); яку ми перевіряємо в didOpen подія:

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

Ми робимо це в випадку, коли модал не буде відкрито одразу popstate \ htmx:historyRestore (особливо, якщо у вас багато історії). Отже, ми повинні перевірити це в didOpen подія (якщо вона є у ключі сеансу, іноді така дія може перезавантажити тощо... отже, ми повинні знати про це).

BONUS - для мого PachingTagHelper

Якщо ти користуєшся моєю PachingTagHelp є проблема з PageSize і використання SweetAlert; все це через ТАКОЖ з' єднання htmx: configRequest} подія. Тому нам потрібно додати маленький код, щоб переконатися, що він не суперечить модалі 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;
})();

Щоб виправити це, вам слід додати невеличку функцію:

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

Це просто отримує запит і додає pagesize опублікувати його. Потім імпортувати цю функцію

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

Тут ви можете побачити, що ми виявляємо pageSize увімкнути і під час його пошуку (отже, ви змінили розмір сторінки) програма використовує getPathWithPageSize функція для отримання нового шляху. The pageSize Код замінить його пізніше у трубопроводі, але це дозволить йому правильно виявити, щоб закрити, як очікувалося.

Включення

Ось так можна використовувати SweetAlert2 як індикатор завантаження HTMX. Це трохи злом, але це працює і це хороший спосіб використовувати однакову бібліотеку для завантаження індикаторів і діалогових вікон підтвердження.9

logo

©2024 Scott Galloway