Mobile

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.

#mobile#interactive#tablet#layout#master-detail#rotation

Demo

Preview

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

Thread 0 posts

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

No posts yet. The first one opens the thread.