-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add client-side toast directive #42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f33f53f
f357b36
b0fb120
a2c693a
434d61a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -166,3 +166,137 @@ export function handleAnimateDirectives(rootElement: Element): void { | |
| document.head.appendChild(style); | ||
| } | ||
| } | ||
|
|
||
| // ─── Toast directives ──────────────────────────────────────────────────────── | ||
|
|
||
| interface ToastMessage { | ||
| id: string; | ||
| title?: string; | ||
| body?: string; | ||
| type: "info" | "success" | "warning" | "error"; | ||
| dismissible: boolean; | ||
| dismissMS: number; | ||
| } | ||
|
|
||
| // Key used to store the last processed data-pending value on each trigger element. | ||
| // Prevents showing the same batch of toasts twice if handleToastDirectives is | ||
| // called multiple times within a single update cycle (e.g. from multiple patches). | ||
| const PENDING_PROCESSED_KEY = "__lvtPendingProcessed"; | ||
|
|
||
| /** | ||
| * Read data-pending toast messages from server trigger elements and create | ||
| * client-managed toast DOM. Called after each LiveTemplate DOM update. | ||
| */ | ||
| export function handleToastDirectives(rootElement: Element): void { | ||
| rootElement | ||
| .querySelectorAll<HTMLElement>("[data-toast-trigger]") | ||
| .forEach((trigger) => { | ||
| const pending = trigger.getAttribute("data-pending"); | ||
| if (!pending) return; | ||
| // Skip if this exact batch was already processed (handles multi-patch calls) | ||
| if ((trigger as any)[PENDING_PROCESSED_KEY] === pending) return; | ||
| (trigger as any)[PENDING_PROCESSED_KEY] = pending; | ||
|
|
||
| let messages: ToastMessage[]; | ||
| try { | ||
| messages = JSON.parse(pending); | ||
| } catch { | ||
| return; | ||
| } | ||
| if (!Array.isArray(messages) || !messages.length) return; | ||
|
|
||
| const position = trigger.getAttribute("data-position") || "top-right"; | ||
| const stack = getOrCreateToastStack(position); | ||
| messages.forEach((msg) => { | ||
| const el = createToastElement(msg); | ||
| stack.appendChild(el); | ||
| if (typeof msg.dismissMS === "number" && msg.dismissMS > 0) { | ||
| setTimeout(() => el.remove(), msg.dismissMS); | ||
| } | ||
| }); | ||
|
Comment on lines
+200
to
+216
|
||
| }); | ||
| } | ||
|
Comment on lines
+186
to
+218
|
||
|
|
||
| /** | ||
| * Set up a document click listener that dismisses all visible toasts when | ||
| * the user clicks outside the toast stack. Called once at connect time. | ||
| */ | ||
| export function setupToastClickOutside(): void { | ||
| const key = "__lvt_toast_click_outside"; | ||
| const existing = (document as any)[key]; | ||
| if (existing) document.removeEventListener("click", existing); | ||
| const listener = (e: Event) => { | ||
| const stack = document.querySelector("[data-lvt-toast-stack]"); | ||
| if (!stack || stack.contains(e.target as Node)) return; | ||
| stack.querySelectorAll("[data-lvt-toast-item]").forEach((el) => el.remove()); | ||
| }; | ||
| (document as any)[key] = listener; | ||
| document.addEventListener("click", listener); | ||
| } | ||
|
|
||
| function getOrCreateToastStack(position: string): HTMLElement { | ||
| let stack = document.querySelector( | ||
| "[data-lvt-toast-stack]" | ||
| ) as HTMLElement | null; | ||
| if (!stack) { | ||
| stack = document.createElement("div"); | ||
| stack.setAttribute("data-lvt-toast-stack", ""); | ||
| stack.setAttribute("aria-live", "polite"); | ||
| applyPositionStyles(stack, position); | ||
| document.body.appendChild(stack); | ||
| } | ||
| return stack; | ||
| } | ||
|
|
||
| function applyPositionStyles(stack: HTMLElement, position: string): void { | ||
| const s = stack.style; | ||
| switch (position) { | ||
| case "top-left": | ||
| s.top = "1rem"; s.left = "1rem"; break; | ||
| case "top-center": | ||
| s.top = "1rem"; s.left = "50%"; s.transform = "translateX(-50%)"; break; | ||
| case "bottom-right": | ||
| s.bottom = "1rem"; s.right = "1rem"; break; | ||
| case "bottom-left": | ||
| s.bottom = "1rem"; s.left = "1rem"; break; | ||
| case "bottom-center": | ||
| s.bottom = "1rem"; s.left = "50%"; s.transform = "translateX(-50%)"; break; | ||
| default: // top-right | ||
| s.top = "1rem"; s.right = "1rem"; break; | ||
| } | ||
| } | ||
|
|
||
| function createToastElement(msg: ToastMessage): HTMLElement { | ||
| const el = document.createElement("div"); | ||
| el.setAttribute("role", "alert"); | ||
| el.setAttribute("data-lvt-toast-item", msg.id); | ||
| if (msg.type) el.setAttribute("data-type", msg.type); | ||
|
|
||
| const inner = document.createElement("div"); | ||
| inner.setAttribute("data-lvt-toast-content", ""); | ||
|
|
||
| if (msg.title) { | ||
| const t = document.createElement("strong"); | ||
| t.textContent = msg.title; | ||
| inner.appendChild(t); | ||
| } | ||
| if (msg.body) { | ||
| const b = document.createElement("p"); | ||
| b.textContent = msg.body; | ||
| inner.appendChild(b); | ||
| } | ||
|
|
||
| el.appendChild(inner); | ||
|
|
||
| if (msg.dismissible) { | ||
| const btn = document.createElement("button"); | ||
| btn.type = "button"; | ||
| btn.setAttribute("aria-label", "Dismiss"); | ||
| btn.textContent = "×"; | ||
| btn.addEventListener("click", () => el.remove()); | ||
| el.appendChild(btn); | ||
| } | ||
|
|
||
| return el; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ToastMessage.typeis currently unused when building the toast DOM, so the rendered markup can’t reflect the toast variant (info/success/warning/error). If styling/behavior depends on the type, consider adding adata-*attribute or class based onmsg.type(or removing the field if it’s not intended to be used).