Layout

MasterDetail

A list on the left and the item picked in detail on the right; one at a time with a Back button when narrow.

#layout#list#navigation#mobile#keyboard#interactive

Demo

Preview
  • Quarterly numbersAna Silva09:41
  • Lunch on Thursday?Ben Okafor08:12
  • Your invoice for MarchBillingMon
  • Welcome to the teamChen WeiSun

Quarterly numbers

Hi all, the report is attached. Revenue is up 12% on last quarter, mostly from the new plans.

  • Ada LovelaceAnalystLondon
  • Grace HopperRear admiralArlington
  • Linus TorvaldsKernelPortland

Ada Lovelace

Analyst, London.

Showing Ada Lovelace.

In a 380px-wide box it shows one side at a time, with a Back button:

  • Quarterly numbersAna Silva09:41
  • Lunch on Thursday?Ben Okafor08:12
  • Your invoice for MarchBillingMon
  • Welcome to the teamChen WeiSun

Quarterly numbers

Hi all, the report is attached. Revenue is up 12% on last quarter, mostly from the new plans.

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

Example

The code of the preview above.

demos/master_detail.qwe
let master_detail_demo_mail = [    { id: "m1", title: "Quarterly numbers", subtitle: "Ana Silva", meta: "09:41", icon: "A" },    { id: "m2", title: "Lunch on Thursday?", subtitle: "Ben Okafor", meta: "08:12", icon: "B" },    { id: "m3", title: "Your invoice for March", subtitle: "Billing", meta: "Mon", icon: "€" },    { id: "m4", title: "Welcome to the team", subtitle: "Chen Wei", meta: "Sun", icon: "C" }]view MasterDetailDemoPanes() =    [        <MasterDetailPane id="m1" open={true}><h4 style="margin: 0 0 6px">Quarterly numbers</h4><p style="margin: 0">Hi all, the report is attached. Revenue is up 12% on last quarter, mostly from the new plans.</p></MasterDetailPane>,        <MasterDetailPane id="m2"><h4 style="margin: 0 0 6px">Lunch on Thursday?</h4><p style="margin: 0">The place by the river at one. Say if you are in.</p></MasterDetailPane>,        <MasterDetailPane id="m3"><h4 style="margin: 0 0 6px">Your invoice for March</h4><p style="margin: 0">€240.00, paid by card ending 4242. Thank you.</p></MasterDetailPane>,        <MasterDetailPane id="m4"><h4 style="margin: 0 0 6px">Welcome to the team</h4><p style="margin: 0">Your first week's plan is in the wiki. Ask me anything.</p></MasterDetailPane>    ]let master_detail_demo_people = [    { id: 1, title: "Ada Lovelace", subtitle: "Analyst", meta: "London" },    { id: 2, title: "Grace Hopper", subtitle: "Rear admiral", meta: "Arlington" },    { id: 3, title: "Linus Torvalds", subtitle: "Kernel", meta: "Portland" }]component MasterDetailDemoLive() {    state who = 1    let p = find(master_detail_demo_people, (x) => x.id == who)    <div.demo-stack>        <MasterDetail id="master-detail-demo-people" label="People" items={master_detail_demo_people} selected={1} height="220px"                      width="240px" on_select={(id) => { who = id }}>            {p != null && <div>                <h4 style="margin: 0 0 6px">{p.title}</h4>                <p style="margin: 0; color: var(--ui-muted)">{p.subtitle}, {p.meta}.</p>            </div>}        </MasterDetail>        <p.demo-note>{p == null ? "Nobody picked." : `Showing ${p.title}.`}</p>    </div>}view MasterDetailDemo() =    <div.demo-stack>        <MasterDetail id="master-detail-demo-mail" label="Messages" items={master_detail_demo_mail} selected="m1" height="300px">            <MasterDetailDemoPanes/>        </MasterDetail>        <MasterDetailDemoLive/>        <p.demo-note>In a 380px-wide box it shows one side at a time, with a Back button:</p>        <div style="width: 380px; max-width: 100%">            <MasterDetail id="master-detail-demo-narrow" label="Messages" items={master_detail_demo_mail} selected="m2" height="260px">                <MasterDetailDemoPanes/>            </MasterDetail>        </div>    </div>

Source

ui/master_detail.qwe
// MasterDetail: a list on the left and the one picked from it in detail on the// right: mail and its message, settings and their page, customers and one's// record. Where it is narrow (under 640px of its own width) it shows one at a// time: the list, then the detail with a Back button.//// The detail is the children. From a page, give a MasterDetailPane for each item,// by the item's id, and the one picked shows:////   <MasterDetail id="mail" label="Messages" selected="m1"//                 items={[{ id: "m1", title: "Quarterly numbers", subtitle: "Ana Silva", meta: "09:41" }, ...]}>//       <MasterDetailPane id="m1"> ... </MasterDetailPane>//       <MasterDetailPane id="m2"> ... </MasterDetailPane>//   </MasterDetail>//// Inside one of your components, make the detail from your own state instead:////   <MasterDetail id="people" items={people} selected={who} on_select={(id) => { who = id }}>//       <PersonCard person={find(people, (p) => p.id == who)}/>//   </MasterDetail>//// items: { id, title, subtitle?, meta?, icon? }: each a row of the list; icon is text// (an emoji, an initial). selected: the id picked to start with (none: the list alone,// with `empty` on the right). label: what screen readers call the list ("Items").// height: the whole thing's height, as CSS ("480px"); the list and the detail scroll// inside it. width: the list's width, as CSS ("280px"). empty: what the detail says// with nothing picked ("Pick an item"). back: the Back button's text ("Back").// The list is a listbox: ↓ ↑ move, Home and End go to the ends, Enter or Space picks.// on_select(id): inside one of your components, called with the id picked (null// when Back clears it on a narrow screen).import ui/theme.qwe use ui_clslet master_detail_css = css`.ui-mdetail { container: ui-mdetail / inline-size; min-width: 0; color: var(--ui-fg); font: 14px/1.4 var(--ui-font); }.ui-mdetail-in {  display: grid; grid-template-columns: var(--ui-mdetail-w, 280px) minmax(0, 1fr); height: var(--ui-mdetail-h, 480px); min-width: 0;  overflow: hidden; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface);}.ui-mdetail-list { margin: 0; padding: 6px; overflow: auto; list-style: none; border-right: 1px solid var(--ui-border); outline: none; }.ui-mdetail-list:focus-visible { box-shadow: inset 0 0 0 2px var(--ui-accent); }.ui-mdetail-item {  display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 10px; border: 0; border-radius: var(--ui-radius-sm);  background: transparent; color: var(--ui-fg); font: inherit; text-align: left; cursor: pointer;}.ui-mdetail-item:hover { background: var(--ui-subtle); }.ui-mdetail-item[aria-selected="true"] { background: var(--ui-accent-soft); }.ui-mdetail-item[aria-selected="true"] .ui-mdetail-title { color: var(--ui-accent); }.ui-mdetail-list:focus-visible .ui-mdetail-active { box-shadow: inset 0 0 0 2px var(--ui-accent); }.ui-mdetail-icon { display: grid; place-items: center; flex: none; width: 32px; height: 32px; border-radius: 50%; background: var(--ui-subtle); font-size: 15px; }.ui-mdetail-text { flex: 1; min-width: 0; display: grid; gap: 1px; }.ui-mdetail-title, .ui-mdetail-sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-mdetail-title { font-weight: 500; }.ui-mdetail-sub { color: var(--ui-muted); font-size: 13px; }.ui-mdetail-meta { flex: none; align-self: flex-start; color: var(--ui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }.ui-mdetail-detail { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: auto; }.ui-mdetail-back {  display: none; align-items: center; gap: 4px; width: fit-content; margin: 8px 8px 0; padding: 6px 10px 6px 6px; border: 0;  border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-accent); font: 500 14px/1 var(--ui-font); cursor: pointer;}.ui-mdetail-back:hover { background: var(--ui-accent-soft); }.ui-mdetail-back:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-mdetail-back svg { width: 16px; height: 16px; }.ui-mdetail-body { flex: 1; padding: 20px; }.ui-mdetail-body > :first-child { margin-top: 0; }.ui-mdetail-empty { display: grid; place-items: center; height: 100%; color: var(--ui-muted); }.ui-mdetail-pane[hidden], .ui-mdetail-panes[hidden] { display: none; }.ui-mdetail-pane > :first-child { margin-top: 0; }.ui-mdetail-pane > :last-child { margin-bottom: 0; }@container ui-mdetail (max-width: 639px) {  .ui-mdetail-in { grid-template-columns: minmax(0, 1fr); }  .ui-mdetail-list { border-right: 0; }  .ui-mdetail-detail, .ui-mdetail-showing .ui-mdetail-list { display: none; }  .ui-mdetail-showing .ui-mdetail-detail { display: flex; }  .ui-mdetail-back { display: inline-flex; }}`component MasterDetail(id: str, items: list = [], selected = null, label: str = "Items", height: str = "480px",                       width: str = "280px", empty: str = "Pick an item", back: str = "Back", on_select = null,                       class: str? = null, children) {    style global master_detail_css    state sel = selected    state active = max([0, index_of(items | map((it) => it.id), selected) ?? 0])    state detail = null    let n = len(items)    // From a page, the detail holds a pane per item: only the one picked shows.    let show = (to) => {        if detail != null {            for p in js("Array.from", detail.querySelectorAll("[data-ui-md-pane]")) => { p.hidden = to == null || p.dataset.uiMdPane != str(to) }        }    }    let pick = (i: int) => {        active = i        sel = items[i].id        show(sel)        on_select && on_select(sel)    }    let go = (e, i: int) => {        active = i        let row = e.target.closest(".ui-mdetail-list").children[i]        row.scrollIntoView({ block: "nearest" })    }    <div class={ui_cls(["ui-mdetail", class])} id={id} style={`--ui-mdetail-w: ${width}; --ui-mdetail-h: ${height}`}>        <div class={ui_cls(["ui-mdetail-in", sel != null && "ui-mdetail-showing"])}>            <ul.ui-mdetail-list role="listbox" tabindex="0" aria-label={label} id={`${id}-list`}                aria-activedescendant={n > 0 ? `${id}-item-${active}` : null} aria-controls={`${id}-detail`}                @keydown={(e) => {                    let to = match e.key {                        "ArrowDown" => min([n - 1, active + 1])                        "ArrowUp" => max([0, active - 1])                        "Home" => 0                        "End" => n - 1                        _ => null                    }                    if to != null && n > 0 {                        e.preventDefault()                        go(e, to)                    } else if (e.key == "Enter" || e.key == " ") && n > 0 {                        e.preventDefault()                        pick(active)                    }                }}>                {for [i, it] in enumerate(items) => <li.ui-mdetail-item key={it.id} id={`${id}-item-${i}`} role="option"                        class={i == active ? "ui-mdetail-active" : null} aria-selected={sel != null && sel == it.id ? "true" : "false"}                        @mousedown={(e) => { e.preventDefault(); e.target.closest(".ui-mdetail-list").focus() }}                        @click={() => { pick(i) }}>                    {get(it, "icon") != null && <span.ui-mdetail-icon aria-hidden="true">{it.icon}</span>}                    <span.ui-mdetail-text>                        <span.ui-mdetail-title>{it.title}</span>                        {get(it, "subtitle") != null && <span.ui-mdetail-sub>{it.subtitle}</span>}                    </span>                    {get(it, "meta") != null && <span.ui-mdetail-meta>{it.meta}</span>}                </li>}            </ul>            <section.ui-mdetail-detail id={`${id}-detail`} aria-label={sel != null ? (find(items, (it) => it.id == sel) ?? { title: "" }).title : empty}                     @mount={(el) => { detail = el; show(sel) }}>                <button.ui-mdetail-back type="button" @click={() => {                    sel = null                    show(null)                    on_select && on_select(null)                    js("document.getElementById", `${id}-list`).focus()                }}>                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"                         stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>                    {back}                </button>                <div.ui-mdetail-body>                    {sel == null && <div.ui-mdetail-empty>{empty}</div>}                    <div.ui-mdetail-panes hidden={sel == null}>{children}</div>                </div>            </section>        </div>    </div>}// One item's detail, by its id, for a MasterDetail written in a page. open: it// shows before any script runs (give it to the item that is `selected`).view MasterDetailPane(id: str, open: bool = false, class: str? = null, children) {    style global master_detail_css    <div class={ui_cls(["ui-mdetail-pane", class])} data-ui-md-pane={id} hidden={!open}>{children}</div>}
Thread
66Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.