Mobile

ThreeColumnView

Mail's sidebar, list and detail: columns hide in order as width shrinks, a Back appears when they do, and the open item survives every change.

#mobile#tablet#interactive#layout#mail#columns

Demo

Preview

Inbox

Survey dates

Tom Okafor, 08:40

Does the 14th still work? The crew can start at seven.

Nothing opened yet

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/three_column_view.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/three_column_view.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/three_column_view.qwe
let three_column_view_demo_css = css`.demo-three-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }.demo-three-frame { width: 100%; height: 100%; margin: 0 auto; }`let three_column_view_demo_folders = [    { id: "inbox", label: "Inbox", count: 4 },    { id: "work", label: "Work", count: 2 },    { id: "2026", label: "Receipts 2026" },    { id: "void", label: "Quiet" },]let three_column_view_demo_items = [    { id: 1, folder: "inbox", title: "Keys for the north gate", from: "Priya Nair", time: "09:12", unread: true, preview: "They are with the caretaker", body: "They are with the caretaker until Friday. Ring the side door." },    { id: 2, folder: "inbox", title: "Survey dates", from: "Tom Okafor", time: "08:40", preview: "Does the 14th still work?", body: "Does the 14th still work? The crew can start at seven." },    { id: 3, folder: "inbox", title: "Invoice 2291", from: "Hollis Ltd", time: "Mon", preview: "Attached, due in thirty days", body: "Attached, due in thirty days. Thanks for your business." },    { id: 4, folder: "inbox", title: "No sender on this one", time: "Sun" },    { id: 5, folder: "work", title: "Crew rota", from: "Dana Wu", time: "Fri", preview: "Week 46 is ready", body: "Week 46 is ready. Two swaps are marked in yellow." },    { id: 6, folder: "work", title: "Fuel card", from: "Finance", time: "Thu", preview: "New limit from Monday", body: "The new limit starts on Monday." },    { id: 7, folder: "2026", title: "Hire of the van", from: "Northpeak", time: "2 Jan", preview: "Paid", body: "Paid in full." },]component ThreeColumnViewDemoLive() {    style global three_column_view_demo_css    state items = three_column_view_demo_items    state said = "Nothing opened yet"    <div.demo-stack>        <div.demo-tablet>            <ThreeColumnView folders={three_column_view_demo_folders} items={items} open={2}                             on_open={(m) => { said = `Opened ${m.title}` }} on_folder={(f) => { said = `In ${f.label}` }}/>        </div>        <div.demo-three-bar>            <Button id="three-demo-remove" size="lg" variant="outline" on_click={() => { items = items | filter((m) => m.id != 2) }}>Delete Survey dates</Button>            <Button id="three-demo-add" size="lg" variant="outline" on_click={() => {                items = items + [{ id: 100 + len(items), folder: "inbox", title: "A new one", from: "Sam", time: "now", preview: "Just in" }]            }}>A new message comes in</Button>            <span.demo-note id="three-demo-said">{said}</span>        </div>    </div>}view ThreeColumnViewDemo() = <ThreeColumnViewDemoLive/>

Source

ui/three_column_view.qwe
// ThreeColumnView: a mail app's layout: folders, the list in a folder, and the item open// from it. Columns give way in order as the width shrinks: all three from 960 px; below// that the folders slide over the other two when asked for; under 600 px, one column at a// time, with a Back that appears as soon as there is somewhere to go back to. Width means// its own box, so a turn, Split View and Slide Over are all the same to it. The open item// stays open through every one of those, and through a change of folder.////   import ui/three_column_view.qwe use ThreeColumnView////   <ThreeColumnView folders={folders} items={messages} open={7}//                    on_open={(m) => { mark_read(m) }}/>//// folders: { id, label, count? }: count is a number shown beside it.// items: { id, folder, title, from?, preview?, time?, unread?, body? }. folder is one of// the folders' ids. time is text, as you want it shown.// folder: the folder to start in (the first). open: the id of the item to start with open// (none). title: the heading over the folders ("Mailboxes").// empty: what the third column says with nothing open ("Nothing is open").// custom_detail: show `children` in the third column in place of the item's own title// and body, and follow on_open to fill it. children: that detail.// on_open(item), on_folder(folder): inside one of your components, called with what was// chosen. A folder with no such id, an open id with no such item, or an item taken away// while open, each say so where they would show; none of them is an error.//// Under 600 px a swipe from the left edge goes back, as on an iPhone, following the finger.// The Back and Folders buttons do the same, as does Escape. A swipe left closes the// folders when they slide over. Up and Down move along a list, Home and End to its ends.// Safari's own edge swipe goes back a page in the browser and cannot be stopped; in the// app the swipe is this one's.import ui/theme.qwe use ui_clslet three_column_view_css = css`.ui-three-col {  position: relative; box-sizing: border-box; min-width: 0; height: 100%; overflow: hidden; container: ui-three-col / inline-size;  background: var(--ui-bg); color: var(--ui-fg); font: 15px/1.45 var(--ui-font);}.ui-three-col-grid { position: relative; display: grid; height: 100%; min-height: 0; grid-template-columns: 220px 320px minmax(0, 1fr); }.ui-three-col-col { display: flex; flex-direction: column; box-sizing: border-box; min-width: 0; min-height: 0; background: var(--ui-bg); }.ui-three-col-side { background: var(--ui-subtle); border-right: 1px solid var(--ui-border); }.ui-three-col-list { border-right: 1px solid var(--ui-border); }.ui-three-col-head {  display: flex; align-items: center; gap: 4px; flex: none; min-height: 52px; box-sizing: border-box;  padding: max(3px, env(safe-area-inset-top)) 8px 3px; border-bottom: 1px solid var(--ui-border);}.ui-three-col-head h2 { flex: 1; min-width: 0; margin: 0; padding: 0 6px; overflow: hidden; font-size: 16px; font-weight: 600; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; outline: none; }.ui-three-col-go {  display: none; align-items: center; gap: 2px; flex: none; min-width: 44px; min-height: 44px; max-width: 45%; box-sizing: border-box; padding: 0 8px; border: 0;  border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-accent); font: 500 15px/1.2 var(--ui-font); cursor: pointer;  -webkit-tap-highlight-color: transparent; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;}.ui-three-col-go:focus-visible { outline: none; box-shadow: var(--ui-focus); }/* touch-action stops at a scroller, so a column's pan-y must be said here too. */.ui-three-col-scroll { flex: 1; min-height: 0; overflow: auto; overflow-anchor: none; touch-action: pan-y; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; padding-bottom: env(safe-area-inset-bottom); }.ui-three-col-folder {  display: flex; align-items: center; justify-content: space-between; gap: 8px; box-sizing: border-box; width: 100%; min-height: 48px; padding: 8px 14px; border: 0;  background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent;}.ui-three-col-folder[aria-current="true"] { background: var(--ui-accent-soft); color: var(--ui-accent); font-weight: 600; }.ui-three-col-folder small { color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-three-col-row {  display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 8px; box-sizing: border-box; width: 100%; min-height: 72px; padding: 10px 14px; border: 0;  border-bottom: 1px solid var(--ui-border); background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent;}.ui-three-col-row[aria-current="true"] { background: var(--ui-accent-soft); }.ui-three-col-row b { min-width: 0; overflow: hidden; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }.ui-three-col-row small { color: var(--ui-muted); font-size: 13px; }.ui-three-col-row span { grid-column: 1 / -1; min-width: 0; overflow: hidden; color: var(--ui-muted); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }.ui-three-col-unread b { font-weight: 700; }.ui-three-col-unread b::before { content: ""; display: inline-block; width: 8px; height: 8px; margin-right: 6px; border-radius: 50%; background: var(--ui-accent); vertical-align: 1px; }.ui-three-col-folder:focus-visible, .ui-three-col-row:focus-visible { outline: none; box-shadow: inset var(--ui-focus); }@media (hover: hover) { .ui-three-col-folder:hover, .ui-three-col-row:hover { background: var(--ui-subtle); } .ui-three-col-go:hover { background: var(--ui-subtle); } }.ui-three-col-none { margin: 0; padding: 32px 20px; color: var(--ui-muted); text-align: center; }.ui-three-col-item { padding: 16px 20px 28px; }.ui-three-col-item p { margin: 0 0 12px; }.ui-three-col-meta { color: var(--ui-muted); font-size: 14px; }.ui-three-col-scrim { display: none; position: absolute; inset: 0; z-index: 2; padding: 0; border: 0; background: var(--ui-overlay); }@container ui-three-col (max-width: 959.98px) {  .ui-three-col-grid { grid-template-columns: 300px minmax(0, 1fr); }  .ui-three-col-side {    position: absolute; inset: 0 auto 0 0; z-index: 3; width: min(300px, 80%); box-shadow: var(--ui-shadow-lg); transform: translateX(-105%); visibility: hidden;    touch-action: pan-y;  }  .ui-three-col[data-depth="0"] .ui-three-col-side { transform: none; visibility: visible; }  .ui-three-col[data-depth="0"] .ui-three-col-scrim { display: block; }  /* Out of sight only once it has slid away, and in sight at once, so the focus can go there. */  .ui-three-col-moving .ui-three-col-side { transition: transform .24s cubic-bezier(.2, .8, .2, 1), visibility 0s linear .24s; }  .ui-three-col-moving[data-depth="0"] .ui-three-col-side { transition: transform .24s cubic-bezier(.2, .8, .2, 1), visibility 0s; }  .ui-three-col-go-folders { display: inline-flex; }}@container ui-three-col (max-width: 599.98px) {  .ui-three-col-grid { display: block; }  .ui-three-col-col { position: absolute; inset: 0; width: 100%; border: 0; touch-action: pan-y; visibility: hidden; box-shadow: none; }  .ui-three-col-side { z-index: 0; transform: translateX(-30%); }  .ui-three-col-list { z-index: 1; transform: translateX(100%); }  .ui-three-col-detail { z-index: 2; transform: translateX(100%); }  .ui-three-col[data-depth="0"] .ui-three-col-side { transform: none; visibility: visible; }  .ui-three-col[data-depth="1"] .ui-three-col-side { transform: translateX(-30%); }  .ui-three-col[data-depth="1"] .ui-three-col-list { transform: none; visibility: visible; }  .ui-three-col[data-depth="2"] .ui-three-col-list { transform: translateX(-30%); }  .ui-three-col[data-depth="2"] .ui-three-col-detail { transform: none; visibility: visible; }  .ui-three-col[data-depth="0"] .ui-three-col-scrim { display: none; }  .ui-three-col-peek .ui-three-col-col { visibility: visible; }  .ui-three-col-moving .ui-three-col-col { transition: transform .26s cubic-bezier(.2, .8, .2, 1), visibility 0s linear .26s; }  .ui-three-col-moving[data-depth="0"] .ui-three-col-side, .ui-three-col-moving[data-depth="1"] .ui-three-col-list,  .ui-three-col-moving[data-depth="2"] .ui-three-col-detail { transition: transform .26s cubic-bezier(.2, .8, .2, 1), visibility 0s; }  .ui-three-col-go-back { display: inline-flex; }  .ui-three-col-go-close { display: none !important; }}.ui-three-col-go-close { display: none; }@container ui-three-col (max-width: 959.98px) { .ui-three-col-go-close { display: inline-flex; } }@media (prefers-reduced-motion: reduce) { .ui-three-col-col { transition: none !important; } }`// How many columns a width has room for. Not measured yet, all three: the CSS has already said.fn three_column_view_columns(width: num) -> int = width <= 0 || width >= 960 ? 3 : width >= 600 ? 2 : 1fn three_column_view_same(a, b) -> bool = a != null && b != null && str(a) == str(b)fn three_column_view_in(items: list, folder) -> list = items | filter((m) => three_column_view_same(get(m, "folder"), folder))fn three_column_view_item(items: list, id) = find(items, (m) => three_column_view_same(get(m, "id"), id))fn three_column_view_folder(folders: list, id) = find(folders, (f) => three_column_view_same(get(f, "id"), id))fn three_column_view_label(folders: list, id) -> str {    let f = three_column_view_folder(folders, id)    f != null ? str(get(f, "label", "")) : "Back"}// Where Back goes from a depth: 2, the open item; 1, the list; 0, the folders.fn three_column_view_back(depth: int) -> int = depth > 0 ? depth - 1 : 0// Whether a swipe back is carried through: past 40% of the width, or a flick that way.fn three_column_view_goes(moved: num, speed: num, width: num) -> bool =    moved > width * 0.4 || (speed > 0.5 && moved > 30)// The next row for a key. Null: not a key that moves.fn three_column_view_step(key: str, at: int, count: int) -> int? {    match key {        "ArrowDown" => at + 1 < count ? at + 1 : at        "ArrowUp" => at > 0 ? at - 1 : 0        "Home" => 0        "End" => count > 0 ? count - 1 : 0        _ => null    }}view ThreeColumnViewRow(m, current: bool, on_open) =    <button class={ui_cls(["ui-three-col-row", get(m, "unread") == true && "ui-three-col-unread"])} type="button" data-item={str(get(m, "id"))}            aria-current={current ? "true" : null} @click={() => { on_open(m) }}>        <b>{str(get(m, "title", "Untitled"))}</b>        <small>{str(get(m, "time", ""))}</small>        <span>{str(get(m, "from", ""))}{get(m, "from") != null && get(m, "preview") != null && ": "}{str(get(m, "preview", ""))}</span>    </button>component ThreeColumnView(folders: list = [], items: list = [], folder = null, open = null, title: str = "Mailboxes",                          empty: str = "Nothing is open", custom_detail: bool = false, on_open = null, on_folder = null,                          class: str? = null, children) {    style global three_column_view_css    state current = folder ?? (len(folders) > 0 ? get(folders[0], "id") : null)    state opened = open    state depth = open != null ? 2 : 1    state columns = 0    state root = null    state win = null    state pull = null          // a finger's swipe: { x, y, last_x, last_at, speed, on, off }    let shown = three_column_view_in(items, current)    let item = opened != null ? three_column_view_item(items, opened) : null    let here = three_column_view_folder(folders, current)    let label = three_column_view_label(folders, current)    let moving = () => {        if root != null {            root.classList.add("ui-three-col-moving")            root.addEventListener("transitionend", (_e) => { root.classList.remove("ui-three-col-moving") }, { once: true })        }    }    // What should have the focus once a column has come in, for those who cannot see it slide.    let settle = (selector: str) => {        if root != null && columns < 3 {            let to = root.querySelector(selector)            if to != null { to.focus({ preventScroll: true }) }        }    }    let to_depth = (d: int) => {        moving()        depth = d        // At once, not when the page next updates: what follows moves the focus there.        if root != null { root.dataset.depth = str(d) }    }    let back = () => {        let next = three_column_view_back(depth)        to_depth(next)        if next == 1 {            settle(`[data-item="${opened != null ? str(opened) : ""}"]`)        } else {            settle(".ui-three-col-side h2")        }    }    let pick = (m) => {        opened = m.id        to_depth(2)        settle(".ui-three-col-detail h2")        on_open && on_open(m)    }    let choose = (f) => {        current = f.id        to_depth(1)        settle(".ui-three-col-list h2")        on_folder && on_folder(f)    }    // A column that has gone cannot hold the focus: it goes to the column that took over.    let check = (el, doc) => {        let next = three_column_view_columns(el.clientWidth)        if next != columns {            let was = columns            columns = next            if next == 3 && depth == 0 { depth = 1 }            if next < was && was != 0 {                let held = doc.activeElement                if held != null && el.contains(held) && js("getComputedStyle", held).visibility == "hidden" {                    settle(depth == 2 ? ".ui-three-col-detail h2" : ".ui-three-col-list h2")                }            }        }    }    let along = (e) => {        let rows = e.target.closest(".ui-three-col-scroll").querySelectorAll("button")        let at = range(rows.length) | filter((n) => rows[n].isSameNode(e.target))        let next = len(at) > 0 ? three_column_view_step(e.key, at[0], rows.length) : null        if next != null {            e.preventDefault()            rows[next].focus()        }    }    <div class={ui_cls(["ui-three-col", class])} data-depth={depth} data-columns={columns == 0 ? null : columns}         @mount={(el) => {             let w = js("window")             let look = (_e = null) => { check(el, w.document) }             let seen = js_new("ResizeObserver", look)             seen.observe(el)             win = w             root = el             () => { seen.disconnect() }         }}         @keydown={(e) => {             let may = columns == 1 ? depth > 0 : columns == 2 && depth == 0             if e.key == "Escape" && may {                 e.preventDefault()                 if columns == 2 { to_depth(1) } else { back() }             }         }}         @pointerdown={(e) => {             // Only where something waits underneath, and from the edge, as on an iPhone:             // a swipe anywhere else belongs to what is in the column.             if columns == 1 && depth > 0 && e.button == 0 {                 let r = root.getBoundingClientRect()                 if e.clientX - r.left < 32 {                     pull = { x: e.clientX, y: e.clientY, last_x: e.clientX, last_at: e.timeStamp, speed: 0, on: false, off: false, id: e.pointerId, side: 1 }                 }             }             if columns == 2 && depth == 0 && e.button == 0 && e.target.closest(".ui-three-col-side") != null {                 pull = { x: e.clientX, y: e.clientY, last_x: e.clientX, last_at: e.timeStamp, speed: 0, on: false, off: false, id: e.pointerId, side: -1 }             }         }}         @pointermove={(e) => {             if pull != null && !pull.off {                 let dx = (e.clientX - pull.x) * pull.side                 let dy = e.clientY - pull.y                 // The first 8 px say whose this is: mostly upright belongs to the column's scroll.                 let wait_more = !pull.on && abs(dx) < 8 && abs(dy) < 8                 let off = !pull.on && !wait_more && abs(dy) > abs(dx)                 let on = pull.on || (!wait_more && !off && dx > 0)                 if on {                     if !pull.on {                         e.target.setPointerCapture(e.pointerId)                         root.classList.remove("ui-three-col-moving")                         root.classList.add("ui-three-col-peek")                     }                     let d = max([0, dx])                     if pull.side > 0 {                         let top = root.querySelector(depth == 2 ? ".ui-three-col-detail" : ".ui-three-col-list")                         let under = root.querySelector(depth == 2 ? ".ui-three-col-list" : ".ui-three-col-side")                         let pct = d / root.clientWidth                         top.style.transform = `translateX(${d}px)`                         under.style.transform = `translateX(${round((pct - 1) * 30, 2)}%)`                     } else {                         root.querySelector(".ui-three-col-side").style.transform = `translateX(${-d}px)`                     }                 }                 let dt = e.timeStamp - pull.last_at                 let step = dt > 0 ? (e.clientX - pull.last_x) * pull.side / dt : pull.speed                 pull = { x: pull.x, y: pull.y, last_x: e.clientX, last_at: e.timeStamp, speed: pull.speed == 0 ? step : pull.speed * 0.4 + step * 0.6,                          on: on, off: off, id: pull.id, side: pull.side, moved: dx }             }         }}         @pointerup={(e) => {             if pull != null {                 let was = pull                 pull = null                 if was.on {                     let run = e.timeStamp - was.last_at < 100 ? was.speed : 0                     let moved = (e.clientX - was.x) * was.side                     let room = was.side > 0 ? root.clientWidth : root.querySelector(".ui-three-col-side").offsetWidth                     let goes = three_column_view_goes(moved, run, room)                     moving()                     root.classList.remove("ui-three-col-peek")                     let cols = root.querySelectorAll(".ui-three-col-col")                     for i in range(cols.length) { cols[i].style.transform = "" }                     if goes { if was.side > 0 { back() } else { to_depth(1) } }                 }             }         }}         @pointercancel={() => {             if pull != null {                 let was = pull                 pull = null                 if was.on {                     moving()                     root.classList.remove("ui-three-col-peek")                     let cols = root.querySelectorAll(".ui-three-col-col")                     for i in range(cols.length) { cols[i].style.transform = "" }                 }             }         }}>        <div.ui-three-col-grid>            <button.ui-three-col-scrim type="button" tabindex="-1" aria-label="Close the folders" data-small-target @click={() => { to_depth(1) }}></button>            <nav class="ui-three-col-col ui-three-col-side" aria-label={title}>                <div.ui-three-col-head>                    <h2 tabindex="-1">{title}</h2>                    <button class="ui-three-col-go ui-three-col-go-close" type="button" @click={() => { to_depth(1) }}>Close</button>                </div>                <div.ui-three-col-scroll                     @keydown={along}>                    {for f in folders =>                        <button.ui-three-col-folder key={str(get(f, "id"))} type="button" data-folder={str(get(f, "id"))}                                aria-current={three_column_view_same(get(f, "id"), current) ? "true" : null} @click={() => { choose(f) }}>                            {str(get(f, "label", ""))}                            {get(f, "count") != null && <small>{str(get(f, "count"))}</small>}                        </button>}                </div>            </nav>            <section class="ui-three-col-col ui-three-col-list" aria-label={label}>                <div.ui-three-col-head>                    <button class="ui-three-col-go ui-three-col-go-folders" type="button" aria-label={`Show ${title}`} @click={() => { to_depth(0); settle(".ui-three-col-side h2") }}>                        <span aria-hidden="true">‹</span> {title}                    </button>                    <h2 tabindex="-1">{here != null ? label : "No such folder"}</h2>                </div>                <div.ui-three-col-scroll                     @keydown={along}>                    {for m in shown =>                        <ThreeColumnViewRow key={str(get(m, "id"))} m={m} current={three_column_view_same(get(m, "id"), opened)} on_open={pick}/>}                    {len(shown) == 0 && <p.ui-three-col-none>{here != null ? "Nothing here" : "That folder is not here"}</p>}                </div>            </section>            <section class="ui-three-col-col ui-three-col-detail" aria-label={item != null ? str(get(item, "title", "Open item")) : "Open item"}>                <div.ui-three-col-head>                    <button class="ui-three-col-go ui-three-col-go-back" type="button" aria-label={`Back to ${label}`} @click={back}>                        <span aria-hidden="true">‹</span> {label}                    </button>                    <h2 tabindex="-1">{item != null ? str(get(item, "title", "Untitled")) : (opened != null ? "No longer here" : "")}</h2>                </div>                <div.ui-three-col-scroll>                    {custom_detail && <div.ui-three-col-custom>{children}</div>}                    {!custom_detail && item != null && <div.ui-three-col-item>                        <p.ui-three-col-meta>{str(get(item, "from", ""))}{get(item, "from") != null && get(item, "time") != null && ", "}{str(get(item, "time", ""))}</p>                        <p>{str(get(item, "body", get(item, "preview", "")))}</p>                    </div>}                    {!custom_detail && item == null && <p.ui-three-col-none>{opened != null ? "That item has been moved or deleted." : empty}</p>}                </div>            </section>        </div>    </div>}
Thread
46Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.