diff --git a/common/layout.py b/common/layout.py index b9c39bb..81f5c74 100644 --- a/common/layout.py +++ b/common/layout.py @@ -337,7 +337,7 @@ def Page( " htmx.config.scrollBehavior = 'smooth';\n" " htmx.config.selfRequestsOnly = false;\n" " \n" - f' \n' + f' \n' f" {django_htmx_script(nonce=None)}\n" f' \n' # Vendored bundles (flowbite 2.4.1, alpinejs/@alpinejs/mask 3.15.12) — diff --git a/games/static/js/htmx-redirect-toast.js b/games/static/js/htmx-redirect-toast.js deleted file mode 100644 index fb0eab1..0000000 --- a/games/static/js/htmx-redirect-toast.js +++ /dev/null @@ -1,37 +0,0 @@ -(function() { - htmx.defineExtension("hx-redirect-toast", { - isInlineSwap: function(swapStyle) { - return swapStyle === "hx-redirect-toast"; - }, - handleSwap: function(swapStyle, target, fragment, settleInfo, htmxConfig) { - var xhr = htmxConfig.xhr; - var hxRedirect = xhr.getResponseHeader("HX-Redirect"); - var hxTrigger = xhr.getResponseHeader("HX-Trigger"); - - // Redirect immediately (toast will be shown on the new page) - if (hxRedirect) { - window.location.href = hxRedirect; - } - - // Only dispatch HX-Trigger events for toasts when not redirecting - if (!hxRedirect && hxTrigger) { - var triggers = JSON.parse(hxTrigger); - var events = Array.isArray(triggers) ? triggers : [triggers]; - events.forEach(function(triggerObj) { - Object.entries(triggerObj).forEach(function(entry) { - var name = entry[0]; - var detail = entry[1]; - try { detail = JSON.parse(detail); } catch(e) {} - target.dispatchEvent(new CustomEvent(name, { - detail: detail, - bubbles: true, - cancelable: true - })); - }); - }); - } - // Return null to prevent any DOM swap - return null; - } - }); -})(); diff --git a/games/static/js/utils.js b/games/static/js/utils.js deleted file mode 100644 index 82f9d15..0000000 --- a/games/static/js/utils.js +++ /dev/null @@ -1,238 +0,0 @@ -/** - * @description Runs initializeElement once for each element matching selector, - * on initial page load and inside every htmx-swapped fragment (a port of - * FastHTML's proc_htmx). htmx fires htmx:load for the initial document and for - * each swapped-in element, so a single registration covers both; the WeakSet - * guarantees once-per-element initialization, replacing the old - * DOMContentLoaded + htmx:afterSwap + per-element guard-flag pattern. - * @param {string} selector - * @param {function(Element): void} initializeElement - */ -function onSwap(selector, initializeElement) { - const initialized = new WeakSet(); - htmx.onLoad((swappedElement) => { - const elements = Array.from(htmx.findAll(swappedElement, selector)); - if (swappedElement.matches && swappedElement.matches(selector)) { - elements.unshift(swappedElement); - } - for (const element of elements) { - if (initialized.has(element)) continue; - initialized.add(element); - initializeElement(element); - } - }); -} - -/** - * @description Formats Date to a UTC string accepted by the datetime-local input field. - * @param {Date} date - * @returns {string} - */ -function toISOUTCString(date) { - function stringAndPad(number) { - return number.toString().padStart(2, 0); - } - const year = date.getFullYear(); - const month = stringAndPad(date.getMonth() + 1); - const day = stringAndPad(date.getDate()); - const hours = stringAndPad(date.getHours()); - const minutes = stringAndPad(date.getMinutes()); - return `${year}-${month}-${day}T${hours}:${minutes}`; -} - -/** - * @description Sync values between source and target elements based on syncData configuration. - * @param {Array} syncData - Array of objects to define source and target elements with their respective value types. - */ -function syncSelectInputUntilChanged(syncData, parentSelector = document) { - const parentElement = - parentSelector === document - ? document - : document.querySelector(parentSelector); - - if (!parentElement) { - console.error(`The parent selector "${parentSelector}" is not valid.`); - return; - } - // Set up a single change event listener on the document for handling all source changes - parentElement.addEventListener("change", function (event) { - // Loop through each sync configuration item - syncData.forEach((syncItem) => { - // Check if the change event target matches the source selector - if (event.target.matches(syncItem.source)) { - const sourceElement = event.target; - const valueToSync = getValueFromProperty( - sourceElement, - syncItem.source_value - ); - const targetElement = document.querySelector(syncItem.target); - - if (targetElement && valueToSync !== null) { - console.log(`Changing value of ${syncItem.target} to ${valueToSync}`) - targetElement[syncItem.target_value] = valueToSync; - } - } - }); - }); - - // Set up a single focus event listener on the document for handling all target focuses - parentElement.addEventListener( - "focus", - function (event) { - // Loop through each sync configuration item - syncData.forEach((syncItem) => { - // Check if the focus event target matches the target selector - if (event.target.matches(syncItem.target)) { - // Remove the change event listener to stop syncing - // This assumes you want to stop syncing once any target receives focus - // You may need a more sophisticated way to remove listeners if you want to stop - // syncing selectively based on other conditions - document.removeEventListener("change", syncSelectInputUntilChanged); - } - }); - }, - true - ); // Use capture phase to ensure the event is captured during focus, not bubble -} - -/** - * @description Retrieve the value from the source element based on the provided property. - * @param {Element} sourceElement - The source HTML element. - * @param {string} property - The property to retrieve the value from. - */ -function getValueFromProperty(sourceElement, property) { - let source = - sourceElement instanceof HTMLSelectElement - ? sourceElement.selectedOptions[0] - : sourceElement; - if (property.startsWith("dataset.")) { - let datasetKey = property.slice(8); // Remove 'dataset.' part - return source.dataset[datasetKey]; - } else if (property in source) { - return source[property]; - } else { - console.error(`Property ${property} is not valid for the option element.`); - return null; - } -} - -/** - * @description Returns a single element by name. - * @param {string} selector The selector to look for. - */ -function getEl(selector) { - if (selector.startsWith("#")) { - return document.getElementById(selector.slice(1)); - } else if (selector.startsWith(".")) { - return document.getElementsByClassName(selector); - } else { - return document.getElementsByTagName(selector); - } -} - -/** - * @description Applies different behaviors to elements based on multiple conditional configurations. - * Each configuration is an array containing a condition function, an array of target element selectors, - * and two callback functions for handling matched and unmatched conditions. - * @param {...Array} configs Each configuration is an array of the form: - * - 0: {function(): boolean} condition - Function that returns true or false based on a condition. - * - 1: {string[]} targetElements - Array of CSS selectors for target elements. - * - 2: {function(HTMLElement): void} callbackfn1 - Function to execute when condition is true. - * - 3: {function(HTMLElement): void} callbackfn2 - Function to execute when condition is false. - */ -function conditionalElementHandler(...configs) { - configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => { - if (condition()) { - targetElements.forEach((elementName) => { - let el = getEl(elementName); - if (el === null) { - console.error(`Element ${elementName} doesn't exist.`); - } else { - callbackfn1(el); - } - }); - } else { - targetElements.forEach((elementName) => { - let el = getEl(elementName); - if (el === null) { - console.error(`Element ${elementName} doesn't exist.`); - } else { - callbackfn2(el); - } - }); - } - }); -} - -function disableElementsWhenValueNotEqual( - targetSelect, - targetValue, - elementList -) { - return conditionalElementHandler([ - () => { - let target = getEl(targetSelect); - console.debug( - `${disableElementsWhenTrue.name}: triggered on ${target.id}` - ); - console.debug(` - ${disableElementsWhenTrue.name}: matching against value(s): ${targetValue}`); - if (targetValue instanceof Array) { - if (targetValue.every((value) => target.value != value)) { - console.debug( - `${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.` - ); - return true; - } - } else { - console.debug( - `${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.` - ); - return target.value != targetValue; - } - }, - elementList, - (el) => { - console.debug( - `${disableElementsWhenTrue.name}: evaluated true, disabling ${el.id}.` - ); - el.disabled = "disabled"; - }, - (el) => { - console.debug( - `${disableElementsWhenTrue.name}: evaluated false, NOT disabling ${el.id}.` - ); - el.disabled = ""; - }, - ]); -} - -function disableElementsWhenTrue(targetSelect, targetValue, elementList) { - return conditionalElementHandler([ - () => { - console.log(`${disableElementsWhenTrue.name}: triggered on ${targetSelect}`) - console.log(`Value of ${targetSelect} is ${targetValue}: ${getEl(targetSelect).value == targetValue}`) - return getEl(targetSelect).value == targetValue; - }, - elementList, - (el) => { - console.log(`${disableElementsWhenTrue.name}: disabling ${el.id}`) - el.disabled = "disabled"; - }, - (el) => { - console.log(`${disableElementsWhenTrue.name}: enabling ${el.id}`) - el.disabled = ""; - }, - ]); -} - -export { - onSwap, - toISOUTCString, - syncSelectInputUntilChanged, - getEl, - conditionalElementHandler, - disableElementsWhenValueNotEqual, - disableElementsWhenTrue, - getValueFromProperty, -}; diff --git a/ts/htmx-redirect-toast.ts b/ts/htmx-redirect-toast.ts new file mode 100644 index 0000000..1413250 --- /dev/null +++ b/ts/htmx-redirect-toast.ts @@ -0,0 +1,60 @@ +/** + * htmx "hx-redirect-toast" extension. + * + * A custom swap style that performs no DOM swap. On an HX-Redirect response it + * navigates immediately (the toast shows on the destination page); otherwise it + * turns the HX-Trigger header into CustomEvents so toasts fire in place. + * + * Classic (non-module) script: it only touches the global htmx and registers an + * extension, so it stays a plain