Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions dom/directives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ToastMessage.type is 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 a data-* attribute or class based on msg.type (or removing the field if it’s not intended to be used).

Suggested change
type: "info" | "success" | "warning" | "error";

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON.parse(pending) is assumed to return ToastMessage[], but runtime input could be a non-array (e.g. a string with a .length), which would pass the !messages.length guard and then throw at messages.forEach. Consider guarding with Array.isArray(...) (and optionally validating each item is an object with required fields) before iterating to avoid client-side crashes from malformed data-pending values.

Copilot uses AI. Check for mistakes.
});
}
Comment on lines +186 to +218
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New toast behavior is introduced here (parsing data-pending, creating a global toast stack, click handlers, and auto-dismiss timers), but there are no corresponding unit tests alongside the existing directive tests. Adding tests for: (1) valid JSON array renders toast elements, (2) malformed/non-array JSON is ignored safely, (3) dismissMS removes toasts via fake timers, and (4) dismissible button removes a toast would help prevent regressions.

Copilot uses AI. Check for mistakes.

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

8 changes: 8 additions & 0 deletions livetemplate-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
handleAnimateDirectives,
handleHighlightDirectives,
handleScrollDirectives,
handleToastDirectives,
setupToastClickOutside,
} from "./dom/directives";
import { EventDelegator } from "./dom/event-delegation";
import { LinkInterceptor } from "./dom/link-interceptor";
Expand Down Expand Up @@ -383,6 +385,9 @@ export class LiveTemplateClient {
// Set up click-away delegation
this.eventDelegator.setupClickAwayDelegation();

// Set up click-outside listener for client-managed toast stack
setupToastClickOutside();

// Set up modal delegation
this.eventDelegator.setupModalDelegation();

Expand Down Expand Up @@ -789,6 +794,9 @@ export class LiveTemplateClient {
// Handle animate directives
handleAnimateDirectives(element);

// Handle toast trigger directives (ephemeral client-side toasts)
handleToastDirectives(element);

// Initialize upload file inputs
this.uploadHandler.initializeFileInputs(element);

Expand Down
Loading