ResponsiveSplit
Master and detail as one stack on a phone and side by side on a tablet, changing live on rotation and keeping the selection.
Demo
Inbox
Rooftop Deliveries
Message 2. It is long enough to read on a phone, and wide enough on a tablet to want a side of its own.
Open: m2. Picked 0, back 0, read 0.
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/responsive_split.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/responsive_split.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/responsive_split.qwe
let responsive_split_demo_css = css`.responsive-split-demo-note { box-sizing: border-box; display: block; width: 100%; min-height: 120px; padding: 10px 12px; 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); }`let responsive_split_demo_mail = range(1, 41) | map((i) => { id: `m${i}`, title: i == 1 ? "Ana Silva" : i == 2 ? "Rooftop Deliveries" : i == 3 ? "Ben Okafor" : `Sender ${i}`, subtitle: i == 1 ? "The plans for Saturday" : i == 2 ? "Your parcel is at the depot" : `Message number ${i}`, meta: `${8 + (i % 4)}:${i % 2 == 0 ? "15" : "40"}`, body: `Message ${i}. It is long enough to read on a phone, and wide enough on a tablet to want a side of its own.`})component ResponsiveSplitDemoLive() { style global responsive_split_demo_css state open = "m2" state picks = 0 state backs = 0 state read = 0 // Changes the list as well as the page's text, as new mail would while a finger is down. let mail = responsive_split_demo_mail | map((m) => m.id == open ? { id: m.id, title: m.title, subtitle: `Read ${read} times`, meta: m.meta, body: m.body } : m) <div.demo-tablet> <ResponsiveSplit id="responsive-split-demo" title="Inbox" items={mail} selected={open} label="Messages" on_select={(id) => { open = id; picks += 1 }} on_back={() => { backs += 1 }}> <label class="ui-sr" for="responsive-split-demo-reply">Reply</label> <textarea.responsive-split-demo-note id="responsive-split-demo-reply" placeholder="Write a reply"></textarea> <p.demo-note id="responsive-split-demo-out">Open: {open}. Picked {picks}, back {backs}, read {read}.</p> <Button size="lg" variant="outline" id="responsive-split-demo-read" on_click={() => { read += 1 }}>Mark as read</Button> </ResponsiveSplit> </div>}view ResponsiveSplitDemo() = <div><ResponsiveSplitDemoLive/></div>Source
ui/responsive_split.qwe
// ResponsiveSplit: a list and what is picked from it, as one stack on a phone and side// by side on a tablet. It looks at its own width, not the screen's, so it stacks in// Slide Over or a narrow pane of Split View and opens up on a phone turned on its side,// and it changes as that width does, with no break: what is selected stays selected, each// side keeps its scroll, and a focused element keeps the focus or hands it to what took// its place. In the stack, Back, Escape, or a finger dragged from the left edge goes back// to the list; a short drag springs back. The first layout comes from CSS, so nothing// jumps as the page starts.//// <ResponsiveSplit title="Inbox" items={mail}/>// <ResponsiveSplit title="Inbox" items={mail} selected={open} on_select={(id) => { open = id }}>// <Reply to={open}/>// </ResponsiveSplit>//// items: a list of { id, title, subtitle?, meta?, body? }: id is text or a number, and// a repeated id is shown once. body: text shown as the detail, so a plain page needs no// more. children: more detail, under the body, shown while something is selected.// selected: the id to start on, and to go to when it changes. title: the list's heading.// back: the back button's words ("Back"). empty: what the detail says with nothing// selected ("Pick one to see it here."). none: what the list says with nothing in it// ("Nothing here yet."). side: the list's width side by side, in pixels (320). label:// what screen readers call the list ("List"). id, class: for the outer element.// on_select(id), on_back(): inside one of your components.//// It fills its parent, which needs a height: 100dvh, say. It splits at 640 px.// Going back keeps the selection, shown on the list, so a phone turned again shows it.import ui/theme.qwe use ui_clslet responsive_split_css = css`.ui-responsive-split { position: relative; container-type: inline-size; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; overflow: hidden; background: var(--ui-bg); color: var(--ui-fg); font: 15px/1.45 var(--ui-font); }/* The track is moved by hand in a drag, so its markup has no style attribute, and the width goes on the outer element: one would be written again on any change, and the drag would jump home. */.ui-responsive-split-track { display: flex; width: 200%; height: 100%; transition: transform .26s cubic-bezier(.2, .8, .2, 1); }.ui-responsive-split-pane { box-sizing: border-box; display: flex; flex-direction: column; flex: none; width: 50%; min-width: 0; height: 100%; min-height: 0; background: var(--ui-bg); }/* visibility, so that a pane out of sight keeps its scroll and leaves the tab order and screen readers. */.ui-responsive-split-detailing .ui-responsive-split-track { transform: translateX(-50%); }.ui-responsive-split-detailing .ui-responsive-split-side { visibility: hidden; transition: visibility 0s .26s; }.ui-responsive-split:not(.ui-responsive-split-detailing) .ui-responsive-split-main { visibility: hidden; transition: visibility 0s .26s; }.ui-responsive-split-dragging .ui-responsive-split-track { transition: none; user-select: none; -webkit-user-select: none; }.ui-responsive-split-dragging .ui-responsive-split-pane { visibility: visible !important; transition: none !important; }.ui-responsive-split-head { display: flex; align-items: center; gap: 4px; flex: none; min-height: 52px; box-sizing: content-box; padding: var(--ui-safe-top, env(safe-area-inset-top, 0px)) 12px 0 var(--ui-responsive-split-left, 16px); border-bottom: 1px solid var(--ui-border); background: var(--ui-surface);}.ui-responsive-split-head h2 { flex: 1; min-width: 0; margin: 0; overflow: hidden; font-size: 17px; font-weight: 600; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }.ui-responsive-split-head h2:focus { outline: none; }.ui-responsive-split-side .ui-responsive-split-head { padding-left: max(16px, var(--ui-safe-left, env(safe-area-inset-left, 0px))); }.ui-responsive-split-main .ui-responsive-split-head { padding-left: max(4px, var(--ui-safe-left, env(safe-area-inset-left, 0px))); }.ui-responsive-split-back { display: inline-flex; align-items: center; gap: 2px; flex: none; min-width: 44px; min-height: 44px; padding: 0 10px 0 4px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-accent); font: 500 15px/1 var(--ui-font); cursor: pointer;}.ui-responsive-split-back:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-responsive-split-back svg { width: 22px; height: 22px; }.ui-responsive-split-scroll { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; padding-bottom: var(--ui-safe-bottom, env(safe-area-inset-bottom, 0px)); }.ui-responsive-split-rows { margin: 0; padding: 0; list-style: none; }.ui-responsive-split-row { display: grid; grid-template-columns: 1fr auto; gap: 0 10px; align-items: center; box-sizing: border-box; width: 100%; min-height: 60px; padding: 9px 12px 9px max(16px, var(--ui-safe-left, env(safe-area-inset-left, 0px))); border: 0; border-bottom: 1px solid var(--ui-border); background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer;}.ui-responsive-split-row:focus-visible { outline: none; box-shadow: inset var(--ui-focus); }.ui-responsive-split-row b { overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }.ui-responsive-split-row small { grid-column: 1; overflow: hidden; color: var(--ui-muted); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }.ui-responsive-split-row i { grid-row: 1 / 3; grid-column: 2; color: var(--ui-muted); font: 13px/1.3 var(--ui-font); }.ui-responsive-split-row[aria-current="true"] { background: var(--ui-accent-soft); }.ui-responsive-split-body { padding: 16px max(16px, var(--ui-safe-right, env(safe-area-inset-right, 0px))) 24px 16px; }.ui-responsive-split-body > div > :first-child { margin-top: 0; }.ui-responsive-split-text { margin: 0 0 16px; white-space: pre-line; }.ui-responsive-split-none { padding: 40px 24px; color: var(--ui-muted); text-align: center; }/* Sideways is the edge drag's. A text box or a wide table in the detail still pans by itself, so a drag begun on one is that box's. */.ui-responsive-split-body { box-sizing: border-box; min-height: 100%; touch-action: pan-y; }.ui-responsive-split-dragging .ui-responsive-split-scroll { overflow: hidden; }@container (min-width: 640px) { .ui-responsive-split-track { width: 100%; transform: none !important; transition: none; } .ui-responsive-split-pane { visibility: visible !important; transition: none; } .ui-responsive-split-side { width: var(--ui-responsive-split-width, 320px); max-width: 45%; border-right: 1px solid var(--ui-border); } .ui-responsive-split-main { flex: 1; width: auto; } .ui-responsive-split-back { display: none; } .ui-responsive-split-main .ui-responsive-split-head { padding-left: 20px; } .ui-responsive-split-body { padding-left: 20px; padding-right: max(20px, var(--ui-safe-right, env(safe-area-inset-right, 0px))); }}@media (hover: hover) { .ui-responsive-split-row:hover { background: var(--ui-subtle); } .ui-responsive-split-row[aria-current="true"]:hover { background: var(--ui-accent-soft); }}@media (prefers-reduced-motion: reduce) { .ui-responsive-split-track { transition: none; } .ui-responsive-split-detailing .ui-responsive-split-side, .ui-responsive-split:not(.ui-responsive-split-detailing) .ui-responsive-split-main { transition: none; }}`// Width at which it opens up: the same as the @container above.let responsive_split_wide = 640// The rows, each id once, the first of a repeat. Prefixed, as a map moves number-like// keys and throws on a key such as `constructor`.fn responsive_split_rows(items) -> list { if type_of(items) != "list" { return [] } let rows = enumerate(items) | filter((p) => type_of(p[1]) == "map" && get(p[1], "id") != null) | map((p) => { at: p[0], key: `k${str(p[1].id)}`, row: p[1] }) let groups = group_by(rows, (r) => r.key) rows | filter((r) => get(groups, r.key, [])[0].at == r.at) | map((r) => r.row)}// Whether a drag ending here goes back: a third of the way, or a flick that way.fn responsive_split_goes(dx, speed, width) -> bool = dx > width / 3 || (dx > 24 && speed > 0.4)component ResponsiveSplit(items = [], selected = null, title: str? = null, back: str = "Back", empty: str = "Pick one to see it here.", none: str = "Nothing here yet.", side: int = 320, label: str = "List", id: str? = null, class: str? = null, on_select = null, on_back = null, children) { style global responsive_split_css state sel = selected state pane = selected != null ? "detail" : "list" state last = selected state host = null state wide = false state press = null if selected != last { last = selected sel = selected if selected != null { pane = "detail" } } let rows = responsive_split_rows(items) let current = sel != null ? find(rows, (r) => str(r.id) == str(sel)) : null if current == null && pane == "detail" { pane = "list" } let narrow = () => host.clientWidth < responsive_split_wide let heading = () => host.querySelector(".ui-responsive-split-main h2") let row = () => host.querySelector(".ui-responsive-split-row[aria-current='true']") ?? host.querySelector(".ui-responsive-split-row") let track = () => host.querySelector(".ui-responsive-split-track") let pick = (it) => { sel = it.id pane = "detail" on_select && on_select(it.id) // A wait lets the page update, as a hidden pane cannot take the focus. js("Promise.resolve") if narrow() { heading().focus({ preventScroll: true }) } } let go_back = () => { pane = "list" on_back && on_back() js("Promise.resolve") let r = row() if r != null { r.focus({ preventScroll: true }) } } let release = () => { host.classList.remove("ui-responsive-split-dragging") track().style.transform = "" press = null } <div class={ui_cls(["ui-responsive-split", pane == "detail" && "ui-responsive-split-detailing", class])} id={id} style={`--ui-responsive-split-width: ${side}px`} @mount={(el) => { let doc = js("document") host = el wide = el.clientWidth >= responsive_split_wide let fit = () => { let now = el.clientWidth >= responsive_split_wide if now != wide { wide = now // Narrower: the focus may be in the pane that has just gone. let held = doc.activeElement if !now && held != null { let gone = el.querySelector(pane == "detail" ? ".ui-responsive-split-side" : ".ui-responsive-split-main") if gone.contains(held) { let to = pane == "detail" ? heading() : row() if to != null { to.focus({ preventScroll: true }) } } } } } let grown = js_new("ResizeObserver", fit) grown.observe(el) () => { grown.disconnect() } }}> <div.ui-responsive-split-track> <section.ui-responsive-split-pane.ui-responsive-split-side aria-label={label}> {title != null && <div.ui-responsive-split-head><h2>{title}</h2></div>} <div.ui-responsive-split-scroll> {len(rows) == 0 && <p.ui-responsive-split-none>{none}</p>} <ul.ui-responsive-split-rows @keydown={(e) => { if e.key == "ArrowDown" || e.key == "ArrowUp" { e.preventDefault() let li = e.target.closest("li") let to = e.key == "ArrowDown" ? li.nextElementSibling : li.previousElementSibling if to != null { to.querySelector("button").focus() } } }}> {for it in rows => <li key={it.id}> <button.ui-responsive-split-row type="button" aria-current={current != null && str(it.id) == str(sel) ? "true" : null} @click={() => { pick(it) }}> <b>{it.title}</b> {get(it, "meta") != null && <i>{it.meta}</i>} {get(it, "subtitle") != null && <small>{it.subtitle}</small>} </button> </li>} </ul> </div> </section> <section.ui-responsive-split-pane.ui-responsive-split-main aria-label={current != null ? current.title : empty} @keydown={(e) => { if e.key == "Escape" && pane == "detail" && narrow() { e.preventDefault() go_back() } }}> <div.ui-responsive-split-head hidden={current == null}> <button.ui-responsive-split-back type="button" @click={() => { go_back() }}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg> {back} </button> <h2 tabindex="-1">{current != null ? current.title : ""}</h2> </div> <div.ui-responsive-split-scroll @pointerdown={(e) => { if e.button == 0 && narrow() && pane == "detail" && e.clientX - host.getBoundingClientRect().left <= 28 { press = { id: e.pointerId, x: e.clientX, y: e.clientY, locked: false, dx: 0, was: e.clientX, then: e.timeStamp, speed: 0 } } }} @pointermove={(e) => { if press != null && e.pointerId == press.id { let dx = e.clientX - press.x let dy = e.clientY - press.y if !press.locked { if abs(dx) >= 8 || abs(dy) >= 8 { // Up and down, or leftwards, is the content's: it is let go. if abs(dy) > abs(dx) || dx < 0 { press = null } else { e.target.closest(".ui-responsive-split-scroll").setPointerCapture(e.pointerId) host.classList.add("ui-responsive-split-dragging") press = set(press, "locked", true) } } } if press != null && press.locked { let gap = e.timeStamp - press.then let speed = gap > 0 ? (e.clientX - press.was) / gap : press.speed track().style.transform = `translateX(calc(-50% + ${max([0, dx])}px))` press = { id: press.id, x: press.x, y: press.y, locked: true, dx, was: e.clientX, then: e.timeStamp, speed } } } }} @pointerup={(e) => { if press != null && e.pointerId == press.id { let goes = press.locked && responsive_split_goes(press.dx, press.speed, host.clientWidth) release() if goes { go_back() } } }} @pointercancel={() => { if press != null { release() } }}> {current == null && <p.ui-responsive-split-none>{empty}</p>} {current != null && <div.ui-responsive-split-body> {get(current, "body") != null && <p.ui-responsive-split-text>{current.body}</p>} <div>{children}</div> </div>} </div> </section> </div> </div>}Related
RotationLayoutTwo arrangements of one set of children, upright and turned: no flash, and focus and scroll kept across the turn.3 views · 6 downloadsDevicePreviewA phone or tablet frame sized by model for showing a component, rotation, safe areas drawn, dark or light.2 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 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 ResponsiveSplit: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.