Demo
Tries: 0.
Cleared: 0.
1 thing did not load. It will be tried again.
1 thing did not load
Photos
Has not given up.
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/retry_banner.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/retry_banner.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/retry_banner.qwe
component RetryBannerDemoLive() { state failed = [] state mended = [] state tries = 0 state log = "" state cleared = 0 state busy = false state gave_up = "Has not given up." // A pretend server, a moment away. Named, as js_new is not waited for in a function written // in the markup's prop. let load = (l) => { tries += 1 log = `${log}${l.id} ` let pause = js("Promise.resolve", js_new("Promise", (done) => { js("AbortSignal.timeout", 30).addEventListener("abort", (_e) => { done(true) }) })) if busy { return { retry_after: 3 } } pause && contains(mended, l.id) } <div.demo-stack> <RetryBanner id="rtry-demo" loads={failed} base_ms={1000} limit_ms={4000} jitter={0} on_retry={load} on_clear={() => { cleared += 1 failed = [] }}/> <div.demo-row> <Button size="lg" variant="outline" id="rtry-fail" on_click={() => { failed = [{ id: "prices", label: "Prices" }, "Stock"]; mended = [] }}>Fail two loads</Button> <Button size="lg" variant="outline" id="rtry-mend-a" on_click={() => { mended = mended | push("prices") }}>Mend prices</Button> <Button size="lg" variant="outline" id="rtry-mend-b" on_click={() => { mended = mended | push("Stock") }}>Mend stock</Button> <Button size="lg" variant="outline" id="rtry-busy" on_click={() => { busy = !busy }}>{busy ? "Server is back" : "Server says wait"}</Button> <Button size="lg" variant="outline" id="rtry-none" on_click={() => { failed = [] }}>The page drops them</Button> </div> <p.demo-note id="rtry-tries">{`Tries: ${tries}.`}</p> <p.demo-note id="rtry-log">{log}</p> <p.demo-note id="rtry-cleared">{`Cleared: ${cleared}.`}</p> <RetryBanner id="rtry-stops" loads={["Photos"]} base_ms={300} factor={1} jitter={0} max_tries={2} on_retry={(_l) => { false }} on_give_up={(ls) => { gave_up = `Gave up on ${len(ls)}.` }}/> <p.demo-note id="rtry-gave">{gave_up}</p> </div>}view RetryBannerDemo() = <RetryBannerDemoLive/>Source
ui/retry_banner.qwe
// RetryBanner: what failed to load, and when it will be tried again, as a countdown that// backs off each time, with a Try now. It tries by itself, stops counting while offline// and tries at once on return, waits while the app is in the background, and goes away when// everything has loaded.//// import ui/retry_banner.qwe use RetryBanner//// component Prices() {// state failed = []// <RetryBanner loads={failed} on_retry={(l) => { load_again(l.id) }} on_clear={() => { failed = [] }}/>// }//// loads: what failed, each { id, label } or a word. Give a new list as things fail; one// taken out of the list goes, and an id that fails again later comes back.// on_retry(load): tries one again, and may wait. Anything but these counts as loaded: false,// { ok: false }, an error value, or { retry_after: seconds }, for a server that said when// (Retry-After), which is waited for in place of the back-off. on_clear(): all loaded.// on_give_up(loads): max_tries were used up.// base_ms: the first wait (2000). factor: what each wait is multiplied by (2). limit_ms: the// longest wait (60000). jitter: a share of the wait added at random, so that a thousand// phones do not come back in one moment (0.1). max_tries: stop counting after this many// failures in a row, leaving Try now (never). title: the heading, for what it says in place of// "2 things did not load". place: "inline", "top" or "bottom", the last two fixed to the// screen, clear of the notch and the home bar. wait_ms: how long a try may take (20000). id, class.//// A try that loads anything starts the back-off again, as the far end is plainly alive. Try// now does not lengthen it: a person pressing it is not a machine failing. The countdown goes// by the clock, so it is right after the system has stopped the page's timers.//// retry_banner_delay(attempt, base, factor, limit) is the wait, and// retry_banner_outcome(answer) reads an answer.import ui/theme.qwe use ui_clslet retry_banner_css = css`.ui-rtry-host { position: relative; min-width: 0; }.ui-rtry { container-type: inline-size; box-sizing: border-box; min-width: 0; border: 1px solid color-mix(in srgb, var(--ui-danger) 40%, var(--ui-border)); border-radius: var(--ui-radius-sm); background: var(--ui-danger-soft); color: var(--ui-fg); font: 15px/1.4 var(--ui-font); }.ui-rtry-top { position: fixed; z-index: 45; top: 0; left: 0; right: 0; padding-top: env(safe-area-inset-top); border-width: 0 0 1px; border-radius: 0; }.ui-rtry-bottom { position: fixed; z-index: 45; bottom: 0; left: 0; right: 0; padding-bottom: env(safe-area-inset-bottom); border-width: 1px 0 0; border-radius: 0; }.ui-rtry-in { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 12px; align-items: center; padding: 10px 12px; padding-left: max(12px, env(safe-area-inset-left)); padding-right: max(12px, env(safe-area-inset-right)); }.ui-rtry-text { display: grid; gap: 1px; min-width: 0; }.ui-rtry-title { margin: 0; font-weight: 600; }.ui-rtry-what { margin: 0; overflow-wrap: anywhere; color: var(--ui-muted); font-size: 14px; }.ui-rtry-line { margin: 0; font-size: 14px; font-variant-numeric: tabular-nums; }.ui-rtry-btn { min-width: 88px; min-height: 46px; padding: 0 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 600 14px/1 var(--ui-font); cursor: pointer; touch-action: manipulation; }.ui-rtry-btn:disabled { color: var(--ui-muted); cursor: default; }.ui-rtry-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-rtry-track { height: 3px; overflow: hidden; background: color-mix(in srgb, var(--ui-danger) 15%, transparent); }.ui-rtry-fill { height: 100%; background: var(--ui-danger); transition: width .25s linear; }.ui-rtry-ok { margin: 0; padding: 10px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-success-soft); color: var(--ui-fg); font: 15px/1.4 var(--ui-font); }@media (hover: hover) { .ui-rtry-btn:hover:enabled { border-color: var(--ui-strong); background: var(--ui-subtle); } }@media (prefers-reduced-motion: reduce) { .ui-rtry-fill { transition: none; } }`// The list as it should be: { id, label }, a word counting as both.fn retry_banner_clean(loads) -> list { if type_of(loads) != "list" { return [] } let good = loads | map((l) => { if type_of(l) == "str" && l != "" { return { id: l, label: l } } if type_of(l) == "map" && l.id != null { return { id: str(l.id), label: str(l.label ?? l.id) } } null }) | filter((l) => l != null) let ids = good | map((l) => l.id) good | enumerate | filter((p) => index_of(ids, p[1].id) == p[0]) | map((p) => p[1])}// How long to wait before a try, given the failures so far. The power is held down, so// nothing grows past what a number can hold.fn retry_banner_delay(attempt: int, base: int, factor: num, limit: int) -> int { let once = max([1, base]) let times = pow(max([1, factor]), min([max([0, attempt]), 40])) min([limit, round(once * times)])}// What an answer means: { ok, after (seconds, or null) }.fn retry_banner_outcome(r) -> map { if is_err(r) || r == false { return { ok: false, after: null } } if type_of(r) == "map" { let after = type_of(r.retry_after) == "int" || type_of(r.retry_after) == "float" ? r.retry_after : null if after != null && after > 0 { return { ok: false, after } } if r.ok == false { return { ok: false, after: null } } } { ok: true, after: null }}fn retry_banner_title(n: int) -> str = n == 1 ? "1 thing did not load" : `${n} things did not load`// A wait as words.fn retry_banner_wait(s: int) -> str = s < 60 ? `${s} s` : (s % 60 == 0 ? `${floor(s / 60)} min` : `${floor(s / 60)} min ${s % 60} s`)// The line under it, for each state it can be in.fn retry_banner_line(trying: bool, offline: bool, stopped: bool, left) -> str { if trying { return "Trying again…" } if offline { return "Waiting for the connection." } if stopped { return "Stopped trying. Try now when you are ready." } left != null ? `Trying again in ${retry_banner_wait(left)}` : ""}// What a screen reader hears: the state, not every second of the countdown.fn retry_banner_said(n: int, trying: bool, offline: bool, stopped: bool, shown: bool) -> str { if n == 0 { return shown ? "All loaded." : "" } if trying { return "Trying again." } if offline { return `${retry_banner_title(n)}. Waiting for the connection.` } if stopped { return `${retry_banner_title(n)}. Stopped trying.` } `${retry_banner_title(n)}. It will be tried again.`}component RetryBanner(loads = [], on_retry = null, on_clear = null, on_give_up = null, base_ms: int = 2000, factor: num = 2, limit_ms: int = 60000, jitter: num = 0.1, max_tries: int? = null, title: str? = null, place: str = "inline", wait_ms: int = 20000, id: str? = null, class: str? = null) { style global retry_banner_css let all = retry_banner_clean(loads) state cleared = [] state started = false state attempt = 0 state deadline = null state span = 1 state rest = 0 state trying = false state stopped = false state offline = false state hidden = false state flash = false let open = all | filter((l) => !contains(cleared, l.id)) let left = deadline != null ? ceil(rest / 1000) : null let share = deadline != null ? round(rest * 100 / span) : 0 // One no longer listed may fail again later. if len(cleared | filter((c) => !contains(all | map((l) => l.id), c))) > 0 { cleared = cleared | filter((c) => contains(all | map((l) => l.id), c)) } let plan = (ms: int) => { span = ms rest = ms deadline = now() + ms } let extra = () => floor(retry_banner_delay(attempt, base_ms, factor, limit_ms) * jitter * random()) // What failed is there, and nothing is counting: start. Also the first time, and after a return. if started && len(open) > 0 && deadline == null && !trying && !stopped && !offline { plan(retry_banner_delay(attempt, base_ms, factor, limit_ms) + extra()) } let call = (l: map) => { js("Promise.resolve", js_new("Promise", (done) => { js("AbortSignal.timeout", wait_ms).addEventListener("abort", (_e = null) => { done(err("no answer")) }) js("Promise.resolve", on_retry({ id: l.id, label: l.label })).then(done, (_e = null) => { done(err("failed")) }) })) } let attempt_all = (manual: bool) => { if !trying && on_retry != null { trying = true deadline = null stopped = false let good = false let after = null for l in open { let out = retry_banner_outcome(call(l)) if out.ok { cleared = cleared | push(l.id) good = true } else if out.after != null { after = out.after } } let still = all | filter((l) => !contains(cleared, l.id)) if len(still) == 0 { attempt = 0 flash = true trying = false on_clear && on_clear() } else { attempt = good ? 0 : (manual ? attempt : attempt + 1) if !good && max_tries != null && attempt >= max_tries { stopped = true on_give_up && on_give_up(still) } else if after != null { plan(round(after * 1000)) } trying = false } } } let tick = () => { if deadline != null && !hidden && !offline { let ms = deadline - now() rest = max([0, ms]) if ms <= 0 { attempt_all(false) } } } if started && len(open) > 0 { every(250, tick) } if flash { every(2500, () => { flash = false }) } <div.ui-rtry-host @mount={(_el) => { let w = js("window") let d = js("document") offline = js("navigator.onLine") == false hidden = d.hidden let went = (_e = null) => { offline = true deadline = null } let came = (_e = null) => { offline = false if len(all | filter((l) => !contains(cleared, l.id))) > 0 { attempt_all(false) } } let turned = (_e = null) => { hidden = d.hidden tick() } w.addEventListener("offline", went) w.addEventListener("online", came) d.addEventListener("visibilitychange", turned) started = true () => { w.removeEventListener("offline", went) w.removeEventListener("online", came) d.removeEventListener("visibilitychange", turned) } }}> <span.ui-sr role="status">{retry_banner_said(len(open), trying, offline, stopped, flash)}</span> {len(open) > 0 && <div class={ui_cls(["ui-rtry", place == "top" && "ui-rtry-top", place == "bottom" && "ui-rtry-bottom", class])} id={id}> <div.ui-rtry-in> <div.ui-rtry-text> <p.ui-rtry-title>{title ?? retry_banner_title(len(open))}</p> <p.ui-rtry-what>{join(open | map((l) => l.label), ", ")}</p> <p.ui-rtry-line>{retry_banner_line(trying, offline, stopped, left)}</p> </div> {on_retry != null && <button.ui-rtry-btn type="button" disabled={trying} @click={() => { attempt_all(true) }}>Try now</button>} </div> <div.ui-rtry-track aria-hidden="true"><div.ui-rtry-fill style={`width: ${share}%`}></div></div> </div>} {len(open) == 0 && flash && <p.ui-rtry-ok>All loaded</p>} </div>}Related
ErrorScreenA full screen for failure with the reason, retry, and offline told apart from server trouble.3 views · 8 downloadsAssetLookupScan or type an asset tag to see its record, history and what to do, with near matches and a report for a tag nobody knows.3 views · 7 downloadsConflictSheetThis device's version against the server's, chosen field by field, explained in plain words.2 views · 6 downloadsDataSaverToggleA setting to keep lighter images and off autoplay, following the phone's save-data, with what it changes.4 views · 5 downloadsDraftKeeperKeeps what was typed on this device through a reload and a crash, offers it back, and clears on send.2 views · 7 downloadsInspectionFormA checklist of pass, fail and N/A with big targets, a note and stamped photo needed on a fail, a score, and a draft kept.2 views · 5 downloads
Thread
Thread 0 posts
On RetryBanner: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.