Toast
A stack of toasts any component adds with toast_show(): tones, an action button, and timers that hold on hover.
Demo
The message is here.
Invoice sentAna will get it in a minute.
Your plan ends in 3 days.
Hover the stack to hold the timers. Without inline, the stack sits in the window's corner.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/toast.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/toast.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/toast.qwe
component ToastDemoLive() { state deleted = false state saves = 0 <div.demo-stack> <div.demo-row> <Button id="toast-demo-save" variant="soft" on_click={() => { saves += 1 toast_show(`Saved (${saves})`, { tone: "success", seconds: 3 }) }}>Save</Button> <Button id="toast-demo-delete" variant="soft" tone="danger" on_click={() => { deleted = true toast_show("Message deleted", { action: "Undo", on_action: () => { deleted = false }, seconds: 8 }) }}>Delete message</Button> <Button id="toast-demo-error" variant="outline" on_click={() => { toast_show("Could not reach the server.", { title: "Offline", tone: "danger", seconds: 0 }) }}>Fail</Button> <Button id="toast-demo-info" variant="ghost" on_click={() => { toast_show("A new version is ready.", { title: "Update", tone: "info" }) }}>Info</Button> </div> <p.demo-note>The message is {deleted ? "deleted" : "here"}.</p> <div style="position: relative; height: 200px; overflow: hidden; border: 1px dashed var(--ui-border); border-radius: var(--ui-radius)"> <ToastHost inline={true} limit={3}/> </div> </div>}view ToastDemo() = <div.demo-stack> <ToastDemoLive/> <div.demo-stack.demo-narrow> <Toast title="Invoice sent" tone="success">Ana will get it in a minute.</Toast> <Toast tone="warning" action="Renew">Your plan ends in 3 days.</Toast> </div> <p.demo-note>Hover the stack to hold the timers. Without inline, the stack sits in the window's corner.</p> </div>Source
ui/toast.qwe
// Toast: short messages that pile up in a corner of the screen and go by themselves.// Put one ToastHost on the page; then any of your components shows a toast with// toast_show(message, options). Each says its tone with an icon and a colour, can// carry one action button ("Undo"), and has a close button. Hovering or focusing the// stack holds the timers; the newest is announced to screen readers (an error at once,// the others politely). With no ToastHost on the page, toast_show falls back to the// builtin notify().//// <ToastHost/> (once, anywhere in the page)// toast_show("Saved", { tone: "success" })// toast_show("Message deleted", { action: "Undo", on_action: () => { restore(id) }, seconds: 8 })// toast_show("Could not reach the server", { title: "Offline", tone: "danger", seconds: 0 })//// toast_show(message, options): options is a map of title, tone ("neutral" (default),// "info", "success", "warning" or "danger"), seconds (how long it stays, 5; 0 keeps it// until closed), action (an action button's text) and on_action() (what it does; the// toast closes after).// ToastHost: placement "bottom-right" (default), "bottom-left", "bottom-center",// "top-right", "top-left" or "top-center". limit: the most shown at once (5; the// oldest goes). label: the stack's name for screen readers ("Notifications").// inline: in its container (positioned) instead of the window's corner.// Toast is one toast as markup, which ToastHost draws: message (children), title, tone,// action, and on_action() and on_close() inside one of your components.import ui/theme.qwe use ui_clslet toast_css = css`.ui-toast-host { position: fixed; z-index: 1100; display: flex; flex-direction: column; gap: 10px; width: min(380px, calc(100vw - 32px)); margin: 0; padding: 0; list-style: none; pointer-events: none; font: 14px/1.45 var(--ui-font);}.ui-toast-host > li { pointer-events: auto; }.ui-toast-bottom-right { right: 16px; bottom: 16px; flex-direction: column-reverse; }.ui-toast-bottom-left { left: 16px; bottom: 16px; flex-direction: column-reverse; }.ui-toast-bottom-center { left: 50%; bottom: 16px; translate: -50% 0; flex-direction: column-reverse; }.ui-toast-top-right { right: 16px; top: 16px; }.ui-toast-top-left { left: 16px; top: 16px; }.ui-toast-top-center { left: 50%; top: 16px; translate: -50% 0; }.ui-toast-inline { position: absolute; z-index: 5; }.ui-toast { display: flex; align-items: flex-start; gap: 10px; padding: 12px 10px 12px 14px; border: 1px solid var(--ui-border); border-left: 3px solid var(--t); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow-lg); animation: ui-toast-in .2s ease-out; --t: var(--ui-strong);}.ui-toast-info { --t: var(--ui-info); }.ui-toast-success { --t: var(--ui-success); }.ui-toast-warning { --t: var(--ui-warning); }.ui-toast-danger { --t: var(--ui-danger); }@keyframes ui-toast-in { from { opacity: 0; transform: translateY(8px) scale(.98); } }.ui-toast-icon { flex: none; width: 18px; height: 18px; margin-top: 1px; color: var(--t); }.ui-toast-neutral .ui-toast-icon { color: var(--ui-muted); }.ui-toast-body { flex: 1; min-width: 0; overflow-wrap: anywhere; }.ui-toast-title { display: block; font-weight: 600; }.ui-toast-msg { color: var(--ui-fg); }.ui-toast-title + .ui-toast-msg { display: block; color: var(--ui-muted); }.ui-toast-action { flex: none; align-self: center; height: 28px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-accent); font: 600 13px/1 var(--ui-font); cursor: pointer;}.ui-toast-action:hover { background: var(--ui-accent-soft); }.ui-toast-x { display: grid; place-items: center; flex: none; width: 26px; height: 26px; margin: -3px 0 -3px -2px; padding: 0; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-muted); cursor: pointer;}.ui-toast-x:hover { background: var(--ui-subtle); color: var(--ui-fg); }.ui-toast-x svg { width: 16px; height: 16px; }.ui-toast-action:focus-visible, .ui-toast-x:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (max-width: 520px) { .ui-toast-host:not(.ui-toast-inline) { left: 12px; right: 12px; width: auto; translate: none; }}@media (prefers-reduced-motion: reduce) { .ui-toast { animation: none; } }`fn toast_icon(tone: str) = match tone { "success" => "M22 11.1V12a10 10 0 1 1-5.9-9.1M22 4 12 14l-3-3" "warning" => "M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" "danger" => "M12 8v4M12 16h.01M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z" "info" => "M12 16v-4M12 8h.01M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z" _ => "M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 0 1-3.4 0" }// Shows a toast in the page's ToastHost (or with notify() when there is none).fn toast_show(message: str, options: map = {}) { let detail = { ...options, message } let ev = js_new("CustomEvent", "ui-toast", { detail, cancelable: true }) let taken = js("document.dispatchEvent", ev) == false if !taken { notify(message, { title: get(options, "title"), tone: get(options, "tone") ?? "neutral", seconds: get(options, "seconds") ?? 5 }) } null}view Toast(title: str? = null, tone: str = "neutral", action: str? = null, on_action = null, on_close = null, class: str? = null, children) { style global toast_css <div class={ui_cls(["ui-toast", `ui-toast-${tone}`, class])}> <svg.ui-toast-icon viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={toast_icon(tone)}/></svg> <div.ui-toast-body> {title != null && <strong.ui-toast-title>{title}</strong>} <span.ui-toast-msg>{children}</span> </div> {action != null && <button.ui-toast-action type="button" @click={() => { on_action && on_action() }}>{action}</button>} <button.ui-toast-x type="button" aria-label="Dismiss" @click={() => { on_close && on_close() }}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg> </button> </div>}component ToastHost(placement: str = "bottom-right", limit: int = 5, label: str = "Notifications", inline: bool = false, class: str? = null) { style global toast_css state items = [] // { n, message, title, tone, action, on_action, until } state count = 0 state held = false let dismiss = (n: int) => { items = items | filter((t) => t.n != n) } if len(items) > 0 && !held { every(200, () => { let at = now() items = items | filter((t) => t.until == null || t.until > at) }) } <ol class={ui_cls(["ui-toast-host", `ui-toast-${placement}`, inline && "ui-toast-inline", class])} aria-label={label} @mount={(el) => { let heard = (e) => { e.preventDefault() let d = e.detail let secs = d.seconds ?? 5 count += 1 let all = items | push({ n: count, message: str(d.message), title: d.title, tone: d.tone ?? "neutral", action: d.action, on_action: d.on_action, until: secs > 0 ? now() + secs * 1000 : null }) items = len(all) > limit ? drop(all, len(all) - limit) : all } js("document.addEventListener", "ui-toast", heard) () => js("document.removeEventListener", "ui-toast", heard) }} @pointerenter={() => { held = true }} @pointerleave={() => { held = false let at = now() items = items | map((t) => t.until != null && t.until < at + 2000 ? { ...t, until: at + 2000 } : t) }} @focusin={(e) => { held = e.target.closest(".ui-toast-host").matches(":focus-within") }} @focusout={(e) => { if e.relatedTarget == null || !e.target.closest(".ui-toast-host").contains(e.relatedTarget) { held = false } }}> {for t in items => <li key={t.n} role={t.tone == "danger" ? "alert" : "status"} aria-live={t.tone == "danger" ? "assertive" : "polite"} aria-atomic="true"> <Toast title={t.title} tone={t.tone} action={t.action} on_action={() => { dismiss(t.n) if t.on_action != null { t.on_action() } }} on_close={() => { dismiss(t.n) }}>{t.message}</Toast> </li>} </ol>}Related
SnackbarA short message along the bottom of the window with one action, shown from anywhere by calling snackbar_show().6 views · 6 downloadsBannerAn announcement across the top of the page in six tones, with a link, closed for the session once dismissed.7 views · 6 downloadsCoachmarkA small callout pointing at an element by id, explaining it once, dismissed with Got it; several make a tour.6 views · 8 downloadsConnectionStatusSays when the browser goes offline, as a bar across the top or a pill, and that it is back online for a moment.6 views · 7 downloadsCookieConsentA cookie banner with accept, reject and settings by category, remembering the choice in localStorage.6 views · 7 downloadsNoticesThe styles for Quillo's own notify() toasts and ask() dialogs, in every tone and position.5 views · 6 downloads
Thread
Thread 0 posts
On Toast: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.