Convert htmx-redirect-toast.js to TS; remove dead legacy utils.js (issue #17)
- Add ts/htmx-redirect-toast.ts: typed port of the hx-redirect-toast htmx extension. Stays a classic (non-module) script — only touches the global htmx and registers an extension; layout.py now serves dist/htmx-redirect-toast.js - Delete games/static/js/utils.js: the legacy hand-written copy is dead — every compiled module imports dist/utils.js (from ts/utils.ts); nothing references the old path With this, the only first-party JS served is compiled from ts/; the sole remaining hand-written .js in static is the vendored datepicker.umd.js bundle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -337,7 +337,7 @@ def Page(
|
|||||||
" htmx.config.scrollBehavior = 'smooth';\n"
|
" htmx.config.scrollBehavior = 'smooth';\n"
|
||||||
" htmx.config.selfRequestsOnly = false;\n"
|
" htmx.config.selfRequestsOnly = false;\n"
|
||||||
" </script>\n"
|
" </script>\n"
|
||||||
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n'
|
f' <script src="{static("js/dist/htmx-redirect-toast.js")}"></script>\n'
|
||||||
f" {django_htmx_script(nonce=None)}\n"
|
f" {django_htmx_script(nonce=None)}\n"
|
||||||
f' <link rel="stylesheet" href="{static("base.css")}" />\n'
|
f' <link rel="stylesheet" href="{static("base.css")}" />\n'
|
||||||
# Vendored bundles (flowbite 2.4.1, alpinejs/@alpinejs/mask 3.15.12) —
|
# Vendored bundles (flowbite 2.4.1, alpinejs/@alpinejs/mask 3.15.12) —
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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 <script> like the other vendored-adjacent glue.
|
||||||
|
*/
|
||||||
|
declare const htmx: any;
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
htmx.defineExtension("hx-redirect-toast", {
|
||||||
|
isInlineSwap(swapStyle: string): boolean {
|
||||||
|
return swapStyle === "hx-redirect-toast";
|
||||||
|
},
|
||||||
|
handleSwap(
|
||||||
|
swapStyle: string,
|
||||||
|
target: HTMLElement,
|
||||||
|
fragment: Node,
|
||||||
|
settleInfo: unknown,
|
||||||
|
htmxConfig: { xhr: XMLHttpRequest }
|
||||||
|
): null {
|
||||||
|
const xhr = htmxConfig.xhr;
|
||||||
|
const hxRedirect = xhr.getResponseHeader("HX-Redirect");
|
||||||
|
const 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) {
|
||||||
|
const triggers = JSON.parse(hxTrigger);
|
||||||
|
const events = Array.isArray(triggers) ? triggers : [triggers];
|
||||||
|
events.forEach((triggerObject: Record<string, unknown>) => {
|
||||||
|
Object.entries(triggerObject).forEach(([name, rawDetail]) => {
|
||||||
|
let detail: unknown = rawDetail;
|
||||||
|
try {
|
||||||
|
detail = JSON.parse(rawDetail as string);
|
||||||
|
} catch {
|
||||||
|
// keep as-is
|
||||||
|
}
|
||||||
|
target.dispatchEvent(
|
||||||
|
new CustomEvent(name, {
|
||||||
|
detail,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Return null to prevent any DOM swap
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user