DragReorder
Put a list in order by long press and drag, scrolling at its edges, and by keys or buttons too.
Demo
- DepotLoad by 7:30
- Harbour Street3 parcels
- Mill Lane1 parcel, signature
- Station Road2 parcels
- Oak Avenue5 parcels
- Bridge Court1 parcel
- Quarry Hill2 parcels, steep drive
- Park View4 parcels
- Church Close1 parcel
- Market Square6 parcels
- Willow Way2 parcels
- Depot returnBy 16:00
Not changed yet.
- Two
- No id
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/drag_reorder.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/drag_reorder.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/drag_reorder.qwe
let drag_reorder_demo_stops = [ { id: 1, title: "Depot", subtitle: "Load by 7:30" }, { id: 2, title: "Harbour Street", subtitle: "3 parcels" }, { id: 3, title: "Mill Lane", subtitle: "1 parcel, signature" }, { id: 4, title: "Station Road", subtitle: "2 parcels" }, { id: 5, title: "Oak Avenue", subtitle: "5 parcels" }, { id: 6, title: "Bridge Court", subtitle: "1 parcel" }, { id: 7, title: "Quarry Hill", subtitle: "2 parcels, steep drive" }, { id: 8, title: "Park View", subtitle: "4 parcels" }, { id: 9, title: "Church Close", subtitle: "1 parcel" }, { id: 10, title: "Market Square", subtitle: "6 parcels" }, { id: 11, title: "Willow Way", subtitle: "2 parcels" }, { id: 12, title: "Depot return", subtitle: "By 16:00" },]component DragReorderDemoLive() { state order = "Not changed yet." <div.demo-stack> <DragReorder items={drag_reorder_demo_stops} max_height="390px" label="Delivery stops" on_reorder={(ids) => { order = join(ids, " ") }}/> <p.demo-note id="drag-reorder-order">{order}</p> // Bad data, for the test to look at: nothing here should stop the page. <div id="drag-reorder-bad" hidden="true"><DragReorder items={[null, { id: 1 }, { id: 2, title: "Two" }, { id: 2, title: "Again" }, { title: "No id" }]}/><DragReorder items="none"/></div> </div>}view DragReorderDemo() = <DragReorderDemoLive/>Source
ui/drag_reorder.qwe
// DragReorder: a list put in order by hand. On touch a long press lifts a row, so the// list still scrolls under a passing finger; the grip at its left lifts one at once,// which suits a mouse too. Rows make way as it moves, the list or the page scrolls by// itself near its edges, and letting go drops it there. The arrow keys move a row from its// grip, screen readers have a Move up and a Move down button in each row, and in a wide// container those stand on the row for anyone to press.//// component Stops() {// state rows = load_stops()// <DragReorder items={rows} max_height="60dvh" label="Stops"// on_reorder={(ids) => { save_order(ids) }}/>// }//// items: each { id, title, subtitle? }. One without a title is left out, a second with an// id already seen too, and if the list changes, rows keep the order they were given by hand and new// ones go last. on_reorder(ids): inside one of your components, called with all the ids, as text, in the// new order, when a row has really moved. delay: the long press, in milliseconds// (320). max_height: a CSS height past which the list scrolls inside itself; without it the// page scrolls. label: the list's name for screen readers. class.import ui/theme.qwe use ui_clslet drag_reorder_css = css`.ui-drag-reorder { position: relative; container-type: inline-size; color: var(--ui-fg); font: 15px/1.4 var(--ui-font); }.ui-drag-reorder-list { position: relative; margin: 0; padding: 0; list-style: none; overscroll-behavior-y: contain; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-subtle); }.ui-drag-reorder-row { position: relative; display: flex; align-items: center; gap: 6px; min-height: 60px; padding: 4px 8px 4px 0; border-bottom: 1px solid var(--ui-border); background: var(--ui-surface); touch-action: pan-y; -webkit-touch-callout: none; transition: transform .18s cubic-bezier(.2, .8, .2, 1), box-shadow .15s; }.ui-drag-reorder-row:last-child { border-bottom: 0; }.ui-drag-reorder-pressing { background: var(--ui-subtle); user-select: none; -webkit-user-select: none; }.ui-drag-reorder-lifted { z-index: 3; box-shadow: var(--ui-shadow-lg); transition: box-shadow .15s; user-select: none; -webkit-user-select: none; cursor: grabbing; }.ui-drag-reorder-grip { display: grid; flex: none; place-items: center; width: 44px; height: 52px; padding: 0; border: 0; background: transparent; color: var(--ui-muted); cursor: grab; touch-action: none; }.ui-drag-reorder-grip svg { width: 20px; height: 20px; }.ui-drag-reorder-grip:focus-visible { outline: none; box-shadow: var(--ui-focus); border-radius: var(--ui-radius-sm); }.ui-drag-reorder-text { flex: 1; min-width: 0; }.ui-drag-reorder-title { display: block; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-drag-reorder-sub { display: block; color: var(--ui-muted); font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }/* For screen readers on a phone, which have no arrow keys. position: relative on the row keeps them from widening the page. */.ui-drag-reorder-moves { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }.ui-drag-reorder-move { 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-drag-reorder-move[aria-disabled="true"] { color: var(--ui-muted); opacity: .55; cursor: default; }.ui-drag-reorder-move:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (hover: hover) { .ui-drag-reorder-move:not([aria-disabled="true"]):hover { background: var(--ui-subtle); } }@container (min-width: 520px) { .ui-drag-reorder-moves { position: static; display: flex; flex: none; gap: 6px; width: auto; height: auto; overflow: visible; clip: auto; }}@media (prefers-reduced-motion: reduce) { .ui-drag-reorder-row { transition: none; } }`// The rows that can be shown: those with a title, each id once, ids as text.fn drag_reorder_clean(items) { if type_of(items) != "list" { return [] } let named = items | filter((i) => type_of(i) == "map" && get(i, "title") != null) // One without an id gets its place, marked so it cannot be taken for a real id of that number. let all = enumerate(named) | map((p) => { ...p[1], id: get(p[1], "id") != null ? str(p[1].id) : `_${p[0]}` }) let ids = all | map((i) => i.id) enumerate(all) | filter((p) => index_of(ids, p[1].id) == p[0]) | map((p) => p[1])}// The ids in the order they stand now: those given by hand first, then any new.fn drag_reorder_ids(rows, hand: list) -> list { let have = rows | map((r) => r.id) let kept = hand | filter((id) => contains(have, id)) kept + (have | filter((id) => !contains(kept, id)))}// The list with the item at from moved to the place to.fn drag_reorder_move(ids: list, from: int, to: int) -> list { if from == to || from < 0 || to < 0 || from >= len(ids) || to >= len(ids) { return ids } let rest = enumerate(ids) | filter((p) => p[0] != from) | map((p) => p[1]) slice(rest, 0, to) + [ids[from]] + slice(rest, to, len(rest))}// The place a row dragged by dy would land in: how many of the others have their middle above its middle.fn drag_reorder_target(from: int, tops: list, heights: list, dy: num) -> int { let middle = tops[from] + heights[from] / 2 + dy enumerate(tops) | filter((p) => p[0] != from && p[1] + heights[p[0]] / 2 < middle) | len()}// How far row i steps aside while the row at from hangs over the place to.fn drag_reorder_shift(i: int, from: int, to: int, step: num) -> num { if i > from && i <= to { return -step } if i < from && i >= to { return step } 0}// Pixels a tick the list scrolls, for a finger y between an edge's top and bottom: more// the nearer the edge, nothing outside the zone.fn drag_reorder_speed(y: num, top: num, bottom: num, zone: num, fastest: num) -> num { if y < top + zone { return -round(fastest * min([1, (top + zone - y) / zone])) } if y > bottom - zone { return round(fastest * min([1, (y - bottom + zone) / zone])) } 0}// What scrolls this element: the nearest box that can, else the page.fn drag_reorder_scroller(el) { let box = el.parentElement while box != null && !box.isSameNode(js("document.body")) { let how = js("getComputedStyle", box).overflowY if (how == "auto" || how == "scroll") && box.scrollHeight > box.clientHeight { return box } box = box.parentElement } js("document.scrollingElement")}// A tap felt, where there is such a thing.fn drag_reorder_buzz() { if is_err(js("quilloNative.haptic", "medium")) && js("navigator.userActivation.hasBeenActive") == true { js("navigator.vibrate", 10) }}component DragReorder(items = [], on_reorder = null, delay: int = 320, max_height: str? = null, label: str = "List in order", class: str? = null) { style global drag_reorder_css let rows = drag_reorder_clean(items) state hand = [] // the order made here, ids as text let ids = drag_reorder_ids(rows, hand) let shown = ids | map((id) => find(rows, (r) => r.id == id)) state list_el = null state press = null // a touch on a row, waiting for its time: { id, row, x, y } state lifted = "" // the id of the row in the air state g = null // that row's drag, which handlers keep to themselves state said = "" let title_of = (id: str) => find(rows, (r) => r.id == id).title let commit = (from: int, to: int, row_id: str) => { let next = drag_reorder_move(ids, from, to) hand = next said = `${title_of(row_id)} moved to place ${to + 1} of ${len(next)}.` on_reorder && on_reorder(next) } let shift_rows = (to: int) => { let all = g.rows for i in range(0, len(all)) { if i != g.from { let by = drag_reorder_shift(i, g.from, to, g.step) all[i].style.transform = by != 0 ? `translateY(${by}px)` : "" } } } let follow = () => { let dy = g.py - g.y + (g.box.scrollTop - g.top0) g.rows[g.from].style.transform = `translateY(${dy}px)` let to = drag_reorder_target(g.from, g.tops, g.heights, dy) if to != g.to { shift_rows(to) g = { ...g, to } } } let lift = (row, id: int, y: num) => { press = null let all = list_el.querySelectorAll(".ui-drag-reorder-row") let n = all.length let els = for i in range(0, n) => all[i] let from = index_of(ids, row.dataset.id) let box = drag_reorder_scroller(row) g = { pid: id, y, py: y, from, to: from, rows: els, box, top0: box.scrollTop, tops: for e in els => e.offsetTop, heights: for e in els => e.offsetHeight, step: row.offsetHeight } row.setPointerCapture(id) lifted = row.dataset.id drag_reorder_buzz() } // Rows go back to their places at once, in their new order, and the one let go glides in. let drop = (cancel: bool) => { let was = g g = null lifted = "" let row = was.rows[was.from] let dy = was.py - was.y + (was.box.scrollTop - was.top0) let id = row.dataset.id if cancel || was.to == was.from { for e in was.rows { e.style.transform = "" } } else { for e in was.rows { e.style.transition = "none" e.style.transform = "" } let hung = was.tops[was.from] + dy commit(was.from, was.to, id) js("requestAnimationFrame", (_t = null) => { for e in was.rows { e.style.transition = "" } if !js("matchMedia", "(prefers-reduced-motion: reduce)").matches { row.animate([{ transform: `translateY(${hung - row.offsetTop}px)` }, { transform: "none" }], { duration: 160, easing: "ease-out" }) } }) } } let by_key = (e, id: str) => { let at = index_of(ids, id) let to = match e.key { "ArrowUp" => at - 1 "ArrowDown" => at + 1 "Home" => 0 "End" => len(ids) - 1 _ => -1 } if to >= 0 && to < len(ids) && to != at { e.preventDefault() commit(at, to, id) } if e.key == "ArrowUp" || e.key == "ArrowDown" || e.key == "Home" || e.key == "End" { e.preventDefault() } } let step = (id: str, by: int) => { let at = index_of(ids, id) let to = at + by if to >= 0 && to < len(ids) { commit(at, to, id) } } let tick = () => { if g != null { let page = g.box.isSameNode(js("document.scrollingElement")) let r = g.box.getBoundingClientRect() let top = page ? 0 : r.top let bottom = page ? js("window.innerHeight") : r.bottom let by = drag_reorder_speed(g.py, top, bottom, 56, 10) if by != 0 { g.box.scrollTop += by follow() } } } if press != null { every(delay, () => { if press != null { lift(press.row, press.id, press.y) } }) } if lifted != "" { every(16, tick) } <div class={ui_cls(["ui-drag-reorder", class])}> <ul.ui-drag-reorder-list role="list" aria-label={label} style={max_height != null ? `max-height: ${max_height}; overflow-y: auto` : null} @mount={(me) => { list_el = me // Once a row is in the air the finger is the drag's. pan-y would hand it to the // list on the first move, as nothing else stops the browser in the middle of a touch. let guard = (e) => { if g != null && e.cancelable { e.preventDefault() } } me.addEventListener("touchmove", guard, { passive: false }) () => { me.removeEventListener("touchmove", guard) } }} @contextmenu={(e) => { if press != null || g != null { e.preventDefault() } }} @selectstart={(e) => { if press != null || g != null { e.preventDefault() } }} @pointerdown={(e) => { let row = e.target.closest(".ui-drag-reorder-row") let on_move = e.target.closest(".ui-drag-reorder-moves") != null if row != null && !on_move && e.isPrimary && e.button == 0 && g == null { if e.target.closest(".ui-drag-reorder-grip") != null { e.preventDefault() lift(row, e.pointerId, e.clientY) } else { press = { id: e.pointerId, row, x: e.clientX, y: e.clientY, mouse: e.pointerType == "mouse" } } } }} @pointermove={(e) => { if press != null && e.pointerId == press.id { let far = abs(e.clientX - press.x) + abs(e.clientY - press.y) // A mouse needs no long press; a finger that moves first is scrolling. if far > 4 && press.mouse { lift(press.row, press.id, press.y) } else { if far > 10 { press = null } } } if g != null && e.pointerId == g.pid { g = { ...g, py: e.clientY } follow() } }} @pointerup={(e) => { if press != null && e.pointerId == press.id { press = null } if g != null && e.pointerId == g.pid { drop(false) } }} @pointercancel={(e) => { if press != null && e.pointerId == press.id { press = null } if g != null && e.pointerId == g.pid { drop(true) } }} @keydown={(e) => { if e.key == "Escape" && g != null { drop(true) } }}> {for [i, it] in enumerate(shown) => <li key={it.id} data-id={it.id} class={ui_cls(["ui-drag-reorder-row", press != null && press.row.dataset.id == it.id && "ui-drag-reorder-pressing", lifted == it.id && "ui-drag-reorder-lifted"])}> <button.ui-drag-reorder-grip type="button" aria-label={`Move ${it.title}, place ${i + 1} of ${len(shown)}. Use the up and down arrows.`} @keydown={(e) => { by_key(e, it.id) }}> <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="9" cy="6" r="1.6"/><circle cx="15" cy="6" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="18" r="1.6"/><circle cx="15" cy="18" r="1.6"/></svg> </button> <span.ui-drag-reorder-text> <span.ui-drag-reorder-title>{it.title}</span> {get(it, "subtitle") != null && <span.ui-drag-reorder-sub>{it.subtitle}</span>} </span> // data-small-target: on a phone these are a pixel, for screen readers alone. <span.ui-drag-reorder-moves data-small-target="true"> <button.ui-drag-reorder-move type="button" aria-label={`Move ${it.title} up`} aria-disabled={i == 0 ? "true" : null} @click={() => { step(it.id, -1) }}>Up</button> <button.ui-drag-reorder-move type="button" aria-label={`Move ${it.title} down`} aria-disabled={i == len(shown) - 1 ? "true" : null} @click={() => { step(it.id, 1) }}>Down</button> </span> </li>} </ul> <span.ui-sr role="status">{said}</span> </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 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 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 downloads
Thread
Thread 0 posts
On DragReorder: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.