Mobile

PhotoGridSelect

A tight square grid of photos with a run of the finger to pick many, a count, and month headers.

#mobile#interactive#photos#grid#selection#tablet

Demo

Preview

73 photos

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

Thread 0 posts

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

No posts yet. The first one opens the thread.