Convert toast.js to TypeScript (issue #17)

- Add ts/toast.ts: typed port of the Alpine toast store + window.toast +
  window.fetchWithHtmxTriggers. Toast / ToastStore / ToastMessage interfaces
  type the store and the show-toast CustomEvent detail; Alpine declared as a
  type-only ambient global
- Declare window.toast in ts/globals.d.ts
- Stays a classic (non-module) script — no import/export — so it keeps defining
  globals; layout.py now serves dist/toast.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 13:58:43 +02:00
parent 19e9fd1419
commit 1decf588c1
3 changed files with 80 additions and 44 deletions
+1 -1
View File
@@ -364,7 +364,7 @@ def Page(
" <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n" " <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n"
' <div id="global-modal-container" hx-swap-oob="true"></div>\n' ' <div id="global-modal-container" hx-swap-oob="true"></div>\n'
f" {_TOAST_CONTAINER}\n" f" {_TOAST_CONTAINER}\n"
f' <script src="{static("js/toast.js")}"></script>\n' f' <script src="{static("js/dist/toast.js")}"></script>\n'
" </body>\n</html>\n" " </body>\n</html>\n"
) )
+1
View File
@@ -3,6 +3,7 @@ export {};
declare global { declare global {
interface Window { interface Window {
fetchWithHtmxTriggers(input: RequestInfo | URL, init?: RequestInit): Promise<Response>; fetchWithHtmxTriggers(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
toast(message: string, type?: string): void;
readSearchSelect(form: HTMLElement): void; readSearchSelect(form: HTMLElement): void;
applyFilterBar(event: Event): boolean; applyFilterBar(event: Event): boolean;
clearFilterBar(formId: string, filterInputId: string): void; clearFilterBar(formId: string, filterInputId: string): void;
+78 -43
View File
@@ -1,12 +1,36 @@
declare const Alpine: any;
interface Toast {
id: number;
message: string;
type: string;
visible: boolean;
timer: ReturnType<typeof setTimeout> | null;
pausedAt: number | null;
}
interface ToastStore {
toasts: Toast[];
addToast(message: string, type?: string): void;
dismissToast(id: number): void;
clearToastTimer(id: number): void;
resumeToastTimer(id: number, duration: number): void;
}
interface ToastMessage {
message: string;
type?: string;
}
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
let idCounter = 0; let idCounter = 0;
console.log("[toast] Alpine available:", typeof Alpine !== "undefined"); console.log("[toast] Alpine available:", typeof Alpine !== "undefined");
Alpine.store("toasts", { const store: ToastStore = {
toasts: [], toasts: [],
addToast(message, type) { addToast(message: string, type?: string) {
console.log("[toast] addToast called:", { message, type }); console.log("[toast] addToast called:", { message, type });
if (!type) type = "info"; if (!type) type = "info";
const validTypes = ["success", "error", "info", "warning", "debug"]; const validTypes = ["success", "error", "info", "warning", "debug"];
@@ -25,29 +49,32 @@ document.addEventListener("alpine:init", () => {
const toast = this.toasts[this.toasts.length - 1]; const toast = this.toasts[this.toasts.length - 1];
const autoDismissDelay = type === "debug" ? 3000 : 5000; const autoDismissDelay = type === "debug" ? 3000 : 5000;
toast.timer = setTimeout(() => { toast.timer = setTimeout(() => {
console.log("[toast] auto-dismiss after " + (autoDismissDelay / 1000) + "s"); console.log("[toast] auto-dismiss after " + autoDismissDelay / 1000 + "s");
this.dismissToast(id); this.dismissToast(id);
}, autoDismissDelay); }, autoDismissDelay);
} }
}, },
dismissToast(id) { dismissToast(id: number) {
console.log("[toast] dismissToast for id:", id); console.log("[toast] dismissToast for id:", id);
const idx = this.toasts.findIndex((t) => t.id === id); const index = this.toasts.findIndex((toast) => toast.id === id);
if (idx === -1) { console.log("[toast] toast not found"); return; } if (index === -1) {
console.log("[toast] toast not found");
return;
}
const toast = this.toasts[idx]; const toast = this.toasts[index];
if (toast.timer) clearTimeout(toast.timer); if (toast.timer) clearTimeout(toast.timer);
toast.visible = false; toast.visible = false;
setTimeout(() => { setTimeout(() => {
this.toasts = this.toasts.filter((t) => t.id !== id); this.toasts = this.toasts.filter((toast) => toast.id !== id);
console.log("[toast] after dismiss, count:", this.toasts.length); console.log("[toast] after dismiss, count:", this.toasts.length);
}, 300); }, 300);
}, },
clearToastTimer(id) { clearToastTimer(id: number) {
const toast = this.toasts.find((t) => t.id === id); const toast = this.toasts.find((toast) => toast.id === id);
if (toast?.timer) { if (toast?.timer) {
console.log("[toast] pause timer for toast id:", id); console.log("[toast] pause timer for toast id:", id);
clearTimeout(toast.timer); clearTimeout(toast.timer);
@@ -56,8 +83,8 @@ document.addEventListener("alpine:init", () => {
} }
}, },
resumeToastTimer(id, duration) { resumeToastTimer(id: number, duration: number) {
const toast = this.toasts.find((t) => t.id === id); const toast = this.toasts.find((toast) => toast.id === id);
if (toast?.pausedAt && toast.timer === null) { if (toast?.pausedAt && toast.timer === null) {
console.log("[toast] resume timer for toast id:", id); console.log("[toast] resume timer for toast id:", id);
toast.timer = setTimeout(() => { toast.timer = setTimeout(() => {
@@ -66,64 +93,67 @@ document.addEventListener("alpine:init", () => {
toast.pausedAt = null; toast.pausedAt = null;
} }
}, },
}); };
Alpine.store("toasts", store);
Alpine.data("toastStore", () => ({ Alpine.data("toastStore", () => ({
init() { init() {
console.log("[toast] toastStore.init running"); console.log("[toast] toastStore.init running");
console.log("[toast] Alpine store toasts:", Alpine.store("toasts").toasts); console.log("[toast] Alpine store toasts:", Alpine.store("toasts").toasts);
window.addEventListener("show-toast", (e) => { window.addEventListener("show-toast", (event) => {
console.log("[toast] show-toast event received:", e.detail); const detail = (event as CustomEvent<ToastMessage | ToastMessage[]>).detail;
if (Array.isArray(e.detail)) { console.log("[toast] show-toast event received:", detail);
e.detail.forEach((msg) => { if (Array.isArray(detail)) {
Alpine.store("toasts").addToast(msg.message, msg.type); detail.forEach((message) => {
Alpine.store("toasts").addToast(message.message, message.type);
}); });
} else { } else {
Alpine.store("toasts").addToast(e.detail.message, e.detail.type); Alpine.store("toasts").addToast(detail.message, detail.type);
} }
}); });
try { try {
const script = document.getElementById("django-messages"); const script = document.getElementById("django-messages");
if (script) { if (script) {
const msgs = JSON.parse( const messages: ToastMessage[] = JSON.parse(
script.textContent || script.innerText || "[]" script.textContent || (script as HTMLElement).innerText || "[]"
); );
console.log("[toast] django-messages script found:", msgs); console.log("[toast] django-messages script found:", messages);
if (Array.isArray(msgs)) { if (Array.isArray(messages)) {
msgs.forEach((msg) => { messages.forEach((message) => {
console.log("[toast] loading django-message:", msg); console.log("[toast] loading django-message:", message);
Alpine.store("toasts").addToast(msg.message, msg.type || "info"); Alpine.store("toasts").addToast(message.message, message.type || "info");
}); });
} }
} }
} catch (e) { } catch (error) {
console.error("[toast] localStorage restore failed:", e); console.error("[toast] localStorage restore failed:", error);
// ignore parse errors // ignore parse errors
} }
}, },
addToast(message, type) { addToast(message: string, type?: string) {
console.log("[toast] toastStore.addToast delegating:", { message, type }); console.log("[toast] toastStore.addToast delegating:", { message, type });
Alpine.store("toasts").addToast(message, type); Alpine.store("toasts").addToast(message, type);
}, },
dismissToast(id) { dismissToast(id: number) {
console.log("[toast] toastStore.dismissToast delegating:", id); console.log("[toast] toastStore.dismissToast delegating:", id);
Alpine.store("toasts").dismissToast(id); Alpine.store("toasts").dismissToast(id);
}, },
})); }));
}); });
function toast(message, type) { function toast(message: string, type?: string): void {
console.log("[toast] toast() called:", { message, type }); console.log("[toast] toast() called:", { message, type });
const evt = new CustomEvent("show-toast", { const event = new CustomEvent("show-toast", {
detail: { message, type }, detail: { message, type },
bubbles: true, bubbles: true,
}); });
document.dispatchEvent(evt); document.dispatchEvent(event);
console.log("[toast] CustomEvent dispatched, type:", evt.type); console.log("[toast] CustomEvent dispatched, type:", event.type);
} }
window.toast = toast; window.toast = toast;
@@ -135,7 +165,10 @@ window.toast = toast;
* @todo Migrate these call sites to hx-post + hx-on::after-request * @todo Migrate these call sites to hx-post + hx-on::after-request
* for HTMX-native toast handling. * for HTMX-native toast handling.
*/ */
window.fetchWithHtmxTriggers = function fetchWithHtmxTriggers(url, options = {}) { window.fetchWithHtmxTriggers = function fetchWithHtmxTriggers(
url: RequestInfo | URL,
options: RequestInit = {}
): Promise<Response> {
console.log("[fetchWithHtmxTriggers] fetching:", url); console.log("[fetchWithHtmxTriggers] fetching:", url);
return fetch(url, options).then(async (response) => { return fetch(url, options).then(async (response) => {
console.log("[fetchWithHtmxTriggers] response status:", response.status); console.log("[fetchWithHtmxTriggers] response status:", response.status);
@@ -152,19 +185,21 @@ window.fetchWithHtmxTriggers = function fetchWithHtmxTriggers(url, options = {})
} }
// Handle both single object and array of events // Handle both single object and array of events
const events = Array.isArray(triggers) ? triggers : [triggers]; const events = Array.isArray(triggers) ? triggers : [triggers];
events.forEach((triggerObj) => { events.forEach((triggerObject: Record<string, unknown>) => {
Object.entries(triggerObj).forEach(([name, detail]) => { Object.entries(triggerObject).forEach(([name, detail]) => {
console.log("[fetchWithHtmxTriggers] dispatching event:", name, detail); console.log("[fetchWithHtmxTriggers] dispatching event:", name, detail);
let parsedDetail = detail; let parsedDetail: unknown = detail;
try { try {
parsedDetail = JSON.parse(detail); parsedDetail = JSON.parse(detail as string);
} catch { } catch {
// keep as string // keep as string
} }
document.dispatchEvent(new CustomEvent(name, { document.dispatchEvent(
detail: parsedDetail, new CustomEvent(name, {
bubbles: true, detail: parsedDetail,
})); bubbles: true,
})
);
}); });
}); });
} }