Feedback

Snackbar

A short message along the bottom of the window with one action, shown from anywhere by calling snackbar_show().

#feedback#notification#toast#status#interactive

Demo

Preview

Archived 0, restored 0. Press the buttons quickly: each message waits its turn.

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/snackbar.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/snackbar.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/snackbar.qwe
component SnackbarDemoLive() {    state archived = 0    state restored = 0    <div.demo-stack>        <div.demo-row>            <Button id="snackbar-demo-archive" variant="outline"                    on_click={() => {                        archived += 1                        snackbar_show("Message archived", { action: "Undo", on_action: () => { restored += 1 } })                    }}>Archive a message</Button>            <Button id="snackbar-demo-saved" variant="outline"                    on_click={() => { snackbar_show("Draft saved", { tone: "success", seconds: 2 }) }}>Save a draft</Button>            <Button id="snackbar-demo-failed" variant="outline" tone="danger"                    on_click={() => { snackbar_show("Could not send. Check the connection and try again.", { tone: "danger", seconds: 0 }) }}>Fail to send</Button>        </div>        <p.demo-note>Archived {archived}, restored {restored}. Press the buttons quickly: each message waits its turn.</p>    </div>}view SnackbarDemo() =    <div.demo-stack>        <Snackbar/>        <SnackbarDemoLive/>    </div>

Source

ui/snackbar.qwe
// Snackbar: a short message along the bottom of the window with one action// ("Undo"), shown by calling a function from anywhere on the page. Put one// <Snackbar/> on the page; then, in any component's handler:////   <Snackbar/>//   snackbar_show("Message archived", { action: "Undo", on_action: () => { restore(id) } })//   snackbar_show("Could not send. Try again.", { tone: "danger", seconds: 0 })//// snackbar_show(message, options): message is one short line. options: action// (the button's text, none by default), on_action() (called with nothing when the// button is pressed; the snackbar then closes), seconds (how long it stays: 5; 0// keeps it until closed) and tone ("neutral" (default), "success", "warning",// "danger" or "info"). A message shown while one is up waits its turn.// Snackbar: position "bottom" (default, centred), "bottom-left" or "bottom-right";// seconds: the default stay (5); dismissable: a close button (true); label: what// screen readers call the region ("Notifications"). Each message is announced// (role="status"), and Escape closes the one showing while it has the focus.import ui/theme.qwe use ui_clslet snackbar_css = css`.ui-snackbar-host { position: fixed; z-index: 70; display: grid; justify-items: center; pointer-events: none;  left: 16px; right: 16px; bottom: max(16px, env(safe-area-inset-bottom)); font: 14px/1.45 var(--ui-font); }.ui-snackbar-bottom-left { justify-items: start; }.ui-snackbar-bottom-right { justify-items: end; }.ui-snackbar {  display: flex; align-items: center; gap: 12px; width: max-content; max-width: min(560px, 100%); min-height: 48px;  padding: 10px 8px 10px 16px; border-radius: var(--ui-radius-sm); background: var(--ui-fg); color: var(--ui-bg);  box-shadow: var(--ui-shadow-lg); pointer-events: auto; animation: ui-snackbar-in .2s ease-out;  --t: var(--ui-bg);}.ui-snackbar-success { --t: var(--ui-success); }.ui-snackbar-warning { --t: var(--ui-warning); }.ui-snackbar-danger { --t: var(--ui-danger); }.ui-snackbar-info { --t: var(--ui-info); }.ui-snackbar:not(.ui-snackbar-neutral) { box-shadow: var(--ui-shadow-lg), inset 4px 0 0 var(--t); }.ui-snackbar-text { flex: 1; min-width: 0; margin: 0; }.ui-snackbar-action {  flex: none; height: 32px; padding: 0 12px; border: 0; border-radius: var(--ui-radius-sm);  background: color-mix(in srgb, var(--ui-bg) 14%, transparent); color: inherit; font: 600 13px/1 var(--ui-font); cursor: pointer;}.ui-snackbar:not(.ui-snackbar-neutral) .ui-snackbar-action { color: color-mix(in srgb, var(--t) 70%, var(--ui-bg)); }.ui-snackbar-action:hover { background: color-mix(in srgb, var(--ui-bg) 24%, transparent); }.ui-snackbar-x {  flex: none; display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: var(--ui-radius-sm);  background: transparent; color: inherit; font-size: 20px; line-height: 1; opacity: .7; cursor: pointer;}.ui-snackbar-x:hover { opacity: 1; background: color-mix(in srgb, var(--ui-bg) 14%, transparent); }.ui-snackbar-action:focus-visible, .ui-snackbar-x:focus-visible { outline: 2px solid var(--ui-bg); outline-offset: 1px; }@keyframes ui-snackbar-in { from { opacity: 0; transform: translateY(12px); } }@media (prefers-reduced-motion: reduce) { .ui-snackbar { animation: none; } }@media (max-width: 480px) { .ui-snackbar { width: 100%; } }`// Shows a snackbar: the Snackbar on the page hears this and shows the message.fn snackbar_show(message: str, options: map = {}) {    js("document.dispatchEvent", js_new("CustomEvent", "ui-snackbar", { detail: { message, ...options } }))}component Snackbar(position: str = "bottom", seconds: int = 5, dismissable: bool = true,                   label: str = "Notifications", class: str? = null) {    style global snackbar_css    state queue = []    state current = null    state made = 0    let stay = current != null ? (current.seconds ?? seconds) : 0    let close = () => {        let next = first(queue)        if next != null { queue = drop(queue, 1) }        current = next    }    if current != null && stay > 0 { every(stay * 1000, close) }    <div class={ui_cls(["ui-snackbar-host", `ui-snackbar-${position}`, class])} role="status" aria-label={label}         @mount={(el) => {             let hear = (e) => {                 let d = e.detail                 made += 1                 let item = { n: made, message: str(d.message ?? ""), action: d.action, on_action: d.on_action,                              seconds: d.seconds, tone: d.tone ?? "neutral" }                 if current == null { current = item } else { queue = push(queue, item) }             }             js("document.addEventListener", "ui-snackbar", hear)             () => js("document.removeEventListener", "ui-snackbar", hear)         }}>        {current != null && <div class={ui_cls(["ui-snackbar", `ui-snackbar-${current.tone}`])} key={current.n}                                 @keydown={(e) => { if e.key == "Escape" { close() } }}>            <p.ui-snackbar-text>{current.message}</p>            {current.action != null && <button.ui-snackbar-action type="button"                    @click={() => {                        if current.on_action != null { current.on_action() }                        close()                    }}>{current.action}</button>}            {dismissable && <button.ui-snackbar-x type="button" aria-label="Dismiss" @click={close}>×</button>}        </div>}    </div>}
Thread
66Useful? Like it, or add to its thread below.

Thread 0 posts

On Snackbar: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.