// // Toaster -- the notification host. Mount it once (usually in a layout) and // raise notifications from anywhere with the runtime global: // // toast("Saved") // toast.success("Invite sent to " + email) // toast.error("Could not save", { title: "Network error", duration: 8000 }) // toast({ message: "Uploading", tone: "info", duration: 0 }) // 0 = sticky // // Each tone ships a built-in icon tinted with that tone colour -- green for // success, red for danger, amber for warning, blue for info. Swap in your own // per toast, or per tone on the host: // // toast.success("Shipped", { icon: "icon-[lucide--rocket]" }) // toast("Quiet one", { icon: false }) // suppress the icon // // // never show icons // // Actions run your own code. The handler is passed straight through, so it // closes over whatever the calling function can see: // // toast("Note deleted", { // actionLabel: "Undo", // onAction: function () { restoreNote(id) } // }) // // toast.warning("Two versions of this file", { // actions: [ // { label: "Keep mine", onClick: keepMine }, // { label: "Use theirs", onClick: useTheirs, tone: "danger", dismiss: false } // ] // }) // // The toast dismisses itself after an action; pass dismiss: false on the // action to keep it open (for a step that has to report back). At most two // actions render -- see normalizeActions. A declarative @action on the tag // still fires for every click, so a page can log or route centrally. // // IMPORTANT -- what a callback may do. It runs long after the function that // created it returned, and a client function only flushes its state when its // body ends, so assigning your own component state from inside a callback // writes to a dead local and is lost. Calling toast(), fetch, navigation and // anything else that is not a state assignment works normally. To change // state, dispatch an event and handle it declaratively, which re-enters with // live state: // // onAction: function () { // window.dispatchEvent(new CustomEvent("app:undo-delete")) // } // ... //
// // An icon prop is a CSS class, never markup, so any icon system works // (iconify, an icon font, your own sprite). NOTE the class has to appear in // YOUR source for a scanner like Tailwind to emit it -- that is exactly why // the defaults are inline SVG rather than classes from this package. // // The runtime never imports this component: toast() dispatches a // `wrnexus:toast` window event and this listens for it, so an app can supply // its own host by listening for the same event. // // NOTE: apostrophes are avoided in the style block on purpose -- the .wrn // block scanner treats a quote as a string delimiter while counting braces, // so a stray one breaks parsing of the whole component. component Toaster { outputs { show(payload: { id: number; message: string; tone: string }) dismiss(payload: { id: number; reason: string }) action(payload: { id: number; sourceEvent: Event }) } props { // Default tone for toasts raised without one of their own. Every Wire UI // component takes color and size; here they set the stack defaults. color: string = "info" // default | sm | lg -- controls toast width and density. size: string = "default" // top-left | top-center | top-right | bottom-left | bottom-center | bottom-right position: string = "bottom-right" // Auto-dismiss delay in ms. 0 keeps a toast until it is dismissed. duration: number = 4500 // Oldest toasts beyond this are retired as new ones arrive. max: number = 4 pauseOnHover: boolean = true // Icons: a built-in glyph per tone, tinted with that tone colour. // Override any of them with an icon class of your own (iconify, an icon // font, whatever your app already uses) -- the built-in SVG is only a // fallback so the component needs no icon dependency. Per toast: // toast.success("Saved", { icon: "icon-[lucide--party-popper]" }) // toast("Quiet", { icon: false }) // no icon on this one showIcon: boolean = true successIcon: string = "" dangerIcon: string = "" warningIcon: string = "" infoIcon: string = "" closable: boolean = true showProgress: boolean = true closeLabel: string = "Dismiss notification" class: string = "" } state toasts = [] state sequence = 0 // Timer bookkeeping, deliberately outside `toasts`: the view never reads // this, so pausing and resuming rebuilds no DOM. See the note in functions. state timers = {} functions { // TIMER DESIGN -- read before changing. // // 1. No callback here touches state. A client function gets state as a // local snapshot and flushes it back when the body returns, so a write // from a setTimeout callback lands in a dead local and is lost; calling // a peer function from one re-flushes the stale snapshot over live // state. Timers therefore only dispatch a window event, and the // declarative @window handlers re-enter with live state. // // 2. Timer bookkeeping lives in `timers`, NOT on the toast entries. // The view renders `toasts` through data-for, so touching a toast // object rebuilds every row -- which restarted each progress bar from // zero and made the bar look permanently full. Hover has to be free of // that: pausing must leave the DOM completely alone. `timers` is never // read by the view, so writing it re-renders nothing. // // 3. Everything that DOES change `toasts` (add, dismiss, remove) returns // untouched items by reference for the rows it is not changing, so the // keyed loop reuses those nodes and their bars keep running. client function scheduleEvent(name, id, delay) { return setTimeout(function () { window.dispatchEvent(new CustomEvent(name, { detail: { id: id } })) }, delay) } // kind is either "dismiss" (the toast lifetime, which hover pauses) or // "remove" (the exit-animation cleanup, which hover must NOT touch -- // see pauseAll). client function trackTimer(id, handle, life, kind) { var next = {} Object.keys(timers).forEach(function (key) { next[key] = timers[key] }) next[id] = { handle: handle, expiresAt: Date.now() + life, remaining: life, kind: kind || "dismiss" } timers = next } client function forgetTimer(id) { var next = {} Object.keys(timers).forEach(function (key) { if (String(key) !== String(id)) { next[key] = timers[key] } }) timers = next } // An explicit per-toast icon wins; otherwise the tone default prop; and if // that is empty the built-in SVG for the tone renders instead. Returns a // CSS class name, never markup, so an app can hand us any icon system. client function resolveIcon(tone, requested) { if (requested) { return String(requested) } if (tone === "success") { return successIcon } if (tone === "danger") { return dangerIcon } if (tone === "warning") { return warningIcon } if (tone === "info") { return infoIcon } return "" } // Actions arrive either as a single actionLabel/onAction pair or as an // actions array. At most two are rendered: the view has two fixed slots // because a data-for inside a data-for is not expanded by the runtime, so // an arbitrary list cannot be rendered per row. Two covers the real cases // (Undo, Retry, View / Dismiss); anything beyond that is dropped loudly // rather than silently. client function normalizeActions(detail) { var list = [] if (Array.isArray(detail.actions)) { list = detail.actions.filter(function (action) { return action && action.label }) } else if (detail.actionLabel) { list = [{ label: detail.actionLabel, onClick: detail.onAction, tone: detail.actionTone }] } if (list.length > 2) { console.warn( "[wrnexus] Toaster renders at most 2 actions per toast; ignoring " + (list.length - 2) + " extra." ) } return list.slice(0, 2) } // Is the pointer resting on the stack right now? // // mouseenter only fires when the pointer MOVES. A toast raised while the // cursor is already parked over the stack -- which is exactly what an // action handler does -- therefore gets no enter event, is never paused, // and counts down and disappears while the user is still reaching for its // button. Its progress bar meanwhile IS paused, because CSS :hover does // apply, so the bar sat still while the toast quietly expired. Asking the // DOM for the live :hover state closes that gap. client function stackHovered() { var list = refs.list return !!(pauseOnHover && list && list.matches(":hover")) } client function receiveToast(sourceEvent) { var detail = sourceEvent.detail || {} // One toast per raise, however many hosts are mounted. // // toast() dispatches a window event, so EVERY mounted Toaster hears it // and a page with two hosts showed the message twice (the component // showcase mounts five and showed five). Claiming the event on the // detail object lets the first host win and the rest stand down, so an // accidental second host is harmless instead of multiplying every // notification. if (detail.__wrnClaimed) { return } detail.__wrnClaimed = true sequence = sequence + 1 var id = sequence var life = detail.duration === 0 ? 0 : (detail.duration || duration) var tone = detail.tone || color var entry = { id: id, title: detail.title || "", message: detail.message === undefined ? "" : String(detail.message), tone: tone, icon: resolveIcon(tone, detail.icon), showIcon: showIcon && detail.icon !== false, actions: normalizeActions(detail), duration: life, leaving: false } // Retire the oldest live toasts in the same pass that appends the new // one, so a burst can never leave the stack over max. Untouched rows are // returned by reference so their nodes (and bars) survive. var live = toasts.filter(function (item) { return !item.leaving }) var retire = live.length + 1 > max ? live.slice(0, live.length + 1 - max) : [] toasts = toasts .map(function (item) { var doomed = retire.some(function (old) { return old.id === item.id }) return doomed ? Object.assign({}, item, { leaving: true }) : item }) .concat([entry]) retire.forEach(function (item) { clearTimeout((timers[item.id] || {}).handle) trackTimer(item.id, scheduleEvent("wrnexus:toast:remove", item.id, 240), 240, "remove") }) if (life) { // Born paused when the stack is already hovered: handle 0 parks it, // and resumeAll starts the clock when the pointer finally leaves. if (stackHovered()) { trackTimer(id, 0, life, "dismiss") } else { trackTimer(id, scheduleEvent("wrnexus:toast:dismiss", id, life), life, "dismiss") } } output.show({ id: id, message: entry.message, tone: entry.tone }) } client function dismissToast(id, reason) { var found = false toasts = toasts.map(function (item) { if (item.id !== id || item.leaving) { return item } found = true return Object.assign({}, item, { leaving: true }) }) if (!found) { return } clearTimeout((timers[id] || {}).handle) // Give the exit animation time to play, then drop the entry. trackTimer(id, scheduleEvent("wrnexus:toast:remove", id, 240), 240, "remove") output.dismiss({ id: id, reason: reason || "auto" }) } client function removeToast(id) { clearTimeout((timers[id] || {}).handle) forgetTimer(id) toasts = toasts.filter(function (item) { return item.id !== id }) } client function dismissById(sourceEvent) { var detail = sourceEvent.detail || {} dismissToast(detail.id, detail.reason || "timeout") } client function removeById(sourceEvent) { var detail = sourceEvent.detail || {} removeToast(detail.id) } client function clearAll() { toasts.slice().forEach(function (item) { if (!item.leaving) { dismissToast(item.id, "clear") } }) } // Hovering must not run the clock down while a toast is being read, so // the remaining time is banked and the timers restart on the way out. // Only `timers` is written, so not a single DOM node is rebuilt -- the // progress bars simply stop where they are (CSS pauses them on :hover) // and carry on from there. client function pauseAll() { if (!pauseOnHover) { return } var now = Date.now() var next = {} Object.keys(timers).forEach(function (key) { var entry = timers[key] // A "remove" timer finishes an exit animation -- pausing it strands // the toast: invisible, still taking up space in the stack, and still // able to swallow clicks, forever. Only lifetimes pause. if (!entry || !entry.handle || entry.kind !== "dismiss") { next[key] = entry return } clearTimeout(entry.handle) var left = entry.expiresAt - now next[key] = { handle: 0, expiresAt: entry.expiresAt, remaining: left > 0 ? left : 1, kind: "dismiss" } }) timers = next } client function resumeAll() { if (!pauseOnHover) { return } var now = Date.now() var next = {} Object.keys(timers).forEach(function (key) { var entry = timers[key] if (!entry || entry.handle || entry.kind !== "dismiss") { next[key] = entry return } var left = entry.remaining > 0 ? entry.remaining : 1 // Number(key): object keys come back as strings, and the dismiss // handler matches ids with !==, so a string id silently matches no // toast and the resumed timer would fire into the void. next[key] = { handle: scheduleEvent("wrnexus:toast:dismiss", Number(key), left), expiresAt: now + left, remaining: left, kind: "dismiss" } }) timers = next } // The click handler for an action. Runs as a fresh invocation from the // DOM, so state here is live and calling peer functions is safe. client function runAction(id, index, sourceEvent) { var toast = null toasts.forEach(function (item) { if (item.id === id) { toast = item } }) if (!toast) { return } var action = toast.actions[index] if (!action) { return } // Declarative listeners on the tag see every action too. output.action({ id: id, index: index, label: action.label, sourceEvent: sourceEvent }) // The callback is application code: a throw here must not take the // toaster down with it, or the toast would be stuck on screen forever. if (typeof action.onClick === "function") { try { action.onClick(sourceEvent) } catch (error) { console.error("[wrnexus] toast action handler failed", error) } } // NOTHING may call a peer function past this point. // // The callback is application code and is re-entrant: a handler that // raises its own toast runs receiveToast in a fresh invocation, which // appends to live state. Calling a peer from here would first flush the // snapshot this function captured on entry -- taken BEFORE the callback // ran -- straight over that live state, silently erasing the toast the // handler just raised. So the dismissal is dispatched inline instead, // and handled on the next tick with state that is actually current. // This function never assigns to state itself, so it flushes nothing. if (action.dismiss !== false) { setTimeout(function () { window.dispatchEvent( new CustomEvent("wrnexus:toast:dismiss", { detail: { id: id, reason: "action" } }) ) }, 0) } } } view {
  1. {item.title}

    {item.message}

} style { .wire-toaster { position: fixed; inset: 0; z-index: 1400; display: flex; padding: clamp(0.75rem, 2vw, 1.25rem); /* The host covers the viewport so it can align the stack in any corner; it must never swallow clicks meant for the page underneath. */ pointer-events: none; } /* * pointer-events MUST be re-enabled here, not only on .wire-toast. * * The host is pointer-events: none so the page underneath stays clickable * through the empty overlay, and the list inherits that. An element with * pointer-events: none is never a hit-test target, so it never matches * :hover AND never receives mouseenter/mouseleave -- and those two do not * bubble up from the rows either. The result was that hover-to-pause did * nothing at all for a real user: toasts kept counting down and vanished * from under the cursor as they reached for the action button. (Synthetic * dispatchEvent bypasses hit-testing, so it hid this in testing.) * * The list box wraps the stack exactly -- its height is the toasts plus * their gaps -- so making it interactive costs the page nothing. */ .wire-toaster__list { display: flex; flex-direction: column; gap: 0.6rem; width: min(23rem, 100%); margin: 0; padding: 0; list-style: none; pointer-events: auto; } /* Nothing to hover when the stack is empty. */ .wire-toaster__list:empty { pointer-events: none; } .wire-toaster[data-size="sm"] .wire-toaster__list { width: min(18rem, 100%); } .wire-toaster[data-size="lg"] .wire-toaster__list { width: min(28rem, 100%); } .wire-toaster[data-size="sm"] .wire-toast { padding: 0.6rem 0.7rem; font-size: 0.78rem; } .wire-toaster[data-size="lg"] .wire-toast { padding: 1.05rem 1.1rem; } .wire-toaster[data-position^="top"] { align-items: flex-start; } .wire-toaster[data-position^="bottom"] { align-items: flex-end; } /* Newest nearest the screen edge: at the bottom that means visually last, so the column is reversed rather than the state array. */ .wire-toaster[data-position^="bottom"] .wire-toaster__list { flex-direction: column-reverse; } .wire-toaster[data-position$="left"] { justify-content: flex-start; } .wire-toaster[data-position$="center"] { justify-content: center; } .wire-toaster[data-position$="right"] { justify-content: flex-end; } .wire-toast { position: relative; display: flex; align-items: flex-start; gap: 0.7rem; overflow: hidden; padding: 0.85rem 0.9rem; color: var(--wire-color-text); background: var(--wire-color-surface-raised); border: 1px solid var(--wire-color-border); border-radius: 0.9rem; box-shadow: 0 18px 40px color-mix(in srgb, black 28%, transparent); pointer-events: auto; animation: wire-toast-in 220ms cubic-bezier(0.16, 1, 0.3, 1) both; } .wire-toaster[data-position$="left"] .wire-toast { animation-name: wire-toast-in-left; } /* * Belt and braces: a toast on its way out is faded to nothing but still * occupies its box until it is dropped from the list, so without this it * can swallow clicks aimed at whatever is under it. */ .wire-toast[data-leaving="true"] { animation: wire-toast-out 200ms ease forwards; pointer-events: none; } .wire-toast__indicator { flex: 0 0 auto; width: 0.4rem; align-self: stretch; border-radius: 999px; background: var(--toast-accent, var(--wire-color-primary)); } .wire-toast[data-tone="success"] { --toast-accent: var(--wire-color-success); } .wire-toast[data-tone="danger"] { --toast-accent: var(--wire-color-danger); } .wire-toast[data-tone="warning"] { --toast-accent: var(--wire-color-warning); } .wire-toast[data-tone="info"] { --toast-accent: var(--wire-color-info); } /* * Centred like the trailing controls. The row is top-aligned so long text * starts at the top, but on a one-line toast the 1.75rem close button is * taller than the text, so a top-aligned body left the text sitting a few * pixels above the button it is supposed to line up with. */ /* * Tinted with the tone accent, so the glyph reads as the status at a * glance -- green for success, red for danger, amber for warning, and the * info colour otherwise. The built-in SVG strokes with currentColor, and * an app icon class that uses currentColor (iconify does) picks up the * same value for free. */ .wire-toast__icon { flex: 0 0 auto; align-self: center; width: 1.15rem; height: 1.15rem; color: var(--toast-accent, var(--wire-color-primary)); } .wire-toast__body { flex: 1 1 auto; min-width: 0; align-self: center; display: grid; gap: 0.15rem; } .wire-toast__title, .wire-toast__message { margin: 0; overflow-wrap: anywhere; } .wire-toast__title { color: var(--wire-color-text); font-size: 0.85rem; font-weight: 650; line-height: 1.35; } .wire-toast__message { color: var(--wire-color-text-muted); font-size: 0.82rem; line-height: 1.5; } /* * The trailing controls share one alignment. The close button used to be * pinned to the top with a negative margin while the action sat centred, * so the two sat on different lines and read as misaligned. Both are * centred against the toast body now, and both are the same height, so * their centres line up whether the toast is one line or three. */ .wire-toast__actions { display: flex; align-items: center; flex: 0 0 auto; align-self: center; gap: 0.35rem; } /* A destructive action reads in the danger colour regardless of tone. */ .wire-toast__action[data-tone="danger"] { color: var(--wire-color-danger); border-color: color-mix(in srgb, var(--wire-color-danger) 45%, transparent); } .wire-toast__action { appearance: none; flex: 0 0 auto; min-height: 1.75rem; display: inline-flex; align-items: center; padding: 0 0.6rem; color: var(--toast-accent, var(--wire-color-primary)); background: transparent; border: 1px solid color-mix(in srgb, var(--toast-accent, var(--wire-color-primary)) 40%, transparent); border-radius: 0.55rem; font: inherit; font-size: 0.78rem; font-weight: 650; cursor: pointer; } /* * padding is reset explicitly: an app-level button padding rule beats the * browser default and collapses the icon to a sliver inside this * fixed-size button. */ .wire-toast__close { appearance: none; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; align-self: center; padding: 0; width: 1.75rem; height: 1.75rem; margin: 0; color: var(--wire-color-text-muted); background: transparent; border: 0; border-radius: 999px; cursor: pointer; transition: color 150ms ease, background 150ms ease; } .wire-toast__close:hover { color: var(--wire-color-text); background: var(--wire-color-surface-soft); } .wire-toast__close svg { flex: 0 0 auto; width: 0.875rem; height: 0.875rem; } /* * Sweeps left to right as the toast lives out its delay, reaching full * width as it is dismissed, so the remaining time is readable at a glance. * (Flip the keyframes below to run 1 -> 0 if you would rather it drain.) */ .wire-toast__progress { position: absolute; left: 0; bottom: 0; height: 2px; width: 100%; transform-origin: left center; background: var(--toast-accent, var(--wire-color-primary)); animation: wire-toast-progress var(--toast-duration, 4500ms) linear forwards; } /* Hover pauses the clock, so the bar must pause with it or it would lie about how much time is left. */ /* * Paused straight from :hover rather than from a state flag. A flag would * mean writing state on every hover, and that rebuilds the rows -- which * restarts the bars from zero and is exactly what made them look stuck. * CSS pauses the animation in place and touches no DOM, and the JS timer * is banked on the same mouseenter, so the two stay in step. */ .wire-toaster__list:hover .wire-toast__progress, .wire-toaster__list:focus-within .wire-toast__progress { animation-play-state: paused; } @keyframes wire-toast-in { from { opacity: 0; transform: translateX(18px) scale(0.98); } to { opacity: 1; transform: none; } } @keyframes wire-toast-in-left { from { opacity: 0; transform: translateX(-18px) scale(0.98); } to { opacity: 1; transform: none; } } @keyframes wire-toast-out { to { opacity: 0; transform: translateX(12px) scale(0.97); } } @keyframes wire-toast-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } } @media (max-width: 639px) { .wire-toaster { justify-content: stretch; } .wire-toaster__list { width: 100%; } } /* * Reduced motion drops the entry/exit movement but KEEPS the progress * sweep: it is a clock, not decoration, and a frozen bar would both * misreport the time left and look like the bug it used to be. A linear * 2px bar carries no vestibular risk. */ @media (prefers-reduced-motion: reduce) { .wire-toast, .wire-toast[data-leaving="true"] { animation: none; } } } }