@
feat(ui): add DataTable and Toaster, drop the legacy Table, fix overlay dialogs DataTable replaces the 20-line Table scaffold entirely: columns, sorting, filtering, pagination, selection, bulk actions, comparison layout, sticky first column, custom HTML cells, and a remote source driven by a `request` output rather than a function prop (props travel as HTML attributes, so a function arrives as its own source text). Toaster replaces the hand-rolled status div: tone icons, actions, hover pause/resume and a progress bar. Overlays audit -- Modal and Drawer declared aria-modal="true" but nothing ever moved focus into the panel, so the @keydown handler on their root never ran and closeOnEscape did nothing. Focus, focus restore, a Tab trap and a body scroll lock now live in the reactive runtime, shared by both. ContextMenu placed pointer menus by subtracting a guessed 340x420 from the viewport, which pushed every menu that was not that size away from the pointer; it now positions at the pointer and lets the anchored clamp pull it back once it can be measured. The reactive runtime size budget moves 150k -> 175k to cover anchored overlays, dialog behaviour, the toaster and the DataTable client half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> @
This commit is contained in:
@@ -0,0 +1,962 @@
|
||||
//
|
||||
// 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
|
||||
// <Toaster successIcon="icon-[lucide--party-popper]" />
|
||||
// <Toaster showIcon={false} /> // 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"))
|
||||
// }
|
||||
// ...
|
||||
// <div @window:app:undo-delete="restoreItem()">
|
||||
//
|
||||
// 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 <Toaster> 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 {
|
||||
<div
|
||||
{...attrs}
|
||||
data-ui-component="Toaster"
|
||||
data-toaster="true"
|
||||
data-position='{position}'
|
||||
data-size='{size}'
|
||||
class='wire-toaster {class}'
|
||||
role="region"
|
||||
aria-label="Notifications"
|
||||
@window:wrnexus:toast='receiveToast(event)'
|
||||
@window:wrnexus:toast:dismiss='dismissById(event)'
|
||||
@window:wrnexus:toast:clear='clearAll()'
|
||||
@window:wrnexus:toast:remove='removeById(event)'
|
||||
>
|
||||
<!-- Hover is bound to the list, not to each toast, and pauses the whole
|
||||
stack. A row is a data-for clone that is recreated whenever state
|
||||
changes, so a per-row mouseleave would be bound to a node the
|
||||
browser has already discarded: the pointer would never "leave", the
|
||||
toast would stay paused, and it would hang on screen forever.
|
||||
The list element is stable for the lifetime of the page. Pausing
|
||||
the whole stack is also what people expect -- reading one toast
|
||||
should not let its neighbours expire underneath it. -->
|
||||
<ol
|
||||
class="wire-toaster__list"
|
||||
data-ref="list"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
@mouseenter='pauseAll()'
|
||||
@mouseleave='resumeAll()'
|
||||
@focusin='pauseAll()'
|
||||
@focusout='resumeAll()'
|
||||
>
|
||||
<li
|
||||
class="wire-toast"
|
||||
data-for="item in toasts"
|
||||
data-key="item.id"
|
||||
data-tone='{item.tone}'
|
||||
data-leaving='{item.leaving}'
|
||||
>
|
||||
<span
|
||||
class="wire-toast__indicator"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
|
||||
<!-- An app-supplied icon class (iconify or otherwise). -->
|
||||
<span
|
||||
class='wire-toast__icon {item.icon}'
|
||||
data-show="item.showIcon && item.icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
|
||||
<!-- Built-in fallback. Inline SVG on purpose: the package cannot
|
||||
assume the host app has an icon set installed, and a class from
|
||||
here would not be in the app Tailwind content globs anyway, so
|
||||
it would generate no CSS and render nothing. Each tone shows its
|
||||
own glyph via data-show; all of them inherit --toast-accent, so
|
||||
the icon is green / red / amber / blue with the tone. -->
|
||||
<svg
|
||||
class="wire-toast__icon wire-toast__icon--default"
|
||||
data-show="item.showIcon && !item.icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g data-show="item.tone === 'success'">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</g>
|
||||
<g data-show="item.tone === 'danger'">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4" />
|
||||
<path d="M12 16h.01" />
|
||||
</g>
|
||||
<g data-show="item.tone === 'warning'">
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" />
|
||||
</g>
|
||||
<g data-show="item.tone !== 'success' && item.tone !== 'danger' && item.tone !== 'warning'">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
class="wire-toast__body"
|
||||
>
|
||||
<p
|
||||
class="wire-toast__title"
|
||||
data-show="item.title"
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
<p
|
||||
class="wire-toast__message"
|
||||
>
|
||||
{item.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Two fixed action slots. A data-for inside a data-for is not
|
||||
expanded by the runtime, so an arbitrary list cannot be looped
|
||||
per row; normalizeActions caps the array at two and warns. -->
|
||||
<div
|
||||
class="wire-toast__actions"
|
||||
data-show="item.actions.length"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="wire-toast__action"
|
||||
data-show="item.actions.length > 0"
|
||||
data-tone='{item.actions[0].tone}'
|
||||
@click='runAction(item.id, 0, event)'
|
||||
>
|
||||
{item.actions[0].label}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="wire-toast__action"
|
||||
data-show="item.actions.length > 1"
|
||||
data-tone='{item.actions[1].tone}'
|
||||
@click='runAction(item.id, 1, event)'
|
||||
>
|
||||
{item.actions[1].label}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="wire-toast__close"
|
||||
data-show="closable"
|
||||
aria-label='{closeLabel}'
|
||||
@click='dismissToast(item.id, "close-button")'
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="M6 6 18 18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Decoration only: the timer above owns dismissal. Shown when the
|
||||
toast has a lifetime, and paused in step with it on hover. -->
|
||||
<span
|
||||
class="wire-toast__progress"
|
||||
data-show="showProgress && item.duration"
|
||||
style="--toast-duration: {item.duration}ms"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user