Mobile

PullToRefresh

Pull a list down from its top to refresh, with resistance, a spinner, and success and failure states.

#mobile#interactive#gesture#refresh#scroll#list

Demo

Preview
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.

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.qwe
main.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>}
Thread
38Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.