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.
Demo
- Quarterly numbersAna Silva
- Lunch on Thursday?Ben Okafor
- Your invoice for MarchBilling
- Welcome to the teamChen Wei
Quarterly numbers
Hi all, the report is attached. Revenue is up 12% on last quarter, mostly from the new plans.
Lunch on Thursday?
The place by the river at one. Say if you are in.
Your invoice for March
€240.00, paid by card ending 4242. Thank you.
Welcome to the team
Your first week's plan is in the wiki. Ask me anything.
- Ada LovelaceAnalyst
- Grace HopperRear admiral
- Linus TorvaldsKernel
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 Silva
- Lunch on Thursday?Ben Okafor
- Your invoice for MarchBilling
- Welcome to the teamChen Wei
Quarterly numbers
Hi all, the report is attached. Revenue is up 12% on last quarter, mostly from the new plans.
Lunch on Thursday?
The place by the river at one. Say if you are in.
Your invoice for March
€240.00, paid by card ending 4242. Thank you.
Welcome to the team
Your first week's plan is in the wiki. Ask me anything.
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.qweimport 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.
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
// 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>}Related
Thread 0 posts
On MasterDetail: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.