SwipeToast
A toast sent away by a sideways swipe, with Undo, stacked and timed, standing above the tab bar and the on-screen keyboard.
Demo
Undone: 0. Dismissed: 0.
Get it
It needs theme.qwe, as every component does, and bottom_sheet.qwe; 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/bottom_sheet.qwe$ curl -O https://quillolang.org/components/ui/swipe_toast.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/swipe_toast.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/swipe_toast.qwe
let swipe_toast_demo_css = css`.swipe-toast-demo-page { display: flex; flex-direction: column; gap: 12px; box-sizing: border-box; height: 100%; padding: 20px 20px 72px; }.swipe-toast-demo-tabs { position: absolute; inset: auto 0 0 0; display: flex; align-items: center; justify-content: space-around; height: 56px; border-top: 1px solid var(--ui-border); background: var(--ui-surface); color: var(--ui-muted); font: 12px var(--ui-font); }`component SwipeToastDemoLive() { style global swipe_toast_demo_css state notes = [] state next = 1 state kept = 0 state undone = 0 state dropped = 0 let add = (text: str, undo: str?) => { notes = notes + [{ id: `toast-${next}`, text: text, undo: undo, seconds: 3 }] next = next + 1 } <div.demo-stack> <div.demo-phone> <div class="swipe-toast-demo-page"> <Button size="lg" on_click={() => { add(`Email ${next} deleted`, "Undo") }}>Delete an email</Button> <Button size="lg" variant="outline" on_click={() => { add("Draft saved", null) }}>Save a draft</Button> <Button size="lg" variant="soft" on_click={() => { notes = notes + [{ id: `toast-${next}`, text: "No connection. Will try again.", tone: "danger", seconds: 0 }] next = next + 1 }}>Lose the connection</Button> <p.demo-note>Undone: <span class="swipe-toast-demo-undone">{undone}</span>. Dismissed: <span class="swipe-toast-demo-dropped">{dropped}</span>.</p> </div> <div class="swipe-toast-demo-tabs" aria-hidden="true"><span>Inbox</span><span>Sent</span><span>Settings</span></div> <SwipeToast toasts={notes} on_undo={(_t) => { undone += 1 }} on_dismiss={(_t) => { dropped += 1 }}/> </div> </div>}view SwipeToastDemo() = <SwipeToastDemoLive/>Source
ui/swipe_toast.qwe
// SwipeToast: short notes low on the screen, for what just happened: swiped sideways, either// way, they go; each can offer Undo, and each goes by itself after a few seconds. New ones// stack below the last. They stand above a tab bar, and above the on-screen keyboard when// it is up, so they are never hidden by either. A finger on one, or focus in one, holds its// timer. They take nothing from the page and are announced without moving focus.//// import ui/swipe_toast.qwe use SwipeToast//// component Inbox() {// state notes = []// <SwipeToast toasts={notes} offset={64} on_undo={(t) => { restore(t.id) }}/>// }//// toasts: a list of maps, which may grow at any time: id (each its own; the text serves),// text, undo (the words on its button; none when left out), seconds (how long this one// stays), tone ("danger" for a red edge, "success" for a green one). A row without text is// left out, and so is an id seen before. seconds: how long each stays (5; 0 keeps it until// someone acts). limit: how many show at once (3): the newest do, the others wait and say// how many, and come back as those go. offset: how far above the screen's foot they stand,// in pixels, for a tab bar (64; the safe area is added). label: what screen readers call// the place ("Notifications"). on_undo(toast): called when Undo is pressed.// on_dismiss(toast): called when it leaves any other way.//// The equal of the swipe is the Dismiss button, and Escape or Delete with focus in it.// The fixed position is held by a frame with a transform: see the demo.import ui/bottom_sheet.qwe use bottom_sheet_track, bottom_sheet_track_move, bottom_sheet_speed, bottom_sheet_dismissed, bottom_sheet_keyboard_watchlet swipe_toast_css = css`.ui-swipe-toast-host { position: fixed; inset: auto 0 var(--ui-swipe-toast-bottom, 64px) 0; z-index: 60; display: flex; flex-direction: column; align-items: center; gap: 8px; box-sizing: border-box; padding: 0 max(12px, env(safe-area-inset-right)) 0 max(12px, env(safe-area-inset-left)); pointer-events: none; transition: bottom .2s;}.ui-swipe-toast-host quillo-island { display: contents; }.ui-swipe-toast-more { margin: 0; padding: 0 10px; border-radius: 999px; background: var(--ui-subtle); color: var(--ui-muted); font: 12px/20px var(--ui-font); pointer-events: auto; }.ui-swipe-toast { display: flex; align-items: center; gap: 4px; box-sizing: border-box; width: 100%; max-width: 420px; min-height: 52px; padding: 2px 4px 2px 14px; border: 1px solid var(--ui-border); border-left: 3px solid var(--ui-swipe-toast-edge, var(--ui-border)); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow-lg); font: 14px/1.35 var(--ui-font); pointer-events: auto; /* Sideways is this gesture's; up and down still scroll the page. */ touch-action: pan-y; -webkit-touch-callout: none; user-select: none; animation: ui-swipe-toast-in .26s cubic-bezier(.22, 1, .36, 1); transition: transform .24s cubic-bezier(.22, 1, .36, 1), opacity .2s;}@keyframes ui-swipe-toast-in { from { transform: translateY(16px); opacity: 0; } }.ui-swipe-toast-danger { --ui-swipe-toast-edge: var(--ui-danger); }.ui-swipe-toast-success { --ui-swipe-toast-edge: var(--ui-success); }.ui-swipe-toast-left, .ui-swipe-toast-right { opacity: 0; }.ui-swipe-toast-left { transform: translateX(-110%); }.ui-swipe-toast-right { transform: translateX(110%); }.ui-swipe-toast-fade { transform: translateY(12px); opacity: 0; }.ui-swipe-toast-text { flex: 1; min-width: 0; margin: 0; padding: 6px 0; overflow-wrap: anywhere; }.ui-swipe-toast-undo { flex: none; min-width: 56px; height: 44px; padding: 0 10px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-accent); font: 600 14px var(--ui-font); cursor: pointer; touch-action: inherit;}.ui-swipe-toast-close { display: grid; place-items: center; flex: none; width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-muted); cursor: pointer; touch-action: inherit;}@media (hover: hover) { .ui-swipe-toast-undo:hover, .ui-swipe-toast-close:hover { background: var(--ui-subtle); } }.ui-swipe-toast-undo:focus-visible, .ui-swipe-toast-close:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-swipe-toast-close svg { width: 16px; height: 16px; }@media (prefers-reduced-motion: reduce) { .ui-swipe-toast { animation: none; transition: none; } .ui-swipe-toast-host { transition: none; } }`// Those that can show, in order, each once.fn swipe_toast_rows(toasts, gone) { let done = gone let list_of = type_of(toasts) == "list" ? toasts : [] let given = list_of | filter((t) => type_of(t) == "map" && t.text != null && str(t.text) != "") let seen = {} let line = [] for t in given { let id = str(t.id ?? t.text) if !contains(done, id) && get(seen, id, false) == false { seen = set(seen, id, true) line = line + [{ id: id, text: str(t.text), undo: t.undo == null ? "" : str(t.undo), tone: t.tone, seconds: t.seconds }] } } line}// How long a toast stays, in milliseconds; 0 for one that waits. Its own seconds come first.fn swipe_toast_ms(toast, seconds) { let own = toast.seconds let s = type_of(own) == "int" || type_of(own) == "float" ? own : seconds let good = type_of(s) == "int" || type_of(s) == "float" ? s : 5 good <= 0 ? 0 : round(min([good, 3600]) * 1000)}// Above the tab bar, or above the keyboard when it is up, which hides the tab bar anyway.fn swipe_toast_bottom(offset: num, keyboard: num) = keyboard > 0 ? `${keyboard + 8}px` : `calc(${offset}px + env(safe-area-inset-bottom))`fn swipe_toast_class(tone, away: str?) { let edge = tone == "danger" ? " ui-swipe-toast-danger" : tone == "success" ? " ui-swipe-toast-success" : "" `ui-swipe-toast${edge}${away == null ? "" : ` ui-swipe-toast-${away}`}`}component SwipeToastItem(toast, seconds: num = 5, on_gone = null) { let life = swipe_toast_ms(toast, seconds) state left = life state held = false state inside = false state away = null // "left", "right" or "fade" as it goes state how = "dismiss" state slide = 200 state card = null state track = null state dragging = false state moved = false if away != null { every(slide, () => { on_gone && on_gone(toast, how) }) } if life > 0 && !held && !inside && away == null { every(100, () => { left = left - 100 if left <= 0 { away = "fade" } }) } let spring = () => { card.style.removeProperty("transform") card.style.removeProperty("opacity") card.style.removeProperty("transition") } let moving = (e) => { if track != null { track = bottom_sheet_track_move(track, e.clientX, e.clientY, e.timeStamp) if track.lock == "y" && !dragging { track = null } if track != null && track.lock == "x" { if !dragging { dragging = true moved = true card.setPointerCapture(e.pointerId) card.style.transition = "none" } let dx = track.x - track.x0 card.style.transform = `translateX(${round(dx, 2)}px)` card.style.opacity = str(round(max([0.25, 1 - abs(dx) / card.offsetWidth]), 3)) } } } let ended = (e) => { held = false if track != null && dragging { let dx = track.x - track.x0 let speed = bottom_sheet_speed(track, "x", e.timeStamp) // The way it was going counts: back past where it started is not away. let sign = dx >= 0 ? 1 : -1 let leave = bottom_sheet_dismissed(abs(dx), card.offsetWidth, speed * sign, 0.4) spring() if leave { away = dx >= 0 ? "right" : "left" } } track = null dragging = false } let cancelled = () => { held = false if dragging { spring() } track = null dragging = false } <div class={swipe_toast_class(toast.tone, away)} @mount={(el) => { card = el if js("window.matchMedia", "(prefers-reduced-motion: reduce)").matches { slide = 16 } }} @pointerdown={(e) => { moved = false if e.button == 0 { held = true track = bottom_sheet_track(e.clientX, e.clientY, e.timeStamp) } }} @pointermove={moving} @pointerup={ended} @pointercancel={cancelled} @pointerleave={() => { if !dragging { held = false track = null } }} @touchmove={(e) => { if dragging && e.cancelable { e.preventDefault() } }} @focusin={() => { inside = true }} @focusout={() => { inside = false }} @keydown={(e) => { if e.key == "Escape" || e.key == "Delete" { away = "fade" } }}> <p.ui-swipe-toast-text>{toast.text}</p> {toast.undo != "" && <button.ui-swipe-toast-undo type="button" @click={() => { if !moved { how = "undo" away = "fade" } moved = false }}>{toast.undo}</button>} <button.ui-swipe-toast-close type="button" aria-label="Dismiss" @click={() => { if !moved { away = "fade" } moved = false }}> <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 SwipeToast(toasts = [], seconds: num = 5, limit: int = 3, offset: int = 64, label: str = "Notifications", on_undo = null, on_dismiss = null) { style global swipe_toast_css state gone = [] state keyboard = 0 let line = swipe_toast_rows(toasts, gone) let room = max([1, limit]) let showing = slice(line, max([0, len(line) - room]), len(line)) let waiting = len(line) - len(showing) <div.ui-swipe-toast-host role="status" aria-live="polite" aria-label={label} style={`--ui-swipe-toast-bottom: ${swipe_toast_bottom(offset, keyboard)}`} @mount={(_el) => { let undo = bottom_sheet_keyboard_watch(js("window"), (px) => { keyboard = px }) () => { undo() } }}> {waiting > 0 && <p.ui-swipe-toast-more>{waiting} more</p>} {for t in showing => <SwipeToastItem key={t.id} toast={t} seconds={seconds} on_gone={(row, how) => { gone = gone + [row.id] if how == "undo" { on_undo && on_undo(row) } else { on_dismiss && on_dismiss(row) } }}/>} </div>}Related
SwipeDismissListSwipe rows away, the rest closing up, with undo in batches that come back in order.3 views · 5 downloadsActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloadsAlertInboxAlerts by severity, swiped to acknowledge or snooze, with buttons and keys to the same, and a badge that stays right as snoozes run out.3 views · 8 downloadsAlphaRailListA long list with sticky letter headers and an A to Z rail scrubbed by finger, with a bubble showing the letter and a haptic per step.1 view · 6 downloadsApprovalQueueOne request at a time, swiped to approve or reject, with a required reason, undo and a count of what is left.3 views · 5 downloadsBookSpreadReader pages, one column upright and a two-page spread turned, by swipe or edge tap, the place kept by text and not page number.2 views · 7 downloads
Thread
Thread 0 posts
On SwipeToast: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.