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.
Demo
Inbox
Survey dates
Does the 14th still work? The crew can start at seven.
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.qwemain.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>}Related
PhoneShellThe whole app frame: header, scroller, tab bar, safe areas and keyboard handled once, for one fixed-height screen that does not bounce.2 views · 6 downloadsResponsiveSplitMaster and detail as one stack on a phone and side by side on a tablet, changing live on rotation and keeping the selection.2 views · 5 downloadsRotationLayoutTwo arrangements of one set of children, upright and turned: no flash, and focus and scroll kept across the turn.3 views · 6 downloadsSafeAreaPads content for notches and home bars by env(), per side, with a view of the insets under a debug prop.4 views · 7 downloadsSizeClassViewTells compact, medium and expanded widths by its own box, so Split View, Slide Over and a turn are one case, and swaps slots without losing what is in them.1 view · 5 downloadsSplitViewTwo or three panes with a divider to drag, which give way by width, with the selection kept across a turn and a keyboard route for the divider.3 views · 7 downloads
Thread
Thread 0 posts
On ThreeColumnView: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.