PullToRefresh
Pull a list down from its top to refresh, with resistance, a spinner, and success and failure states.
Demo
Ana CostaSent the signed lease
Ben OkaforMoved the viewing to Friday
Chloe MartinPaid the deposit
Dev PatelAsked about parking
Elena RossiBooked the movers
Farid HaddadReported a leaking tap
Grace LinRenewed for a year
Hugo BlancReturned the keys
Ines SilvaSent the meter reading
Jonas WeberAdded a second tenant
Kira NovakChanged the bank details
Liam ByrneAsked for a copy of the lease
Flat 13Sent the meter reading
Flat 14Sent the meter reading
Flat 15Sent the meter reading
Flat 16Sent the meter reading
Flat 17Sent the meter reading
Flat 18Sent the meter reading
Flat 19Sent the meter reading
Flat 20Sent the meter reading
Flat 21Sent the meter reading
Flat 22Sent the meter reading
Flat 23Sent the meter reading
Flat 24Sent the meter reading
Flat 25Sent the meter reading
Flat 26Sent the meter reading
Flat 27Sent the meter reading
Flat 28Sent the meter reading
Flat 29Sent the meter reading
Flat 30Sent the meter reading
Refreshes finished: 0. Pull from the top of the list, or use the button.
A Promise of true
A Promise of false
A Promise of nothing
A Promise refused
Plain true
Plain false
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/pull_to_refresh.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/pull_to_refresh.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/pull_to_refresh.qwe
let pull_to_refresh_demo_rows = [ { id: 1, who: "Ana Costa", text: "Sent the signed lease" }, { id: 2, who: "Ben Okafor", text: "Moved the viewing to Friday" }, { id: 3, who: "Chloe Martin", text: "Paid the deposit" }, { id: 4, who: "Dev Patel", text: "Asked about parking" }, { id: 5, who: "Elena Rossi", text: "Booked the movers" }, { id: 6, who: "Farid Haddad", text: "Reported a leaking tap" }, { id: 7, who: "Grace Lin", text: "Renewed for a year" }, { id: 8, who: "Hugo Blanc", text: "Returned the keys" }, { id: 9, who: "Ines Silva", text: "Sent the meter reading" }, { id: 10, who: "Jonas Weber", text: "Added a second tenant" }, { id: 11, who: "Kira Novak", text: "Changed the bank details" }, { id: 12, who: "Liam Byrne", text: "Asked for a copy of the lease" },] + (for i in range(13, 31) => { id: i, who: `Flat ${i}`, text: "Sent the meter reading" })let pull_to_refresh_demo_css = css`.demo-pull-row { display: block; padding: 12px 16px; border-bottom: 1px solid var(--ui-border); }.demo-pull-row b { display: block; font-weight: 600; }.demo-pull-row span { color: var(--ui-muted); font-size: 14px; }`component PullToRefreshDemoLive() { style global pull_to_refresh_demo_css state rows = pull_to_refresh_demo_rows state fail_next = false state finish = null // the done of the refresh under way: a timer stands in for the network state added = 0 state count = 0 state tall = "100%" if finish != null { every(700, () => { let done = finish finish = null count += 1 if !fail_next { added += 1 rows = [{ id: 100 + added, who: "New message", text: `Arrived in refresh ${added}` }] + rows } done(!fail_next) }) } <div.demo-stack> <div.demo-tablet> <PullToRefresh height={tall} label="Messages" on_refresh={(done) => { finish = done }}> {for r in rows => <div.demo-pull-row key={r.id}><b>{r.who}</b><span>{r.text}</span></div>} </PullToRefresh> </div> <label.demo-note style="display: flex; align-items: center; gap: 8px; min-height: 44px"> <input type="checkbox" id="pull-demo-fail" bind:checked={fail_next}/> The next refresh fails </label> <p.demo-note id="pull-demo-count">Refreshes finished: {count}. Pull from the top of the list, or use the button.</p> // For the test: a prop changing in mid-pull, and each way a callback can answer. <div id="pull-demo-hooks" hidden="true"> <button id="pull-demo-resize" type="button" @click={() => { tall = "99%" }}>Resize</button> <PullToRefresh height="80px" on_refresh={(_done) => js("Promise.resolve", true)}>A Promise of true</PullToRefresh> <PullToRefresh height="80px" on_refresh={(_done) => js("Promise.resolve", false)}>A Promise of false</PullToRefresh> <PullToRefresh height="80px" on_refresh={(_done) => js("Promise.resolve")}>A Promise of nothing</PullToRefresh> <PullToRefresh height="80px" on_refresh={(_done) => js("Promise.reject", "no")}>A Promise refused</PullToRefresh> <PullToRefresh height="80px" on_refresh={(_done) => true}>Plain true</PullToRefresh> <PullToRefresh height="80px" on_refresh={(_done) => false}>Plain false</PullToRefresh> </div> </div>}view PullToRefreshDemo() = <PullToRefreshDemoLive/>Source
ui/pull_to_refresh.qwe
// PullToRefresh: a scrolling area that refreshes when it is pulled down from its top,// as a phone's lists do. The pull slows as it goes, past a threshold it says so, and on// release it holds a spinner until the work is done, then says whether it worked. It// only takes a pull that starts at the very top and goes down, with that top on the screen:// otherwise the area, or the page around it, scrolls as usual. It also stops the browser's// own pull from reloading the page.// A Refresh button beside it does the same for keyboards and screen readers.//// component Feed() {// state rows = first_rows()// <PullToRefresh height="60dvh" on_refresh={(done) => {// rows = load_rows() // a server fn: the page waits for it here// done(true)// }}>// {for r in rows => <p key={r.id}>{r.title}</p>}// </PullToRefresh>// }//// on_refresh(done): inside one of your components, called once per refresh. It must take// that one parameter. Call done(true) when it worked and done(false), or done with an error// value, when it did not; a call made after a timer or from another callback is fine. Or// give the answer back: true, false, an error value, or a Promise of one, which is waited// for, nothing in it meaning it worked and a refusal that it did not.// height: how tall the whole thing is, as CSS ("100%", for a screen that sets its own).// threshold: how far the pull must show, in pixels, to count (70). limit: how far it// can show, which sets the resistance: limit * d / (d + limit) for a finger moved d (180).// timeout: seconds to wait for done before calling it a failure (30).// label: what screen readers call the area. pull_text, ready_text, busy_text, done_text,// failed_text: what it says at each step. disabled: no pull and no button. class.// children: what scrolls.import ui/theme.qwe use ui_clslet pull_to_refresh_css = css`.ui-pull-refresh { position: relative; display: flex; flex-direction: column; min-height: 0; overflow: hidden; container-type: inline-size; background: var(--ui-bg); color: var(--ui-fg); font: 15px/1.5 var(--ui-font); }.ui-pull-refresh-bar { display: flex; flex: none; align-items: center; gap: 8px; min-height: 52px; padding: 4px 12px; border-bottom: 1px solid var(--ui-border); background: var(--ui-surface); }.ui-pull-refresh-status { flex: 1; min-width: 0; color: var(--ui-muted); font-size: 13px; }.ui-pull-refresh-btn { display: inline-flex; flex: none; align-items: center; justify-content: center; gap: 6px; min-width: 44px; min-height: 44px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 14px/1 var(--ui-font); cursor: pointer; }.ui-pull-refresh-btn svg { width: 20px; height: 20px; }.ui-pull-refresh-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-pull-refresh-btn[aria-disabled="true"] { color: var(--ui-muted); cursor: default; }.ui-pull-refresh-btn-text { display: none; }@media (hover: hover) { .ui-pull-refresh-btn:not([aria-disabled="true"]):hover { background: var(--ui-subtle); } }.ui-pull-refresh-view { position: relative; flex: 1; min-height: 0; overflow: hidden; background: var(--ui-subtle); --p: 0; }.ui-pull-refresh-ind { position: absolute; inset: 0 0 auto 0; display: flex; align-items: center; justify-content: center; gap: 8px; height: 56px; color: var(--ui-muted); font-size: 13px; opacity: var(--p); }.ui-pull-refresh-arrow { display: grid; place-items: center; width: 24px; height: 24px; transform: rotate(calc(var(--p) * 180deg)); }.ui-pull-refresh-arrow svg { width: 20px; height: 20px; }.ui-pull-refresh-spin { width: 20px; height: 20px; border: 2px solid var(--ui-accent); border-right-color: transparent; border-radius: 50%; animation: ui-pull-refresh-spin .7s linear infinite; }@keyframes ui-pull-refresh-spin { to { transform: rotate(360deg); } }.ui-pull-refresh-done { color: var(--ui-success); font-weight: 600; }.ui-pull-refresh-failed { color: var(--ui-danger); font-weight: 600; }/* Its own scroller, and contain keeps the browser's pull and the page behind out of it. pan-y lets the browser scroll; a pull that is ours is kept from it by the touchmove guard in @mount. */.ui-pull-refresh-scroll { position: relative; height: 100%; overflow-y: auto; overscroll-behavior-y: contain; touch-action: pan-y; background: var(--ui-bg); transition: transform .28s cubic-bezier(.2, .8, .2, 1); }.ui-pull-refresh-on .ui-pull-refresh-scroll { transition: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }@container (min-width: 480px) { .ui-pull-refresh-btn { padding: 0 14px; } .ui-pull-refresh-btn-text { display: inline; }}@media (prefers-reduced-motion: reduce) { .ui-pull-refresh-scroll { transition: none; } .ui-pull-refresh-spin { animation-duration: 2.4s; }}`// Which way a move has gone, once it is past the slop: "x", "y", or "" for not yet.fn pull_to_refresh_axis(dx: num, dy: num, slop: num) -> str { if abs(dx) < slop && abs(dy) < slop { return "" } abs(dx) > abs(dy) ? "x" : "y"}// How far the area shows for a finger moved d: all of it at first, less and less after,// and never as far as limit.fn pull_to_refresh_resist(d: num, limit: num) -> num { if d <= 0 || limit <= 0 { return 0 } limit * d / (d + limit)}// A pull counts past the threshold, or past 60% of it when it is still moving fast at// release, as a flick is over before it has gone far.fn pull_to_refresh_commit(shown: num, speed: num, threshold: num) -> bool { shown >= threshold || (shown >= threshold * 0.6 && speed >= 0.6)}// A sample of the finger for its speed: replaced at most every 32 ms, as moves can come a// millisecond apart and one such pair would read as a huge speed. s is { pos, at, v }.fn pull_to_refresh_sample(s, pos: num, now: num) { let dt = now - s.at dt < 32 ? s : { pos, at: now, v: (pos - s.pos) / dt }}// Its speed at release, in pixels a millisecond: none if the finger stopped before it lifted.fn pull_to_refresh_speed(s, pos: num, now: num, moved: num) -> num { if now - moved > 80 { return 0 } // What has moved since the sample, when that says more than the sample does: a flick // can be over before a second sample comes, and the last move may have just made one. let dt = now - s.at let since = dt >= 8 ? (pos - s.pos) / dt : 0 abs(since) > abs(s.v) ? since : s.v}// What the callback gave, Promise or not, in a map. Called from the component, a Promise// of nothing would be waited for there and come back as null, which reads as "no answer,// done will be called"; called here, and boxed, it is still the Promise when it comes back.fn pull_to_refresh_ask(on_refresh, done) = { answer: on_refresh(done) }component PullToRefresh(on_refresh = null, height: str = "420px", threshold: int = 70, limit: int = 180, timeout: int = 30, label: str = "Refreshable list", pull_text: str = "Pull to refresh", ready_text: str = "Release to refresh", busy_text: str = "Refreshing", done_text: str = "Up to date", failed_text: str = "Could not refresh", disabled: bool = false, class: str? = null, children) { style global pull_to_refresh_css state phase = "idle" // idle, pulling, ready, busy, done, failed state wrap = null state sky = null // what holds the indicator: not the root, whose style attribute follows height state box = null state g = null // the finger's gesture, read by handlers only, so it updates nothing state run = 0 // which refresh is under way: a late done from an earlier one is ignored let held = 56 // the height the indicator is held at let show = (px: num, p: num) => { box.style.transform = px > 0 ? `translateY(${px}px)` : "" sky.style.setProperty("--p", `${p}`) } let close = () => { phase = "idle" show(0, 0) } let finish = (r: int, ok) => { if r == run && phase == "busy" { phase = ok == false || is_err(ok) ? "failed" : "done" } } let start = () => { phase = "busy" show(held, 1) run += 1 let r = run if on_refresh == null { finish(r, true) } else { let v = pull_to_refresh_ask(on_refresh, (ok = true) => { finish(r, ok) }).answer // A callback may wait, and then what it gives is a Promise. if type_of(v) == "promise" { v.then((x = true) => { finish(r, x) }, (_e = null) => { finish(r, false) }) } else { if type_of(v) == "bool" || is_err(v) { finish(r, v) } } } } if phase == "busy" { every(timeout * 1000, () => { finish(run, false) }) } if phase == "done" { every(1100, close) } if phase == "failed" { every(2400, close) } let working = phase == "busy" || phase == "done" || phase == "failed" let word = match phase { "ready" => ready_text "busy" => busy_text "done" => done_text "failed" => failed_text _ => pull_text } <div class={ui_cls(["ui-pull-refresh", (phase == "pulling" || phase == "ready") && "ui-pull-refresh-on", class])} style={`height: ${height}`} @mount={(me) => { wrap = me }}> <div.ui-pull-refresh-bar> <span.ui-pull-refresh-status role="status">{working ? word : ""}</span> <button.ui-pull-refresh-btn type="button" aria-label="Refresh" aria-disabled={working || disabled ? "true" : null} @click={() => { if !working && !disabled { start() } }}> <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-2.6-6.4M21 3v6h-6"/></svg> <span.ui-pull-refresh-btn-text>Refresh</span> </button> </div> <div.ui-pull-refresh-view @mount={(me) => { sky = me }}> <div.ui-pull-refresh-ind aria-hidden="true"> {phase == "busy" ? <span.ui-pull-refresh-spin></span> : phase == "done" || phase == "failed" ? null : <span.ui-pull-refresh-arrow><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M6 13l6 6 6-6"/></svg></span>} <span class={phase == "done" ? "ui-pull-refresh-done" : phase == "failed" ? "ui-pull-refresh-failed" : null}>{word}</span> </div> <div.ui-pull-refresh-scroll tabindex="0" role="group" aria-label={label} @mount={(me) => { box = me // Pointer events alone cannot keep a downward pull: pan-y gives it to the browser // and pan-down has no Safari. A touchmove that says no can, on both engines, and // pointermove comes first, so it knows by then whether the pull is ours. let guard = (e) => { if g != null && g.on && e.cancelable { e.preventDefault() } } me.addEventListener("touchmove", guard, { passive: false }) () => { me.removeEventListener("touchmove", guard) } }} @pointerdown={(e) => { // Its top above the screen: the page has more to bring down first, and that comes before a refresh. g = !disabled && !working && e.isPrimary && e.button == 0 && box.scrollTop <= 0 && wrap.getBoundingClientRect().top >= 0 ? { id: e.pointerId, y: e.clientY, x: e.clientX, on: false, base: 0, moved: e.timeStamp, shown: 0, s: { pos: e.clientY, at: e.timeStamp, v: 0 } } : null }} @pointermove={(e) => { if g != null && e.pointerId == g.id { let dx = e.clientX - g.x let dy = e.clientY - g.y if !g.on { let way = pull_to_refresh_axis(dx, dy, 8) if way == "x" || (way == "y" && (dy < 0 || box.scrollTop > 0)) { g = null } if way == "y" && g != null { box.setPointerCapture(e.pointerId) // From where the slop ends, not from this move: moves are merged at speed, // and the first of a flick can arrive most of the way there. g = { ...g, on: true, base: g.y + 8 } } } if g != null && g.on { let d = e.clientY - g.base let px = pull_to_refresh_resist(d, limit) show(px, min([1, px / threshold])) // A finger back above where it began scrolls the list, as the browser no longer can. if d < 0 { box.scrollTop = -d } phase = px >= threshold ? "ready" : "pulling" g = { ...g, shown: px, moved: e.timeStamp, s: pull_to_refresh_sample(g.s, e.clientY, e.timeStamp) } } } }} @pointerup={(e) => { if g != null && e.pointerId == g.id { let was = g g = null let v = pull_to_refresh_speed(was.s, e.clientY, e.timeStamp, was.moved) if was.on { if pull_to_refresh_commit(was.shown, v, threshold) { start() } else { close() } } } }} @pointercancel={(e) => { if g != null && e.pointerId == g.id { let was = g g = null if was.on { close() } } }}>{children}</div> </div> </div>}Related
AlphaRailListA 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 downloadsDragReorderPut a list in order by long press and drag, scrolling at its edges, and by keys or buttons too.2 views · 7 downloadsSelectionModeLong press starts selecting, tap adds, a toolbar shows the count and actions, and back leaves the mode.4 views · 6 downloadsSwipeRowA list row with actions behind it each way, a full swipe to run one, and a More button and keys to the same actions.2 views · 8 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 downloads
Thread
Thread 0 posts
On PullToRefresh: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.