DownloadsList
Items with size, progress, pause and retry, the storage used, and a Wi-Fi only setting that holds to the network.
Demo
371.5 MB used of 5 GB
Episode 12: Views first
50%, 20.5 MB of 41 MB
Harbour film
25%, 300 MB of 1.2 GB
Salt, the album
Paused, 30 MB of 88 MB
Salt roads
Connection lost, 12 MB of 62 MB
Fog
Queued, 15 MB
Weather for sailors
9 MB
3 downloads going
Last none; held no; setting unset
0 B used
Nothing downloaded yet.
0 downloads going
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/downloads_list.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/downloads_list.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/downloads_list.qwe
let downloads_list_demo_items = [ { id: "a", title: "Episode 12: Views first", size: 41000000, done: 20500000, state: "loading" }, { id: "b", title: "Harbour film", size: 1200000000, done: 300000000, state: "loading" }, { id: "c", title: "Salt, the album", size: 88000000, done: 30000000, state: "paused" }, { id: "d", title: "Salt roads", size: 62000000, done: 12000000, state: "failed", error: "Connection lost" }, { id: "e", title: "Fog", size: 15000000, state: "queued" }, { id: "f", title: "Weather for sailors", size: 9000000, state: "done" }, { title: "no id" }, "bad",]fn downloads_list_demo_on(d, field: str, v) = type_of(d) == "map" ? set(d, field, v) : dcomponent DownloadsListDemoLive() { state list_of = downloads_list_demo_items state turn = 0 state said = "none" state hold = "no" state on_data = "unset" let mark = (d, st) => { list_of = list_of | map((x) => type_of(x) == "map" && get(x, "id") == d.id ? downloads_list_demo_on(x, "state", st) : x) } <div.demo-stack> <DownloadsList key={turn} items={list_of} quota={5000000000} on_pause={(d) => { said = `pause ${d.id}` }} on_resume={(d) => { said = `resume ${d.id}`; mark(d, "loading") }} on_retry={(d) => { said = `retry ${d.id}`; mark(d, "loading") }} on_remove={(d) => { said = `remove ${d.id}` }} on_wifi_only={(on) => { on_data = `${on}` }} on_hold={(held) => { hold = held ? "yes" : "no" }} on_clear={(done) => { said = `clear ${len(done)}`; list_of = list_of | filter((x) => type_of(x) != "map" || get(x, "state") != "done") }}/> <p.demo-note id="dlist-demo-note">Last {said}; held {hold}; setting {on_data}</p> <div.demo-row> <Button id="dlist-demo-more" size="lg" variant="ghost" on_click={() => { list_of = list_of | map((x) => type_of(x) == "map" && get(x, "state") == "loading" ? set(x, "done", min([get(x, "size") ?? 0, (get(x, "done") ?? 0) + 20500000])) : x) }}>A bit more arrives</Button> <Button id="dlist-demo-again" size="lg" variant="ghost" on_click={() => { list_of = downloads_list_demo_items; turn += 1 }}>Make it again</Button> </div> <div id="dlist-demo-none"><DownloadsList items={null}/></div> </div>}view DownloadsListDemo() = <DownloadsListDemoLive/>Source
ui/downloads_list.qwe
// DownloadsList: what is downloading and what is done, each with its size, progress and the right// button: pause, resume, retry. It shows the space downloads take and the room left on the device, and// holds to Wi-Fi only: on a phone's data network what is running waits, says why, and goes on// by itself when Wi-Fi comes back. A tablet keeps the storage and the setting in a column beside the list.//// import ui/downloads_list.qwe use DownloadsList//// <DownloadsList items={downloads} wifi_only={true}// on_pause={(d) => { pause(d.id) }} on_resume={(d) => { resume(d.id) }} on_retry={(d) => { resume(d.id) }}// on_remove={(d) => { remove(d.id) }} on_wifi_only={(on) => { save_setting(on) }}// on_hold={(held) => { hold_all(held) }}/>//// It shows and decides; the bytes are yours, because where they should go depends on the app. In a phone app// the program's own server is on the phone, so a `server fn` is the place that survives a launch.// items: each { id, title, size, done, state, error }, with size and done in bytes, and state one of// "queued", "loading", "paused", "failed" or "done". wifi_only: start with the setting on (false).// quota: bytes the app allows itself, for "used of"; without it the device's own estimate is used.// label: what screen readers call it.//// on_pause(item), on_resume(item), on_retry(item), on_remove(item): inside one of your components, on// those buttons. The row changes at once, and your next `items` has the last word. on_wifi_only(on): the// setting changed. on_hold(held): true when downloads must stop for the network, false when they may go on.// on_clear(items): Clear finished was pressed.//// iPhones and many browsers do not say which network they are on. Where that is so, it says on screen that// Wi-Fi only cannot be kept to, and goes by online and offline alone.import ui/theme.qwe use ui_clslet downloads_list_css = css`.ui-dlist { min-width: 0; container: ui-dlist / inline-size; color: var(--ui-fg); font: 14px/1.35 var(--ui-font); }.ui-dlist-in { display: grid; gap: 12px; grid-template-columns: minmax(0, 1fr); }.ui-dlist-side { display: grid; gap: 10px; align-content: start; padding: 12px 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); }.ui-dlist-used { margin: 0; font-size: 15px; font-weight: 650; font-variant-numeric: tabular-nums; }.ui-dlist-free { margin: 0; color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-dlist-meter { height: 8px; overflow: hidden; border-radius: 999px; background: var(--ui-subtle); }.ui-dlist-meter i { display: block; height: 100%; background: var(--ui-accent); transform-origin: 0 50%; }.ui-dlist-full i { background: var(--ui-danger); }.ui-dlist-wifi { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 48px; cursor: pointer; font-weight: 600; }/* A real input, drawn as a switch, and 44 px tall itself: the track is the part that shows. */.ui-dlist-wifi input { position: relative; flex: none; width: 52px; height: 44px; margin: 0; appearance: none; -webkit-appearance: none; background: none; cursor: pointer; touch-action: manipulation; }.ui-dlist-wifi input::before { content: ""; position: absolute; top: 8px; left: 0; right: 0; height: 28px; border-radius: 999px; background: var(--ui-strong); transition: background .15s; }.ui-dlist-wifi input::after { content: ""; position: absolute; top: 10px; left: 2px; width: 24px; height: 24px; border-radius: 50%; background: #fff; box-shadow: var(--ui-shadow); transition: transform .15s; }.ui-dlist-wifi input:checked::before { background: var(--ui-accent); }.ui-dlist-wifi input:checked::after { transform: translateX(24px); }.ui-dlist .ui-dlist-wifi input:focus-visible { box-shadow: none; }.ui-dlist-wifi input:focus-visible::before { box-shadow: var(--ui-focus); }.ui-dlist-why { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-dlist-held { color: var(--ui-warning); font-weight: 600; }.ui-dlist-list { display: grid; margin: 0; padding: 0; list-style: none; }.ui-dlist-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 8px; align-items: center; padding: 10px 2px; border-bottom: 1px solid var(--ui-border); }.ui-dlist-title { margin: 0; overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }.ui-dlist-state { margin: 0; color: var(--ui-muted); font-size: 12.5px; font-variant-numeric: tabular-nums; }.ui-dlist-failed .ui-dlist-state { color: var(--ui-danger); }.ui-dlist-waiting .ui-dlist-state { color: var(--ui-warning); }.ui-dlist-bar { grid-column: 1; height: 4px; margin-top: 4px; overflow: hidden; border-radius: 999px; background: var(--ui-subtle); }.ui-dlist-bar i { display: block; height: 100%; background: var(--ui-accent); transform-origin: 0 50%; }.ui-dlist-paused .ui-dlist-bar i, .ui-dlist-waiting .ui-dlist-bar i { background: var(--ui-strong); }.ui-dlist-failed .ui-dlist-bar i { background: var(--ui-danger); }.ui-dlist-acts { grid-column: 2; grid-row: 1 / span 3; display: flex; gap: 2px; }.ui-dlist-btn { display: grid; place-items: center; min-width: 46px; min-height: 46px; padding: 0 6px; border: 0; border-radius: 12px; background: none; color: var(--ui-fg); font: 600 13px var(--ui-font); cursor: pointer; touch-action: manipulation; }.ui-dlist-btn svg { width: 22px; height: 22px; }.ui-dlist-clear { justify-self: start; min-height: 46px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: 12px; background: var(--ui-surface); color: var(--ui-fg); font: 600 14px var(--ui-font); cursor: pointer; touch-action: manipulation; }.ui-dlist-empty { margin: 0; padding: 32px 16px; text-align: center; color: var(--ui-muted); }.ui-dlist :focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (hover: hover) { .ui-dlist-btn:hover { background: var(--ui-subtle); } }@media (prefers-reduced-motion: reduce) { .ui-dlist-wifi input::before, .ui-dlist-wifi input::after { transition: none; } }/* Wide: the storage and the setting keep a column of their own, and a row has room for its size. */@container ui-dlist (min-width: 640px) { .ui-dlist-in { grid-template-columns: minmax(0, 1fr) 260px; gap: 20px; } .ui-dlist-side { grid-column: 2; grid-row: 1; align-self: start; position: sticky; top: 12px; } .ui-dlist-main { grid-column: 1; grid-row: 1; }}`fn downloads_list_num(v) { let t = type_of(v) if t != "int" && t != "float" { return 0 } math.isfinite(v) && v > 0 ? v : 0}let downloads_list_states = ["queued", "loading", "paused", "failed", "done"]// Items that can be shown: each a map with an id, in a state it knows, done kept within size, once each.fn downloads_list_clean(items) -> list { if type_of(items) != "list" { return [] } let rows = items | filter((x) => type_of(x) == "map" && get(x, "id") != null) | map((x) => { let size = downloads_list_num(get(x, "size")) let st = contains(downloads_list_states, get(x, "state")) ? x.state : "queued" let got = st == "done" ? size : downloads_list_num(get(x, "done")) { id: str(x.id), title: str(get(x, "title") ?? "Untitled"), size: size, done: size > 0 ? min([got, size]) : got, state: st, error: str(get(x, "error") ?? "") } }) let ids = rows | map((r) => r.id) rows | enumerate() | filter((p) => index_of(ids, p[1].id) == p[0]) | map((p) => p[1])}// Bytes as a person says them, by thousands, as phones do.fn downloads_list_size(b) -> str { let n = downloads_list_num(b) if n < 1000 { return `${floor(n)} B` } if n < 1000000 { return `${round(n / 1000)} KB` } if n < 1000000000 { return `${round(n / 1000000, 1)} MB` } `${round(n / 1000000000, 2)} GB`}// How far along, as a share.fn downloads_list_share(d: map) = d.size > 0 ? min([d.done / d.size, 1]) : 0// Whether the network stops a download: on data with Wi-Fi only, or with no network at all.fn downloads_list_hold(wifi_only: bool, kind, online: bool) -> str { if !online { return "offline" } if wifi_only && kind == "cellular" { return "data" } ""}// What a row says.fn downloads_list_says(d: map, held: str) -> str { let of = `${downloads_list_size(d.done)} of ${downloads_list_size(d.size)}` if d.state == "done" { return downloads_list_size(d.size) } if d.state == "failed" { return d.error != "" ? `${d.error}, ${of}` : `Failed, ${of}` } if d.state == "paused" { return `Paused, ${of}` } if held == "data" { return `Waiting for Wi-Fi, ${of}` } if held == "offline" { return `Waiting for a connection, ${of}` } if d.state == "queued" { return `Queued, ${downloads_list_size(d.size)}` } `${round(downloads_list_share(d) * 100)}%, ${of}`}let downloads_list_pause = "M6 4h4v16H6zM14 4h4v16h-4z"let downloads_list_play = "M7 4v16l14-8z"component DownloadsList(items = [], wifi_only: bool = false, quota = null, label: str = "Downloads", on_pause = null, on_resume = null, on_retry = null, on_remove = null, on_wifi_only = null, on_hold = null, on_clear = null, class: str? = null) { style global downloads_list_css let from_page = downloads_list_clean(items) state changed = [] // what a press changed, until the page's next `items`: { id, state } or { id, gone } state wifi = wifi_only state kind = null // "wifi", "cellular" or null for not known state told_kind = false // the browser can say which network this is state online = true state spare = null // what the device's own estimate leaves state told_hold = false let list_now = from_page | filter((d) => find(changed, (c) => c.id == d.id && c.gone) == null) | map((d) => { let c = find(changed, (x) => x.id == d.id) c == null ? d : set(d, "state", c.state) }) let held = downloads_list_hold(wifi, kind, online) let on_disk = sum(list_now | map((d) => d.done)) let limit = downloads_list_num(quota) let share = limit > 0 ? min([on_disk / limit, 1]) : 0 let finished = list_now | filter((d) => d.state == "done") let going = len(list_now | filter((d) => d.state == "loading" || d.state == "queued")) let local_change = (d, st) => { changed = (changed | filter((c) => c.id != d.id)) | push({ id: d.id, state: st, gone: false }) } let look = () => { let nav = js("navigator") let net = nav.connection online = nav.onLine != false if net != null && net.type != null { told_kind = true kind = net.type } } // A new list from the page is the truth: what a press changed was only until it came. let sig = join(from_page | map((d) => `${d.id}:${d.state}`), ",") state seen = sig if sig != seen { seen = sig changed = [] } let stopped_now = held != "" if stopped_now != told_hold { told_hold = stopped_now on_hold && on_hold(stopped_now) } <div class={ui_cls(["ui-dlist", class])} role="region" aria-label={label} @mount={(el) => { let win = js("window") look() let net = win.navigator.connection let changed_net = (_e = null) => { look() } win.addEventListener("online", changed_net) win.addEventListener("offline", changed_net) if net != null && net.addEventListener != null { net.addEventListener("change", changed_net) } // Not on every system, and a refusal is only an empty line. let st = win.navigator.storage if st != null && st.estimate != null { st.estimate().then((e = null) => { if e != null && e.quota != null { spare = max([0, e.quota - e.usage]) } }, (_x = null) => { spare = null }) } () => { win.removeEventListener("online", changed_net) win.removeEventListener("offline", changed_net) if net != null && net.removeEventListener != null { net.removeEventListener("change", changed_net) } } }}> <div.ui-dlist-in> <div.ui-dlist-side> <p.ui-dlist-used>{downloads_list_size(on_disk)} used{limit > 0 ? ` of ${downloads_list_size(limit)}` : ""}</p> {limit > 0 && <div class={ui_cls(["ui-dlist-meter", share >= 0.95 && "ui-dlist-full"])} role="meter" aria-label="Storage used" aria-valuemin="0" aria-valuemax="100" aria-valuenow={round(share * 100)}> <i style={`transform: scaleX(${share})`}></i> </div>} {spare != null && <p.ui-dlist-free>{downloads_list_size(spare)} free for this app</p>} <label.ui-dlist-wifi> <span>Wi-Fi only</span> <input type="checkbox" role="switch" checked={wifi} @change={(e) => { wifi = e.checked on_wifi_only && on_wifi_only(e.checked) }}/> </label> {wifi && !told_kind && <p.ui-dlist-why>This device does not say which network it is on, so Wi-Fi only cannot be kept to here.</p>} <p.ui-dlist-why.ui-dlist-held role="status">{held == "data" ? "On mobile data: waiting for Wi-Fi." : held == "offline" ? "No connection: waiting." : ""}</p> </div> <div.ui-dlist-main> {len(list_now) == 0 && <p.ui-dlist-empty>Nothing downloaded yet.</p>} <ul.ui-dlist-list> {for d in list_now => { let waits = held != "" && (d.state == "loading" || d.state == "queued") <li class={ui_cls(["ui-dlist-item", `ui-dlist-${d.state}`, waits && "ui-dlist-waiting"])} key={d.id} data-id={d.id}> <p.ui-dlist-title>{d.title}</p> <p.ui-dlist-state>{downloads_list_says(d, held)}</p> {d.state != "done" && <div.ui-dlist-bar role="progressbar" aria-label={`${d.title}: progress`} aria-valuemin="0" aria-valuemax="100" aria-valuenow={round(downloads_list_share(d) * 100)}> <i style={`transform: scaleX(${downloads_list_share(d)})`}></i> </div>} <div.ui-dlist-acts> {(d.state == "loading" || d.state == "queued") && <button.ui-dlist-btn type="button" aria-label={`Pause ${d.title}`} @click={() => { local_change(d, "paused") on_pause && on_pause(d) }}> <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d={downloads_list_pause}/></svg> </button>} {d.state == "paused" && <button.ui-dlist-btn type="button" aria-label={`Resume ${d.title}`} disabled={held != ""} @click={() => { local_change(d, "loading") on_resume && on_resume(d) }}> <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d={downloads_list_play}/></svg> </button>} {d.state == "failed" && <button.ui-dlist-btn type="button" aria-label={`Retry ${d.title}`} disabled={held != ""} @click={() => { local_change(d, "loading") on_retry && on_retry(d) }}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.700L21 8M21 3v5h-5"/></svg> </button>} <button.ui-dlist-btn type="button" aria-label={`Remove ${d.title}`} @click={() => { changed = changed | push({ id: d.id, state: d.state, gone: true }) on_remove && on_remove(d) }}> <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> </li> }} </ul> {len(finished) > 0 && on_clear != null && <div style="padding-top: 10px"> <button.ui-dlist-clear type="button" @click={() => { on_clear(finished) }}>Clear finished</button> </div>} <p.ui-sr role="status">{going} {going == 1 ? "download" : "downloads"} going</p> </div> </div> </div>}Related
DoubleTapLikeDouble tap a photo card to like it, a heart bursting at the finger, with a plain Like button as the equal.3 views · 5 downloadsMiniPlayerA bar above the tabs with progress and play, expanding to the full player by a swipe up.2 views · 5 downloadsNowPlayingA full player with art, scrub, queue, speed and a sleep timer, tied to the media session.2 views · 5 downloadsPodcastQueueEpisodes reordered by a grip, with played state, resume position, downloads and the time remaining.1 view · 6 downloadsShortVideoFeedVertical swipes of clips: one plays at a time, the next loads ahead, sound behaves, and a refused autoplay shows a button.4 views · 4 downloadsStoryViewerStories with segmented progress: hold to pause, tap to go on or back, swipe down to leave, with the next ones fetched ahead.3 views · 7 downloads
Thread
Thread 0 posts
On DownloadsList: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.