SwipeRow
A list row with actions behind it each way, a full swipe to run one, and a More button and keys to the same actions.
Demo
Northwind BankYour statement is ready
Maya ChenLunch on Thursday?
Parcel TrackingOut for delivery
City LibraryTwo books due soon
Tom ReyesPhotos from the hike
Nothing done yet.
Nothing to do
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/swipe_row.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/swipe_row.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/swipe_row.qwe
let swipe_row_demo_mail = [ { id: 1, from: "Northwind Bank", subject: "Your statement is ready" }, { id: 2, from: "Maya Chen", subject: "Lunch on Thursday?" }, { id: 3, from: "Parcel Tracking", subject: "Out for delivery" }, { id: 4, from: "City Library", subject: "Two books due soon" }, { id: 5, from: "Tom Reyes", subject: "Photos from the hike" },]let swipe_row_demo_css = css`.demo-swipe-from { display: block; font-weight: 600; }.demo-swipe-sub { display: block; color: var(--ui-muted); font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }`component SwipeRowDemoLive() { style global swipe_row_demo_css state rows = swipe_row_demo_mail state said = "Nothing done yet." let act = (m, id: str) => { said = `${id}: ${m.from}` if id == "delete" { rows = rows | filter((r) => r.id != m.id) } } <div.demo-stack> <div role="list" aria-label="Inbox" style="border-top: 1px solid var(--ui-border)"> {for m in rows => <SwipeRow key={m.id} label={m.from} group="demo-inbox" leading={[{ id: "read", label: "Read", tone: "accent" }]} trailing={[{ id: "archive", label: "Archive", tone: "success", full: true }, { id: "delete", label: "Delete", tone: "danger" }]} on_action={(id) => { act(m, id) }}> <span.demo-swipe-from>{m.from}</span> <span.demo-swipe-sub>{m.subject}</span> </SwipeRow>} </div> <p.demo-note id="swipe-row-said" role="status">{said}</p> // Bad data, for the test to look at: nothing here should stop the page. <div id="swipe-row-bad" hidden="true"><SwipeRow leading={[null, "x", { id: "no-label" }]} trailing="none" action_width={2}>Nothing to do</SwipeRow></div> </div>}view SwipeRowDemo() = <SwipeRowDemoLive/>Source
ui/swipe_row.qwe
// SwipeRow: a list row with actions behind it, shown by swiping it sideways, as a mail// app's rows are. A swipe past the actions' middle leaves them open, a flick does it// from less, and a swipe most of the way across runs the action marked full. One row of// a group is open at a time, and a touch anywhere else closes it. Everything a swipe// reaches has another way in: a More button lists the same actions, the arrow keys open// a focused row, and in a wide container the actions stand on the row itself.//// component Inbox() {// state rows = load_rows()// <div role="list">// {for m in rows => <SwipeRow key={m.id} label={m.subject} group="inbox"// leading={[{ id: "read", label: "Read", tone: "accent" }]}// trailing={[{ id: "archive", label: "Archive", full: true }, { id: "delete", label: "Delete", tone: "danger" }]}// on_action={(id) => { act(m, id) }}><b>{m.subject}</b></SwipeRow>}// </div>// }//// leading: the actions a swipe to the right shows, at the row's start. trailing: those a// swipe to the left shows. Each is { id, label, tone?, full? }: tone is "accent", "danger",// "success" or "neutral" (the default); full: true on one action of a side runs it on a// full swipe. on_action(id): inside one of your components, which action was chosen.// group: rows with the same group close each other ("swipe-row"). label: the row's name// for screen readers, used for its More button. action_width: pixels each action takes// (76, and 44 at the least). class. children: the row.import ui/theme.qwe use ui_clslet swipe_row_css = css`.ui-swipe-row { position: relative; overflow: hidden; container-type: inline-size; background: var(--ui-surface); color: var(--ui-fg); font: 15px/1.4 var(--ui-font); border-bottom: 1px solid var(--ui-border); overscroll-behavior-x: contain; }.ui-swipe-row-top { position: relative; min-height: 60px; overflow: hidden; }.ui-swipe-row-pane { position: absolute; inset: 0 auto 0 auto; display: flex; visibility: hidden; max-width: 100%; }.ui-swipe-row-leading { left: 0; }.ui-swipe-row-trailing { right: 0; flex-direction: row; }.ui-swipe-row-showing .ui-swipe-row-pane { visibility: visible; }.ui-swipe-row-act { flex: 1 1 var(--w); display: grid; place-items: center; min-width: 44px; min-height: 44px; padding: 0 6px; border: 0; background: var(--t, var(--ui-muted)); color: var(--tf, #fff); font: 600 13px/1.2 var(--ui-font); cursor: pointer; overflow: hidden; white-space: nowrap; transition: flex-grow .16s; }.ui-swipe-row-act:focus-visible { outline: 2px solid var(--ui-fg); outline-offset: -3px; }.ui-swipe-row-tone-accent { --t: var(--ui-accent); --tf: var(--ui-accent-fg); }.ui-swipe-row-tone-danger { --t: var(--ui-danger); --tf: #fff; }.ui-swipe-row-tone-success { --t: var(--ui-success); --tf: #fff; }/* Past the full swipe, the action that will run takes the whole width of what is shown. */.ui-swipe-row-armed .ui-swipe-row-act-full { flex-grow: 60; }.ui-swipe-row-front { position: relative; display: flex; align-items: center; gap: 8px; min-height: 60px; padding: 6px 4px 6px 16px; background: var(--ui-surface); touch-action: pan-y; transition: transform .24s cubic-bezier(.2, .8, .2, 1); outline: none; }.ui-swipe-row-front:focus-visible { box-shadow: inset var(--ui-focus); }.ui-swipe-row-moving .ui-swipe-row-front { transition: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }.ui-swipe-row-body { flex: 1; min-width: 0; }.ui-swipe-row-more-btn { display: grid; flex: none; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-muted); cursor: pointer; }.ui-swipe-row-more-btn svg { width: 20px; height: 20px; }.ui-swipe-row-more-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-swipe-row-inline { display: none; flex: none; gap: 6px; }.ui-swipe-row-chip { min-width: 44px; min-height: 44px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--t, var(--ui-fg)); font: 500 14px/1 var(--ui-font); cursor: pointer; }.ui-swipe-row-chip:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-swipe-row-sheet { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 12px; border-top: 1px solid var(--ui-border); background: var(--ui-subtle); }@media (hover: hover) { .ui-swipe-row-more-btn:hover { background: var(--ui-subtle); color: var(--ui-fg); } .ui-swipe-row-chip:hover { background: var(--ui-subtle); }}@container (min-width: 520px) { .ui-swipe-row-more-btn { display: none; } .ui-swipe-row-inline { display: flex; }}@media (prefers-reduced-motion: reduce) { .ui-swipe-row-front, .ui-swipe-row-act { transition: none; } }`// Actions with something to show: an entry without a label is left out, and an id falls// back on the label.fn swipe_row_clean(actions) { if type_of(actions) != "list" { return [] } actions | filter((a) => type_of(a) == "map" && get(a, "label") != null) | map((a) => { ...a, id: get(a, "id") ?? a.label })}fn swipe_row_axis(dx: num, dy: num, slop: num) -> str { if abs(dx) < slop && abs(dy) < slop { return "" } abs(dx) > abs(dy) ? "x" : "y"}// Where the row goes for a finger at d (positive to the right): it follows up to the width// of what is behind it and on if a full swipe waits there; past that it drags, and with// nothing on that side it barely moves.fn swipe_row_offset(d: num, left: num, right: num, full_left: bool, full_right: bool) -> num { let side = d >= 0 ? left : right let way = d >= 0 ? 1 : -1 let far = abs(d) let full = d >= 0 ? full_left : full_right if side == 0 { return way * min([far * 0.15, 24]) } if far <= side || full { return d } way * (side + (far - side) * 0.3)}// What a release at x with speed v (pixels a millisecond, signed) comes to: "", "leading",// "trailing", "full-leading" or "full-trailing".fn swipe_row_settle(x: num, v: num, width: num, left: num, right: num, full_left: bool, full_right: bool) -> str { let way = x >= 0 ? 1 : -1 let side = x >= 0 ? left : right let name = x >= 0 ? "leading" : "trailing" if side == 0 { return "" } if (x >= 0 ? full_left : full_right) && abs(x) >= width * 0.6 { return `full-${name}` } if abs(v) >= 0.5 { return v * way > 0 ? name : "" } abs(x) >= side / 2 ? name : ""}fn swipe_row_sample(s, pos: num, now: num) { let dt = now - s.at dt < 32 ? s : { pos, at: now, v: (pos - s.pos) / dt }}// The speed at release, signed: none if the finger stopped before it lifted.fn swipe_row_speed(s, pos: num, now: num, moved: num) -> num { if now - moved > 80 { return 0 } let dt = now - s.at let since = dt >= 8 ? (pos - s.pos) / dt : 0 abs(since) > abs(s.v) ? since : s.v}// A tap felt: the app's haptics, else the web's, which Chrome refuses, with a warning, until// the page has been tapped once. iOS's browser has neither.fn swipe_row_buzz(kind: str, ms: int) { if is_err(js("quilloNative.haptic", kind)) && js("navigator.userActivation.hasBeenActive") == true { js("navigator.vibrate", ms) }}component SwipeRow(leading = [], trailing = [], on_action = null, group: str = "swipe-row", label: str = "Row", action_width: int = 76, class: str? = null, children) { style global swipe_row_css let lefts = swipe_row_clean(leading) let rights = swipe_row_clean(trailing) let each = max([44, action_width]) let left_w = len(lefts) * each let right_w = len(rights) * each let full_left = find(lefts, (a) => get(a, "full") == true) let full_right = find(rights, (a) => get(a, "full") == true) state wrap = null state front = null state side = "" // "", "leading" or "trailing": what stands open state moving = false state armed = false // past the full swipe, in a drag state more = false // the list of actions under the row state dragged = false // a mouse's drag ends in a click, which is not a tap on the row state g = null let rest = (s: str) => s == "leading" ? left_w : s == "trailing" ? -right_w : 0 let place = (x: num) => { front.style.transform = x != 0 ? `translateX(${x}px)` : "" // What is behind grows with the row past its own width, for a full swipe. let pane = wrap.querySelector(x > 0 ? ".ui-swipe-row-leading" : ".ui-swipe-row-trailing") if pane != null { pane.style.width = abs(x) > (x > 0 ? left_w : right_w) ? `${abs(x)}px` : "" } } let close_others = () => { let all = js("document").querySelectorAll(".ui-swipe-row-open") for i in range(0, all.length) { if !all[i].isSameNode(wrap) && all[i].dataset.group == group { all[i].dispatchEvent(js_new("CustomEvent", "ui-swipe-row-close")) } } } let open_to = (s: str) => { if s != "" { close_others() } side = s moving = false armed = false place(rest(s)) } let pick = (a) => { open_to("") more = false on_action && on_action(a.id) } state firing = null // the action of a full swipe, while the row leaves if firing != null { every(200, () => { let a = firing firing = null front.style.transition = "none" open_to("") on_action && on_action(a.id) js("requestAnimationFrame", (_t = null) => { front.style.transition = "" }) }) } <div class={ui_cls(["ui-swipe-row", side != "" && "ui-swipe-row-open", (side != "" || moving || firing != null) && "ui-swipe-row-showing", moving && "ui-swipe-row-moving", armed && "ui-swipe-row-armed", class])} role="listitem" data-group={group} style={`--w: ${each}px`} @mount={(me) => { let d = js("document") wrap = me let shut = () => { open_to("") } let outside = (e) => { if side != "" && !me.contains(e.target) { shut() } } me.addEventListener("ui-swipe-row-close", shut) d.addEventListener("pointerdown", outside, true) () => { d.removeEventListener("pointerdown", outside, true) } }}> <div.ui-swipe-row-top> {len(lefts) > 0 && <div.ui-swipe-row-pane.ui-swipe-row-leading> {for a in lefts => <button key={a.id} type="button" class={ui_cls(["ui-swipe-row-act", get(a, "tone") != null && `ui-swipe-row-tone-${a.tone}`, get(a, "full") == true && "ui-swipe-row-act-full"])} @click={() => pick(a)}>{a.label}</button>} </div>} {len(rights) > 0 && <div.ui-swipe-row-pane.ui-swipe-row-trailing> {for a in rights => <button key={a.id} type="button" class={ui_cls(["ui-swipe-row-act", get(a, "tone") != null && `ui-swipe-row-tone-${a.tone}`, get(a, "full") == true && "ui-swipe-row-act-full"])} @click={() => pick(a)}>{a.label}</button>} </div>} <div.ui-swipe-row-front tabindex="0" @mount={(me) => { front = me }} @keydown={(e) => { if e.target.isSameNode(front) { if e.key == "ArrowLeft" && len(rights) > 0 { e.preventDefault(); open_to(side == "leading" ? "" : "trailing") } if e.key == "ArrowRight" && len(lefts) > 0 { e.preventDefault(); open_to(side == "trailing" ? "" : "leading") } if e.key == "Escape" && side != "" { open_to("") } } }} @click={(e) => { if dragged { dragged = false } else { if side != "" && e.target.closest("button") == null { open_to("") } } }} @pointerdown={(e) => { dragged = false let free = e.isPrimary && e.button == 0 && firing == null && e.target.closest("button, a, input, select, textarea") == null g = free ? { id: e.pointerId, x: e.clientX, y: e.clientY, on: false, base: rest(side), at: 0, moved: e.timeStamp, s: { pos: e.clientX, at: e.timeStamp, v: 0 } } : null }} @pointermove={(e) => { if g != null && e.pointerId == g.id { let dx = e.clientX - g.x if !g.on { let way = swipe_row_axis(dx, e.clientY - g.y, 8) if way == "y" { g = null } if way == "x" { front.setPointerCapture(e.pointerId) close_others() moving = true g = { ...g, on: true, shift: dx > 0 ? 8 : -8 } } } if g != null && g.on { let x = swipe_row_offset(g.base + dx - g.shift, left_w, right_w, full_left != null, full_right != null) place(x) let past = swipe_row_settle(x, 0, front.offsetWidth, left_w, right_w, full_left != null, full_right != null) let now_armed = starts_with(past, "full-") if now_armed && !armed { swipe_row_buzz("medium", 12) } armed = now_armed g = { ...g, at: x, moved: e.timeStamp, s: swipe_row_sample(g.s, e.clientX, e.timeStamp) } } } }} @pointerup={(e) => { if g != null && e.pointerId == g.id { let was = g g = null if was.on { dragged = e.pointerType == "mouse" let v = swipe_row_speed(was.s, e.clientX, e.timeStamp, was.moved) let end = swipe_row_settle(was.at, v, front.offsetWidth, left_w, right_w, full_left != null, full_right != null) if starts_with(end, "full-") { moving = false armed = true side = "" firing = end == "full-leading" ? full_left : full_right let w = wrap.offsetWidth place(end == "full-leading" ? w : -w) } else { open_to(end) } } } }} @pointercancel={(e) => { if g != null && e.pointerId == g.id { let was = g g = null if was.on { open_to("") } } }}> <div.ui-swipe-row-body>{children}</div> <div.ui-swipe-row-inline> {for a in lefts + rights => <button.ui-swipe-row-chip key={a.id} type="button" class={get(a, "tone") != null ? `ui-swipe-row-tone-${a.tone}` : null} @click={() => pick(a)}>{a.label}</button>} </div> <button.ui-swipe-row-more-btn type="button" aria-label={`More actions for ${label}`} aria-expanded={more ? "true" : "false"} @click={(e) => { more = !more let row = wrap if more { js("requestAnimationFrame", (_t = null) => { let first = row.querySelector(".ui-swipe-row-sheet button") first && first.focus() }) } }}> <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg> </button> </div> </div> {more && <div.ui-swipe-row-sheet role="group" aria-label={`Actions for ${label}`} @keydown={(e) => { if e.key == "Escape" { more = false wrap.querySelector(".ui-swipe-row-more-btn").focus() } }}> {for a in lefts + rights => <button.ui-swipe-row-chip key={a.id} type="button" class={get(a, "tone") != null ? `ui-swipe-row-tone-${a.tone}` : null} @click={() => pick(a)}>{a.label}</button>} </div>} </div>}Related
AlertInboxAlerts 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 downloadsConversationListChats with unread counts, a muted state, pinned at the top, typing indicator, swipe to archive, and relative times from a prop.3 views · 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 downloads
Thread
Thread 0 posts
On SwipeRow: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.