PhotoGridSelect
A tight square grid of photos with a run of the finger to pick many, a count, and month headers.
Demo
October 2026
September 2026
August 2026
No date
Picked ; opened none
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/photo_grid_select.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/photo_grid_select.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/photo_grid_select.qwe
fn photo_grid_select_demo_pic(fill: str, n: int) = "data:image/svg+xml," + url_encode(`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' fill='${fill}'/><text x='50' y='60' font-size='28' text-anchor='middle' fill='#fff' font-family='sans-serif'>${n}</text></svg>`)let photo_grid_select_demo_fills = ["#3d5a80", "#ee6c4d", "#6d8f71", "#7a5195", "#98c1d9", "#293241", "#bc5090", "#ef5675"]fn photo_grid_select_demo_month(key: str, from_n: int, count: int) -> list = range(count) | map((i) => { id: from_n + i, src: photo_grid_select_demo_pic(photo_grid_select_demo_fills[(from_n + i) % 8], from_n + i), alt: `Photo ${from_n + i}`, at: `${key}-${i < 9 ? "0" : ""}${i + 1}` })let photo_grid_select_demo_photos = flatten([ photo_grid_select_demo_month("2026-08", 1, 14), photo_grid_select_demo_month("2026-10", 100, 18), photo_grid_select_demo_month("2026-09", 200, 40), [{ id: "x", src: photo_grid_select_demo_pic("#111111", 0), alt: "An old scan", at: "someday" }, { id: "nosrc", at: "2026-10-01" }, 7],])component PhotoGridSelectDemoLive() { state picked = [] state opened = "none" state back = null state given = [] <div.demo-stack> <div.demo-phone style="--ui-pgsel-height: 100%"> <PhotoGridSelect photos={photo_grid_select_demo_photos} selected={given} reveal={back} on_change={(ids) => { picked = ids }} on_open={(p, i) => { opened = `${p.id} at ${i}` }}/> </div> <p.demo-note id="pgsel-demo-note">Picked {join(picked | map((x) => `${x}`), " ")}; opened {opened}</p> <div.demo-row> <Button id="pgsel-demo-give" size="lg" variant="ghost" on_click={() => { given = [100, 101] }}>Pick two from the page</Button> <Button id="pgsel-demo-reveal" size="lg" variant="ghost" on_click={() => { back = 230 }}>Come back to 230</Button> </div> </div>}view PhotoGridSelectDemo() = <PhotoGridSelectDemoLive/>Source
ui/photo_grid_select.qwe
// PhotoGridSelect: a tight square grid of photos under month headers, for picking many at once.// Press and hold a photo, or press Select, and tap to pick. Then a finger run across them picks// everything from the first to where it is, in reading order, as in Photos, and going back// lets go of them; started on a picked photo it unpicks instead. Near the top or bottom// edge the grid scrolls by itself. A finger going straight up or down scrolls, as ever. A tablet// has the months in a column of their own beside larger photos.//// import ui/photo_grid_select.qwe use PhotoGridSelect//// <PhotoGridSelect photos={[{ id: 1, src: "/p/1-small.jpg", alt: "The harbour", at: "2026-10-03" }]}// on_change={(ids) => { chosen = ids }} on_open={(p, i) => { viewing = i }}/>//// photos: each { id, src, alt, at }, with at as a date, "2026-10-03" (only the year and month are read).// Newest first; those without a usable date go last, under "No date". selected: the ids picked to begin// with, and a new list replaces what is picked. month_names: twelve names, for another language.// height: `--ui-pgsel-height`, 100dvh unless set, as the grid scrolls in its own box. label: what// screen readers call it. reveal: an id to bring into view and give focus, when it changes, for coming back// from a viewer. on_change(ids): inside one of your components, when what is picked changes, in the order// shown. on_open(photo, index): a tap on a photo when not picking.//// The keys: the arrows go from photo to photo, Space picks, Shift and an arrow picks as it goes, Enter// opens or picks. Each month has a button to pick all of it.import ui/theme.qwe use ui_clslet photo_grid_select_css = css`.ui-pgsel { display: flex; flex-direction: column; height: var(--ui-pgsel-height, 100dvh); min-width: 0; container: ui-pgsel / inline-size; background: var(--ui-bg); color: var(--ui-fg); font: 14px/1.35 var(--ui-font); }.ui-pgsel-bar { flex: none; display: flex; align-items: center; gap: 6px; min-height: 52px; padding: 2px max(6px, env(safe-area-inset-right, 0px)) 2px max(14px, env(safe-area-inset-left, 0px)); border-bottom: 1px solid var(--ui-border); }.ui-pgsel-count { flex: 1; min-width: 0; margin: 0; font-size: 16px; font-weight: 650; font-variant-numeric: tabular-nums; }.ui-pgsel-btn { min-height: 44px; min-width: 44px; padding: 0 12px; border: 0; border-radius: 10px; background: none; color: var(--ui-accent); font: 600 14px var(--ui-font); cursor: pointer; touch-action: manipulation; }.ui-pgsel-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-pgsel-scroll { position: relative; flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; outline: none; }.ui-pgsel-month { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between; min-height: 44px; padding: 0 6px 0 14px; background: var(--ui-bg); }.ui-pgsel-month h3 { margin: 0; font-size: 15px; font-weight: 650; }.ui-pgsel-section { content-visibility: auto; contain-intrinsic-size: auto 480px; }.ui-pgsel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); gap: 2px; }/* Up and down stay the browser's; sideways is the run across photos. Nothing here selects text or opens a callout. */.ui-pgsel-cell { position: relative; display: block; aspect-ratio: 1; width: 100%; padding: 0; overflow: hidden; border: 0; background: var(--ui-subtle); cursor: pointer; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;}.ui-pgsel-cell img { display: block; width: 100%; height: 100%; object-fit: cover; transition: transform .12s; pointer-events: none; -webkit-user-drag: none; }.ui-pgsel-cell:focus-visible { outline: 3px solid var(--ui-accent); outline-offset: -3px; z-index: 1; }.ui-pgsel-tick { position: absolute; top: 6px; right: 6px; display: none; width: 22px; height: 22px; border: 2px solid #fff; border-radius: 50%; background: rgba(0, 0, 0, .25); box-sizing: border-box; pointer-events: none; }.ui-pgsel-picking .ui-pgsel-tick { display: block; }.ui-pgsel-on .ui-pgsel-tick { background: var(--ui-accent); }.ui-pgsel-on .ui-pgsel-tick::after { content: ""; position: absolute; left: 5px; top: 1px; width: 5px; height: 10px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); }.ui-pgsel-on img { transform: scale(.86); }.ui-pgsel-on { background: var(--ui-accent-soft); }.ui-pgsel-empty { margin: 0; padding: 40px 16px; text-align: center; color: var(--ui-muted); }/* Wide: the months keep a column of their own, and the photos have more room. */@container ui-pgsel (min-width: 640px) { .ui-pgsel-section { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 8px; padding: 6px 8px; } .ui-pgsel-month { flex-direction: column; align-items: flex-start; justify-content: flex-start; align-self: start; top: 6px; padding: 6px 0 6px 8px; } .ui-pgsel-grid { grid-template-columns: repeat(auto-fill, minmax(124px, 1fr)); gap: 4px; } .ui-pgsel-cell { border-radius: 6px; }}@media (prefers-reduced-motion: reduce) { .ui-pgsel-cell img { transition: none; } }`let photo_grid_select_names = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]// The month of a date, as "2026-10", or "" if it has none that can be read.fn photo_grid_select_key(at) -> str { if type_of(at) != "str" || len(at) < 7 || slice(at, 4, 5) != "-" { return "" } let y = int(slice(at, 0, 4)) let m = int(slice(at, 5, 7)) if is_err(y) || is_err(m) || m < 1 || m > 12 { return "" } slice(at, 0, 7)}// A month as words.fn photo_grid_select_month(key: str, names) -> str { if key == "" { return "No date" } let m = int(slice(key, 5, 7)) let all = type_of(names) == "list" && len(names) == 12 ? names : photo_grid_select_names `${all[m - 1]} ${slice(key, 0, 4)}`}// The usable photos, newest first, those with no date last, each with its id as text, and once.fn photo_grid_select_clean(photos) -> list { if type_of(photos) != "list" { return [] } let rows = photos | filter((p) => type_of(p) == "map" && type_of(get(p, "src")) == "str" && p.src != "") let shaped = rows | enumerate() | map((pair) => { let p = pair[1] { id: str(get(p, "id") ?? pair[0]), src: p.src, alt: str(get(p, "alt") ?? ""), at: type_of(get(p, "at")) == "str" ? p.at : "", key: photo_grid_select_key(get(p, "at")) } }) let ids = shaped | map((p) => p.id) let once = shaped | enumerate() | filter((pair) => index_of(ids, pair[1].id) == pair[0]) | map((pair) => pair[1]) let dated = sort_by(once | filter((p) => p.key != ""), (p) => p.at) flatten([reverse(dated), once | filter((p) => p.key == "")])}// Runs of the same month, in order, each photo with its place in the whole list. A list of// keys is kept, not a map: months that look like numbers would move in one.fn photo_grid_select_groups(list_in: list) -> list { let keys = unique(list_in | map((p) => p.key)) let placed = list_in | enumerate() | map((pair) => set(pair[1], "i", pair[0])) let by = group_by(placed, (p) => `m${p.key}`) keys | map((k) => { key: k, items: get(by, `m${k}`, []) })}// A run across photos: what is picked with [lo..hi] set to `adding`.fn photo_grid_select_apply(marks: list, lo: int, hi: int, adding: bool) -> list = marks | enumerate() | map((p) => p[0] >= lo && p[0] <= hi ? adding : p[1])// How many would be picked with [lo..hi] set to `adding`.fn photo_grid_select_count(marks: list, lo: int, hi: int, adding: bool) -> int { let inside = len(slice(marks, lo, hi + 1) | filter((m) => m)) let all = len(marks | filter((m) => m)) all - inside + (adding ? hi - lo + 1 : 0)}fn photo_grid_select_marks(list_in: list, ids) -> list { let wanted = type_of(ids) == "list" ? group_by(ids, (x) => str(x)) : {} list_in | map((p) => has(wanted, p.id))}// How far a key moves in a grid of a number of columns.fn photo_grid_select_step(k: str, columns: int) -> int { if k == "ArrowLeft" { return -1 } if k == "ArrowRight" { return 1 } if k == "ArrowUp" { return 0 - columns } if k == "ArrowDown" { return columns } 0}component PhotoGridSelect(photos = [], selected = [], month_names = null, reveal = null, label: str = "Photos", on_change = null, on_open = null, class: str? = null) { style global photo_grid_select_css let sig = join(photo_grid_select_clean(photos) | map((p) => p.id), ",") let want = type_of(selected) == "list" ? join(selected | map((x) => str(x)), ",") : "" state items = photo_grid_select_clean(photos) state seen = sig state seen_want = want state marks = photo_grid_select_marks(items, selected) state picking = len(marks | filter((m) => m)) > 0 state box = null state doc = null state win = null state cursor = 0 state press = null // a finger down on a photo; no markup reads it state holding = false // waiting to see whether it is a long press state sweeping = false // a run across photos is on state run = null // that run: where it began, what it does, what it covers now state cells = null state draft = null // how many are picked during a run state last_x = 0 state last_y = 0 state moved_at = -1000 // when a press that was not a tap ended state top_i = 0 // the first photo in sight, to come back to after a turn state wide = 0 state turned = false state seen_reveal = reveal let groups = photo_grid_select_groups(items) let picked_n = len(marks | filter((m) => m)) let shown_n = draft ?? picked_n let tell = () => { if on_change != null { on_change(items | enumerate() | filter((p) => marks[p[0]]) | map((p) => p[1].id)) } } let paint = (j, on) => { let c = cells[j] c.classList.toggle("ui-pgsel-on", on) c.setAttribute("aria-pressed", on ? "true" : "false") } let start_run = (i, id) => { cells = box.querySelectorAll(".ui-pgsel-cell") picking = true run = { from: i, adding: !marks[i], lo: i, hi: i } sweeping = true draft = photo_grid_select_count(marks, i, i, !marks[i]) box.setPointerCapture(id) paint(i, !marks[i]) } let cover = (to) => { let r = run let lo = min([r.from, to]) let hi = max([r.from, to]) if lo != r.lo || hi != r.hi { // Only the photos that changed side are touched, and the state waits for the lift. let j = min([lo, r.lo]) let last = max([hi, r.hi]) while j <= last { let inside = j >= lo && j <= hi let was = j >= r.lo && j <= r.hi if inside != was { paint(j, inside ? r.adding : marks[j]) } j += 1 } run = { from: r.from, adding: r.adding, lo: lo, hi: hi } draft = photo_grid_select_count(marks, lo, hi, r.adding) } } let hit = (x, y) => { let el = doc.elementFromPoint(x, y) let c = el == null ? null : el.closest(".ui-pgsel-cell") if c != null { cover(int(c.dataset.i)) } } let end_run = (keep) => { let r = run sweeping = false run = null draft = null if r != null { if keep { marks = photo_grid_select_apply(marks, r.lo, r.hi, r.adding) tell() } else { let j = r.lo while j <= r.hi { paint(j, marks[j]) j += 1 } } } } let toggle_one = (i) => { marks = photo_grid_select_apply(marks, i, i, !marks[i]) picking = true tell() } let set_all = (lo, hi, on) => { marks = photo_grid_select_apply(marks, lo, hi, on) tell() } let cell_at = (i) => box.querySelector(`.ui-pgsel-cell[data-i="${i}"]`) let columns = () => { let first = cell_at(0) let grid = first == null ? null : first.parentNode.closest(".ui-pgsel-grid") grid == null ? 1 : max([1, round((grid.clientWidth + 2) / (first.offsetWidth + 2))]) } if sig != seen { let was_ids = items | enumerate() | filter((p) => marks[p[0]]) | map((p) => p[1].id) let fresh = photo_grid_select_clean(photos) seen = sig items = fresh marks = photo_grid_select_marks(fresh, was_ids) } if want != seen_want { seen_want = want marks = photo_grid_select_marks(items, selected) if len(marks | filter((m) => m)) > 0 { picking = true } } if reveal != seen_reveal { seen_reveal = reveal let i = index_of(items | map((p) => p.id), str(reveal)) if i != null && box != null { cursor = i let c = cell_at(i) if c != null { c.scrollIntoView({ block: "center" }) c.focus({ preventScroll: true }) } } } if holding { every(420, () => { holding = false if press != null { start_run(press.i, press.id) moved_at = press.t + 100000 let phone = win.quilloNative if phone != null { phone.haptic("medium") } else if win.navigator.vibrate != null { win.navigator.vibrate(10) } } }) } if sweeping { // A finger near the edge moves the grid on, and the run with it. every(40, () => { if run != null { let r = box.getBoundingClientRect() let by = last_y < r.top + 48 ? -14 : last_y > r.bottom - 48 ? 14 : 0 if by != 0 { box.scrollTop = box.scrollTop + by hit(last_x, last_y) } } }) } <div class={ui_cls(["ui-pgsel", picking && "ui-pgsel-picking", class])} role="region" aria-label={label}> <div.ui-pgsel-bar> <p.ui-pgsel-count role="status">{picking ? `${shown_n} selected` : `${len(items)} ${len(items) == 1 ? "photo" : "photos"}`}</p> {picking && <button.ui-pgsel-btn type="button" @click={() => { set_all(0, len(items) - 1, picked_n < len(items)) }}>{picked_n < len(items) ? "Select all" : "Clear"}</button>} {len(items) > 0 && <button.ui-pgsel-btn.ui-pgsel-mode type="button" aria-pressed={picking ? "true" : "false"} @click={() => { if picking { picking = false if picked_n > 0 { set_all(0, len(items) - 1, false) } } else { picking = true } }}>{picking ? "Cancel" : "Select"}</button>} </div> {len(items) == 0 && <p.ui-pgsel-empty>No photos yet.</p>} <div.ui-pgsel-scroll @mount={(el) => { box = el win = js("window") doc = win.document wide = el.clientWidth // A long press leaves nothing to tell the browser that this touch is not a scroll, as // touch-action was read when it began. Only a listener that can refuse the move can do it. let keep_finger = (e) => { if sweeping { e.preventDefault() } } el.addEventListener("touchmove", keep_finger, { passive: false }) // A turn changes the columns: the same photo stays at the top. let grown = js_new("ResizeObserver", (_x) => { if el.clientWidth != wide { wide = el.clientWidth let c = cell_at(top_i) if c != null { let m = c.parentNode.closest(".ui-pgsel-section").querySelector(".ui-pgsel-month") let head = el.clientWidth >= 640 ? 0 : m.offsetHeight turned = true el.scrollTop = el.scrollTop + c.getBoundingClientRect().top - el.getBoundingClientRect().top - head } } }) grown.observe(el) () => { grown.disconnect() el.removeEventListener("touchmove", keep_finger) } }} @scroll={() => { // The scroll a turn makes is not a person's: the photo to keep stays the same, turn after turn. if turned { turned = false } else if box.clientWidth == wide { let r = box.getBoundingClientRect() let el = doc.elementFromPoint(r.right - 30, r.top + 60) let c = el == null ? null : el.closest(".ui-pgsel-cell") if c != null { top_i = int(c.dataset.i) } } }} @contextmenu={(e) => { if press != null || sweeping { e.preventDefault() } }} @pointerdown={(e) => { let c = e.target.closest(".ui-pgsel-cell") moved_at = -1000 if c != null && (e.pointerType != "mouse" || e.button == 0) { press = { i: int(c.dataset.i), id: e.pointerId, x: e.clientX, y: e.clientY, t: e.timeStamp } holding = true last_x = e.clientX last_y = e.clientY } }} @pointermove={(e) => { last_x = e.clientX last_y = e.clientY if sweeping { hit(e.clientX, e.clientY) } else if press != null { let dx = e.clientX - press.x let dy = e.clientY - press.y if abs(dx) >= 10 || abs(dy) >= 10 { holding = false // Sideways first, when picking: it is ours from here on. The rest is the browser's. if picking && abs(dx) > abs(dy) { start_run(press.i, press.id) moved_at = e.timeStamp + 100000 hit(e.clientX, e.clientY) } else { press = null } } } }} @pointerup={(e) => { holding = false press = null if sweeping { moved_at = e.timeStamp end_run(true) } }} @pointercancel={() => { holding = false press = null if sweeping { moved_at = -1000 end_run(false) } }} @keydown={(e) => { let c = e.target.closest(".ui-pgsel-cell") if c != null { let k = e.key let i = int(c.dataset.i) let by = photo_grid_select_step(k, columns()) let to = k == "Home" ? 0 : k == "End" ? len(items) - 1 : i + by if by != 0 || k == "Home" || k == "End" { e.preventDefault() if to >= 0 && to < len(items) { cursor = to cell_at(to).focus() if e.shiftKey { set_all(min([i, to]), max([i, to]), true) } } } if k == " " { e.preventDefault() toggle_one(i) } } }}> {for g in groups => { let lo = g.items[0].i let hi = g.items[len(g.items) - 1].i let all_on = len(slice(marks, lo, hi + 1) | filter((m) => m)) == hi - lo + 1 <section.ui-pgsel-section key={g.key}> <div.ui-pgsel-month> <h3>{photo_grid_select_month(g.key, month_names)}</h3> <button.ui-pgsel-btn type="button" aria-label={`${all_on ? "Clear" : "Select"} ${photo_grid_select_month(g.key, month_names)}`} @click={() => { picking = true set_all(lo, hi, !all_on) }}>{all_on ? "Clear" : "Select"}</button> </div> <div.ui-pgsel-grid> {for p in g.items => <button class={ui_cls(["ui-pgsel-cell", marks[p.i] && "ui-pgsel-on"])} key={p.id} type="button" data-i={p.i} aria-pressed={marks[p.i] ? "true" : "false"} aria-label={p.alt != "" ? p.alt : `Photo ${p.i + 1}`} tabindex={cursor == p.i ? "0" : "-1"} @click={(e) => { // A long press or a run has already done what it did. A new press clears this, // so a quick tap after a run is not lost. if e.timeStamp - moved_at > 400 { cursor = p.i if picking { toggle_one(p.i) } else { on_open && on_open(p, p.i) } } }}> <img src={p.src} alt="" loading="lazy" draggable="false"/> <span.ui-pgsel-tick></span> </button>} </div> </section> }} </div> </div>}Related
SelectionModeLong press starts selecting, tap adds, a toolbar shows the count and actions, and back leaves the mode.4 views · 7 downloadsSwipeViewerPhotos swiped sideways, pulled down to close and zoomed, the next ones loaded ahead and the place kept.3 views · 7 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 downloadsActivityRingsProgress rings inside each other that go round again past 100%, drawn once, with a legend and a sentence each for screen readers.3 views · 7 downloadsAdaptiveDialogA sheet on a phone and a centred dialog on a tablet or desktop: the same content, chosen by width, changing live on rotation.3 views · 5 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 PhotoGridSelect: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.