SplitView
Two 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.
Demo
Folders
Inbox
Site visit 7
Notes from visit 7: the north gate was open, and the keys are with the caretaker until Friday.
Not resized yet
Twelve files
Nothing chosen
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/split_view.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/split_view.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/split_view.qwe
let split_view_demo_css = css`.demo-split-head { position: sticky; top: 0; z-index: 1; margin: 0; padding: 12px 14px; border-bottom: 1px solid var(--ui-border); background: var(--ui-subtle); font: 600 14px/1.3 var(--ui-font); }.demo-split-row { display: block; box-sizing: border-box; width: 100%; min-height: 56px; padding: 8px 14px; border: 0; border-bottom: 1px solid var(--ui-border); background: transparent; color: var(--ui-fg); font: 15px/1.3 var(--ui-font); text-align: left; cursor: pointer; }.demo-split-row small { display: block; color: var(--ui-muted); font-size: 13px; }.demo-split-row[aria-current="true"] { background: var(--ui-accent-soft); }.demo-split-body { padding: 16px; font: 15px/1.6 var(--ui-font); }.demo-split-body h4 { margin: 0 0 8px; font-size: 19px; }.demo-split-body textarea { box-sizing: border-box; width: 100%; min-height: 96px; margin-top: 12px; padding: 8px 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 16px/1.4 var(--ui-font); }.demo-split-out { margin: 8px 0 0; }`let split_view_demo_lists = ["Inbox", "Sent", "Archive", "Drafts"]let split_view_demo_notes = range(1, 31) | map((n) => { id: n, title: `Site visit ${n}`, who: n % 2 == 0 ? "Priya Nair" : "Tom Okafor", body: `Notes from visit ${n}: the north gate was open, and the keys are with the caretaker until Friday.`})component SplitViewDemoLive() { style global split_view_demo_css state pane = 1 state folder = "Inbox" state open = 7 state said = "Not resized yet" state tall = "100%" let note = find(split_view_demo_notes, (n) => n.id == open) <div.demo-stack> <div.demo-tablet> <SplitView titles={["Folders", "Notes", "Reader"]} sizes={[22, 33]} pane={pane} height={tall} label="Field notes" on_pane={(i) => { pane = i }} on_resize={(s) => { said = `First ${s.first}, second ${s.second}, two-up ${s.medium}` }}> <SplitViewPane title="Folders"> <p.demo-split-head>Folders</p> {for f in split_view_demo_lists => <button.demo-split-row key={f} type="button" data-folder={f} aria-current={f == folder ? "true" : null} @click={() => { folder = f; pane = 1 }}>{f}</button>} </SplitViewPane> <SplitViewPane title="Notes"> <p.demo-split-head id="split-demo-folder">{folder}</p> {for n in split_view_demo_notes => <button.demo-split-row key={n.id} type="button" data-note={n.id} aria-current={n.id == open ? "true" : null} @click={() => { open = n.id; pane = 2 }}>{n.title}<small>{n.who}</small></button>} </SplitViewPane> <SplitViewPane title="Reader"> <div.demo-split-body> <h4 id="split-demo-title">{note.title}</h4> <p>{note.body}</p> <label for="split-demo-reply">Reply</label> <textarea id="split-demo-reply"></textarea> </div> </SplitViewPane> </SplitView> </div> <div.demo-row> <Button id="split-demo-tall" size="lg" variant="outline" on_click={() => { tall = tall == "100%" ? "96%" : "100%" }}>Change the height</Button> <p.demo-note.demo-split-out id="split-demo-said">{said}</p> </div> <div.demo-tablet style="height: 360px"> <SplitView titles={["Files", "Preview"]} sizes={[40]} folded="" label="Files"> <SplitViewPane title="Files"><div.demo-split-body id="split-demo-two-a">Twelve files</div></SplitViewPane> <SplitViewPane title="Preview"><div.demo-split-body id="split-demo-two-b">Nothing chosen</div></SplitViewPane> </SplitView> </div> </div>}view SplitViewDemo() = <SplitViewDemoLive/>Source
ui/split_view.qwe
// SplitView: two or three panes side by side, as an iPad app lays them out, with a// divider a finger can drag. As the width shrinks the panes give way: three panes show// when it is 840 px or more, two from 600, one below that, with a bar to go between them.// Width means its own box, so a turn, Split View and Slide Over all count. The pane in// use stays in sight through every change, and panes out of sight stay on the page, so// a selection, a scroll and anything typed are as they were when they come back.//// import ui/split_view.qwe use SplitView, SplitViewPane//// <SplitView titles={["Folders", "Messages", "Reader"]} sizes={[24, 34]}// pane={pane} on_pane={(i) => { pane = i }}>// <SplitViewPane title="Folders"><Folders/></SplitViewPane>// <SplitViewPane title="Messages"><Messages on_open={(m) => { open = m; pane = 2 }}/></SplitViewPane>// <SplitViewPane title="Reader"><Reader message={open}/></SplitViewPane>// </SplitView>//// titles: a title for each pane, two or three of them: that says how many there are.// children: as many SplitViewPanes, in order. Not one inside another's pane.// sizes: where the panes start, the first's share of the width in percent, and the second's// when there are three ([30, 35]); the last takes the rest. After that the divider is theirs. medium: the first pane's share when// two show out of three (40). min_width: the least a pane is dragged to, in px (200).// step: how far an arrow key moves a divider, in percent (4; with Shift, three times that).// pane: the pane in use, from 0: it stays in sight when room runs short. A tap or the// focus in a pane makes it the one in use, and the bar moves it too. Inside one of your// components, hold it in your state with on_pane to send a person on to a pane: on_pane(i)// is called with each change. folded: "first" or "last" starts with that pane put away.// on_resize({ first, second, medium }): inside one of your components, the sizes after a// drag or a key. bar: the bar with Back, Next and Hide (true). height: as CSS ("100%").// label: what the whole is called for screen readers.//// The divider: drag it, or focus it and use the arrow keys, Home and End. Enter puts the// pane beside it away and brings it back; so do dragging past the pane's least width, or// a flick towards it, and Hide in the bar. The divider of a pane put away waits at the// edge: a tap or Enter brings the pane back. One pane is away at a time. Screen readers// read it as a separator with its percentage. A vertical pull on it leaves the page to scroll.//// SplitViewPane(title): one pane, which scrolls by itself.import ui/theme.qwe use ui_clslet split_view_css = css`.ui-split-view { position: relative; display: flex; flex-direction: column; box-sizing: border-box; min-width: 0; min-height: 0; height: var(--ui-split-view-h, 100%); container: ui-split-view / inline-size; background: var(--ui-bg); color: var(--ui-fg); font: 15px/1.5 var(--ui-font);}.ui-split-view-bar { display: flex; align-items: center; gap: 4px; flex: none; min-height: 52px; box-sizing: border-box; padding: 3px 6px; border-bottom: 1px solid var(--ui-border); background: var(--ui-surface);}.ui-split-view-title { flex: 1; min-width: 0; margin: 0; padding: 0 8px; overflow: hidden; font-size: 15px; font-weight: 600; text-align: center; text-overflow: ellipsis; white-space: nowrap; }.ui-split-view-go { display: inline-flex; align-items: center; gap: 4px; flex: none; min-width: 44px; min-height: 44px; box-sizing: border-box; padding: 0 10px; 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;}.ui-split-view-go:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (hover: hover) { .ui-split-view-go:hover { background: var(--ui-subtle); } }.ui-split-view-body { position: relative; flex: 1; min-height: 0; overflow: hidden; }.ui-split-view-panes { display: flex; width: 100%; height: 100%; }.ui-split-view-pane { box-sizing: border-box; flex: 1 1 0; min-width: 0; height: 100%; overflow: auto; overflow-anchor: none; overscroll-behavior: contain; outline: none; -webkit-overflow-scrolling: touch; border-left: 1px solid var(--ui-border);}.ui-split-view-pane:first-child { border-left: 0; }.ui-split-view-divider { position: absolute; top: 0; bottom: 0; z-index: 2; display: none; width: 44px; margin-left: -22px; touch-action: pan-y; cursor: col-resize; outline: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }.ui-split-view-grip { position: absolute; top: 50%; left: 50%; width: 8px; height: 56px; margin: -28px 0 0 -4px; border-radius: 999px; background: var(--ui-strong); transition: background .15s, transform .15s; }.ui-split-view-divider:focus-visible .ui-split-view-grip { background: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-split-view-dragging .ui-split-view-grip { background: var(--ui-accent); transform: scaleX(1.25); }.ui-split-view-dragging { cursor: col-resize; }.ui-split-view-dragging .ui-split-view-pane { pointer-events: none; }.ui-split-view-settle .ui-split-view-pane, .ui-split-view-settle .ui-split-view-divider { transition: flex-basis .22s cubic-bezier(.2, .8, .2, 1), left .22s cubic-bezier(.2, .8, .2, 1); }@container ui-split-view (min-width: 840px) { .ui-split-view-panes > :nth-child(1) { flex: 0 0 var(--ui-split-view-a); } .ui-split-view[data-count="3"] .ui-split-view-panes > :nth-child(2) { flex: 0 0 var(--ui-split-view-b); } .ui-split-view[data-fold="last"] .ui-split-view-panes > :nth-child(2) { flex: 1 1 0; } .ui-split-view[data-fold="first"] .ui-split-view-panes > :nth-child(1), .ui-split-view[data-fold="last"] .ui-split-view-panes > :nth-child(3) { position: absolute; inset: 0 auto 0 0; width: 40%; visibility: hidden; pointer-events: none; } .ui-split-view-divider[data-index="0"] { display: block; left: var(--ui-split-view-a); } .ui-split-view-divider[data-index="1"] { display: block; left: calc(var(--ui-split-view-a) + var(--ui-split-view-b)); } /* A pane put away leaves its divider at the edge: the way back for a finger and for the keys. */ .ui-split-view[data-fold="first"] .ui-split-view-divider[data-index="0"] { left: 0; margin-left: 0; } .ui-split-view[data-fold="first"] .ui-split-view-divider[data-index="1"] { left: var(--ui-split-view-b); } .ui-split-view[data-fold="last"] .ui-split-view-divider[data-index="1"] { left: 100%; margin-left: -44px; }}@container ui-split-view (min-width: 600px) and (max-width: 839.98px) { .ui-split-view[data-count="2"] .ui-split-view-panes > :nth-child(1) { flex: 0 0 var(--ui-split-view-a); } .ui-split-view[data-count="2"][data-fold="first"] .ui-split-view-panes > :nth-child(1) { position: absolute; inset: 0 auto 0 0; width: 40%; visibility: hidden; pointer-events: none; } .ui-split-view[data-count="2"] .ui-split-view-divider[data-index="0"] { display: block; left: var(--ui-split-view-a); } .ui-split-view[data-count="2"][data-fold="first"] .ui-split-view-divider[data-index="0"] { left: 0; margin-left: 0; } .ui-split-view[data-count="3"][data-at="0"] .ui-split-view-panes > :nth-child(1) { flex: 0 0 var(--ui-split-view-m); } .ui-split-view[data-count="3"][data-at="0"] .ui-split-view-panes > :nth-child(3) { position: absolute; inset: 0 auto 0 0; width: 40%; visibility: hidden; pointer-events: none; } .ui-split-view[data-count="3"]:not([data-at="0"]) .ui-split-view-panes > :nth-child(1) { position: absolute; inset: 0 auto 0 0; width: 40%; visibility: hidden; pointer-events: none; } .ui-split-view[data-count="3"]:not([data-at="0"]) .ui-split-view-panes > :nth-child(2) { flex: 0 0 var(--ui-split-view-m); border-left: 0; } .ui-split-view[data-count="3"][data-at="0"] .ui-split-view-divider[data-index="0"] { display: block; left: var(--ui-split-view-m); } .ui-split-view[data-count="3"]:not([data-at="0"]) .ui-split-view-divider[data-index="1"] { display: block; left: var(--ui-split-view-m); }}@container ui-split-view (max-width: 599.98px) { .ui-split-view-panes > .ui-split-view-pane { position: absolute; inset: 0 auto 0 0; width: 100%; visibility: hidden; pointer-events: none; border-left: 0; } .ui-split-view[data-at="0"] .ui-split-view-panes > :nth-child(1), .ui-split-view[data-at="1"] .ui-split-view-panes > :nth-child(2), .ui-split-view[data-at="2"] .ui-split-view-panes > :nth-child(3) { visibility: visible; pointer-events: auto; }}@media (prefers-reduced-motion: reduce) { .ui-split-view-settle .ui-split-view-pane, .ui-split-view-settle .ui-split-view-divider, .ui-split-view-grip { transition: none; }}`fn split_view_clamp(v: num, lo: num, hi: num) -> num = hi < lo ? lo : v < lo ? lo : v > hi ? hi : v// The size a width gives. A width not measured yet is nothing's business: "".fn split_view_size(width: num) -> str = width <= 0 ? "" : width < 600 ? "compact" : width < 840 ? "medium" : "expanded"// How far along the panes a divider sits, in percent, for the pane sizes given.fn split_view_at(size: str, count: int, fold: str, i: int, s) -> num { if size == "medium" { return count == 3 ? s.medium : s.first } if i == 0 { return s.first } fold == "first" ? s.second : s.first + s.second}// The range a divider may move in: `room` is the least a pane may be, in percent.fn split_view_range(size: str, count: int, fold: str, i: int, s, room: num) { if size == "medium" || count == 2 { return { low: room, high: 100 - room } } if i == 0 { return { low: room, high: s.first + s.second - room } } fold == "first" ? { low: room, high: 100 - room } : { low: s.first + room, high: 100 - room }}// The sizes with a divider at `to`, which the caller has already kept in range. The// first divider gives what the first pane gains to the second, and leaves the third alone.fn split_view_put(size: str, count: int, fold: str, i: int, to: num, s) { if size == "medium" && count == 3 { return { first: s.first, second: s.second, medium: to } } if i == 0 { let second = count == 3 ? s.first + s.second - to : s.second return { first: to, second: second, medium: s.medium } } fold == "first" ? { first: s.first, second: to, medium: s.medium } : { first: s.first, second: to - s.first, medium: s.medium }}// What a drag ends in: it puts a pane away when the finger went well past the least// width, or flicked that way. Only the outer panes go. Gives "first", "last" or null.fn split_view_folds(i: int, count: int, size: str, past: num, speed: num, moved: num) -> str? { let may_first = i == 0 && (size == "expanded" || count == 2) let may_last = i == 1 && size == "expanded" if may_first && (past < -48 || (speed < -0.6 && moved < -24)) { return "first" } if may_last && (past > 48 || (speed > 0.6 && moved > 24)) { return "last" } null}fn split_view_sizes(sizes, count: int, medium: num) { let raw = type_of(sizes) == "list" ? sizes | filter((n) => type_of(n) == "int" || type_of(n) == "float") : [] let first = split_view_clamp(len(raw) > 0 ? raw[0] : (count == 3 ? 28 : 36), 5, 90) let second = split_view_clamp(len(raw) > 1 ? raw[1] : 34, 5, 95 - first) { first: first, second: second, medium: split_view_clamp(medium, 10, 90) }}fn split_view_vars(s, height: str?) -> str { let tall = height != null && !re.test(height, "[;{}<>\"]") ? `; --ui-split-view-h: ${height}` : "" `--ui-split-view-a: ${s.first}%; --ui-split-view-b: ${s.second}%; --ui-split-view-m: ${s.medium}%${tall}`}view SplitViewPane(title: str? = null, class: str? = null, children) { style global split_view_css <section class={ui_cls(["ui-split-view-pane", class])} aria-label={title} tabindex="-1">{children}</section>}component SplitView(titles: list = ["First", "Second"], sizes = null, medium: num = 40, min_width: num = 200, step: num = 4, pane: int = 0, folded: str = "", bar: bool = true, height: str? = null, label: str? = null, on_pane = null, on_resize = null, class: str? = null, children) { style global split_view_css let count = len(titles) >= 3 ? 3 : 2 state at = split_view_clamp(pane, 0, len(titles) >= 3 ? 2 : 1) state seen = pane state fold = folded == "first" || folded == "last" ? folded : "" state shape = split_view_sizes(sizes, len(titles) >= 3 ? 3 : 2, medium) state size = "" state root = null state pull = null // { i, x, last_x, last_at, speed, on, shape, past } while a finger is on a divider if pane != seen { seen = pane at = split_view_clamp(pane, 0, count - 1) } // The live sizes go on the body, which has no style in its markup: a style={...} is // written whole whenever what it reads changes, which would take them away mid-drag. let body = () => root.querySelector(".ui-split-view-body") let show = (s) => { if root != null { let b = body() b.style.setProperty("--ui-split-view-a", `${s.first}%`) b.style.setProperty("--ui-split-view-b", `${s.second}%`) b.style.setProperty("--ui-split-view-m", `${s.medium}%`) } } let go = (i: int) => { if i != at { at = i on_pane && on_pane(i) } } let settle = () => { if root != null { let b = body() b.classList.add("ui-split-view-settle") b.addEventListener("transitionend", (_e) => { b.classList.remove("ui-split-view-settle") }, { once: true }) } } let told = () => { on_resize && on_resize({ first: round(shape.first, 1), second: round(shape.second, 1), medium: round(shape.medium, 1) }) } let room = () => { let w = root != null ? root.clientWidth : 1000 split_view_clamp(min_width / w * 100, 5, 45) } // Where a press on a pane or the focus in one lands: which pane is in use. let used = (e) => { let p = e.target.closest != null ? e.target.closest(".ui-split-view-pane") : null if p != null && p.parentElement != null { let kids = p.parentElement.children // isSameNode and not ==, which calls any two elements equal. let found = range(kids.length) | filter((n) => kids[n].isSameNode(p)) if len(found) > 0 { go(found[0]) } } } let away = (i: int) => (fold == "first" && i == 0) || (fold == "last" && i == 1) let flip = (which: str) => { fold = fold == which ? "" : which if fold == "first" && at == 0 { go(1) } if fold == "last" && at == 2 { go(1) } } let back = at == 0 || size == "" || size == "expanded" || (size == "medium" && count == 2) ? null : (size == "medium" ? 0 : at - 1) let next = size == "compact" && at < count - 1 ? at + 1 : (size == "medium" && count == 3 && at == 0 ? 2 : null) let can_hide = size == "expanded" || (size == "medium" && count == 2) <div class={ui_cls(["ui-split-view", class])} role="group" aria-label={label} data-count={count} data-at={at} data-fold={fold == "" ? null : fold} data-size={size == "" ? null : size} style={split_view_vars(split_view_sizes(sizes, count, medium), height)} @mount={(el) => { let look = (_e = null) => { size = split_view_size(el.clientWidth) } let seen_by = js_new("ResizeObserver", look) seen_by.observe(el) root = el () => { seen_by.disconnect() } }}> {bar && <div.ui-split-view-bar> {back != null && <button.ui-split-view-go type="button" data-go="back" @click={() => { go(back) }}><span aria-hidden="true">‹</span> {titles[back]}</button>} {can_hide && <button.ui-split-view-go type="button" data-go="hide" aria-expanded={fold == "first" ? "false" : "true"} @click={() => { flip("first"); told() }}>{fold == "first" ? `Show ${titles[0]}` : `Hide ${titles[0]}`}</button>} <p.ui-split-view-title aria-live="polite">{titles[at]}</p> {next != null && <button.ui-split-view-go type="button" data-go="next" @click={() => { go(next) }}>{titles[next]} <span aria-hidden="true">›</span></button>} </div>} <div.ui-split-view-body> <div.ui-split-view-panes @pointerdown={used} @focusin={used}>{children}</div> {for i in range(count - 1) => <div.ui-split-view-divider key={i} data-index={i} role="separator" tabindex="0" aria-orientation="vertical" aria-label={`Resize ${titles[i]} and ${titles[i + 1]}`} aria-valuenow={away(i) ? (i == 0 ? 0 : 100) : round(split_view_at(size, count, fold, i, shape))} aria-valuemin={0} aria-valuemax={100} @pointerdown={(e) => { if e.button == 0 { e.preventDefault() e.target.setPointerCapture(e.pointerId) e.target.focus() pull = { i: i, x: e.clientX, last_x: e.clientX, last_at: e.timeStamp, speed: 0, on: false, shape: shape, past: 0 } } }} @pointermove={(e) => { if pull != null { let dx = e.clientX - pull.x let on = pull.on || abs(dx) > 4 let dt = e.timeStamp - pull.last_at let step_v = dt > 0 ? (e.clientX - pull.last_x) / dt : pull.speed let past = pull.past let live = pull.shape if on && !away(pull.i) { let r = body().getBoundingClientRect() let raw = (e.clientX - r.left) / r.width * 100 let lim = split_view_range(size, count, fold, pull.i, pull.shape, room()) let kept = split_view_clamp(raw, lim.low, lim.high) past = (raw - kept) / 100 * r.width // Past the limit it gives a little, to say there is more to a harder pull. live = split_view_put(size, count, fold, pull.i, kept, pull.shape) show(split_view_put(size, count, fold, pull.i, kept + (raw - kept) * 0.2, pull.shape)) body().classList.add("ui-split-view-dragging") } pull = { i: pull.i, x: pull.x, last_x: e.clientX, last_at: e.timeStamp, speed: pull.speed == 0 ? step_v : pull.speed * 0.4 + step_v * 0.6, on: on, shape: pull.shape, past: past, live: live } } }} @pointerup={(e) => { if pull != null { let was = pull pull = null body().classList.remove("ui-split-view-dragging") if away(was.i) { settle() flip(was.i == 0 ? "first" : "last") told() } else if was.on { let run = e.timeStamp - was.last_at < 100 ? was.speed : 0 let drop = split_view_folds(was.i, count, size, was.past, run, e.clientX - was.x) settle() if drop != null { show(was.shape) flip(drop) } else { shape = was.live show(was.live) } told() } } }} @pointercancel={() => { if pull != null { let was = pull pull = null body().classList.remove("ui-split-view-dragging") settle() show(was.shape) } }} @keydown={(e) => { let now = split_view_at(size, count, fold, i, shape) let lim = split_view_range(size, count, fold, i, shape, room()) let by = e.shiftKey ? step * 3 : step let to = match e.key { "ArrowLeft" => now - by "ArrowRight" => now + by "Home" => lim.low "End" => lim.high _ => null } if to != null && !away(i) { e.preventDefault() shape = split_view_put(size, count, fold, i, split_view_clamp(to, lim.low, lim.high), shape) show(shape) told() } else if e.key == "Enter" { e.preventDefault() flip(i == 0 ? "first" : "last") told() } }}> <span.ui-split-view-grip aria-hidden="true"></span> </div>} </div> </div>}Related
SizeClassViewTells 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 downloadsDragBetweenPanesDrag an item from one pane to another by finger or pen, with a drop preview, a keyboard route and on_move.3 views · 5 downloadsPhoneShellThe 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 downloads
Thread
Thread 0 posts
On SplitView: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.