GroupedList
iOS-style inset grouped rows with headers, footers and disclosure chevrons, full-row tap targets, and dark mode.
Demo
Last tapped: none
Dark
- Always dark hereOn
Bad rows are left out
- The one good row
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/grouped_list.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/grouped_list.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/grouped_list.qwe
let grouped_list_demo_groups = [ { title: "Account", footer: "Your name shows on orders and receipts.", rows: [ { label: "Name", value: "Ada Lovelace", key: "name", icon: "M16 19v-1a4 4 0 0 0-8 0v1M12 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6" }, { label: "Email", value: "ada@analytical.example", key: "email", icon: "M4 6h16v12H4zM4 7l8 6 8-6" }, { label: "Plan", sub: "Renews on 1 December", badge: "Pro", key: "plan", icon: "★" } ] }, { title: "Help", rows: [ { label: "What's new", href: "#grouped-list-new", badge: 3 }, { label: "Version", value: "4.2.1" }, { label: "Old sync", value: "Removed", disabled: true, chevron: true } ] }, { rows: [{ label: "Sign out", danger: true, key: "out" }] }]component GroupedListDemoLive() { state last = "none" <div.demo-stack> <GroupedList label="Your account" groups={grouped_list_demo_groups} on_select={(row) => { last = row.label }}/> <p.demo-note>Last tapped: <b data-last>{last}</b></p> <div.ui-dark data-dark> <GroupedList groups={[{ title: "Dark", rows: [{ label: "Always dark here", value: "On", key: "dark" }] }]}/> </div> <GroupedList groups={[null, { title: "Bad rows are left out", rows: [null, 3, { label: "" }, { label: "The one good row" }] }, { rows: "nonsense" }, { rows: [] }]}/> </div>}view GroupedListDemo() = <GroupedListDemoLive/>Source
ui/grouped_list.qwe
// GroupedList: iOS-style inset grouped rows, the look of a phone's Settings: rounded// groups on a tinted page, each with an optional heading above and a note below, rows// with an icon, a value and a disclosure chevron, the separators starting at the// text. The whole row is the tap target, never just its words. It is a view: links// work with no script, and the light and dark looks come from the theme. In a wide// pane the groups stand in two columns.//// <GroupedList groups={[// { title: "Account", footer: "Used to sign in on your devices.", rows: [// { label: "Name", value: "Ada Lovelace", href: "/me/name" },// { label: "Password", icon: "M5 11h14v9H5zM8 11V7a4 4 0 0 1 8 0v4", href: "/me/password" }// ] },// { rows: [{ label: "Sign out", danger: true, key: "out" }] }// ]} on_select={(row) => { chosen = row.key }}/>//// groups: a list of { title?, footer?, rows }. Each row: label (the words), sub (a// second line), value (on the right, muted), icon (a character, or an SVG path, 24 by// 24 and stroked, when it starts with "M"), badge (a count or a word in a pill), key// (given back to on_select), href (makes it a link), chevron (true or false forces the// disclosure mark, which a link or a pressable row has by itself), danger (red// words, no chevron), disabled (dimmed, not pressable). A row with neither href nor// on_select is plain text, with no chevron. Bad rows and groups are left out.// label: what screen readers call the whole. id, class: on the outer element.// on_select(row): inside one of your components, called with the row tapped; a row// with an href is followed unless the callback is there, and then both happen.import ui/theme.qwe use ui_clslet grouped_list_css = css`.ui-grplist { container-type: inline-size; padding: 4px 0 calc(16px + env(safe-area-inset-bottom)); background: var(--ui-subtle); background: light-dark(var(--ui-subtle), var(--ui-bg)); color: var(--ui-fg); font: 16px/1.3 var(--ui-font); }.ui-grplist-cols { display: grid; grid-template-columns: minmax(0, 1fr); column-gap: 24px; padding: 0 max(16px, env(safe-area-inset-right)) 0 max(16px, env(safe-area-inset-left)); }.ui-grplist-group { min-width: 0; margin: 0 0 22px; }.ui-grplist-group:last-child { margin-bottom: 0; }.ui-grplist-title { margin: 12px 16px 6px; color: var(--ui-muted); font: 500 13px/1.3 var(--ui-font); letter-spacing: .02em; text-transform: uppercase; }.ui-grplist-rows { margin: 0; padding: 0; overflow: hidden; border-radius: var(--ui-radius); background: var(--ui-surface); background: light-dark(var(--ui-surface), var(--ui-subtle)); list-style: none; }.ui-grplist-note { margin: 6px 16px 0; color: var(--ui-muted); font: 13px/1.4 var(--ui-font); }.ui-grplist-row { display: flex; align-items: stretch; gap: 12px; box-sizing: border-box; width: 100%; min-height: 46px; margin: 0; padding: 0 0 0 16px; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; text-decoration: none; -webkit-tap-highlight-color: transparent; touch-action: manipulation;}a.ui-grplist-row, button.ui-grplist-row { cursor: pointer; }a.ui-grplist-row:active, button.ui-grplist-row:active { background: color-mix(in srgb, var(--ui-fg) 9%, transparent); }@media (hover: hover) { a.ui-grplist-row:hover, button.ui-grplist-row:hover { background: color-mix(in srgb, var(--ui-fg) 5%, transparent); } }.ui-grplist-row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--ui-accent); border-radius: var(--ui-radius); }.ui-grplist-icon { display: grid; place-items: center; flex: none; align-self: center; width: 30px; height: 30px; border-radius: 7px; background: var(--ui-accent-soft); color: var(--ui-accent); font-size: 17px; line-height: 1; }.ui-grplist-icon svg { width: 18px; height: 18px; }/* The line sits on the part after the icon, so it starts at the text as on iOS. */.ui-grplist-body { display: flex; align-items: center; flex: 1; gap: 8px; min-width: 0; padding: 10px 14px 10px 0; }li + li .ui-grplist-body { border-top: 1px solid var(--ui-border); }.ui-grplist-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }.ui-grplist-sub { display: block; margin-top: 2px; color: var(--ui-muted); font-size: 13px; }.ui-grplist-value { flex: 0 1 auto; max-width: 70%; color: var(--ui-muted); text-align: right; overflow-wrap: anywhere; }.ui-grplist-badge { flex: none; min-width: 24px; padding: 2px 8px; border-radius: 999px; background: var(--ui-accent); color: var(--ui-accent-fg); font: 600 13px/18px var(--ui-font); text-align: center; }.ui-grplist-chevron { flex: none; width: 8px; height: 14px; color: var(--ui-strong); }.ui-grplist-danger { color: var(--ui-danger); }.ui-grplist-danger .ui-grplist-text { font-weight: 500; }.ui-grplist-off { opacity: .5; cursor: default; }@container (min-width: 640px) { .ui-grplist-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; padding: 0 24px; } .ui-grplist-group:last-child { margin-bottom: 22px; }}`// Only the rows that can be shown: a row is a map with a label.fn grouped_list_rows(group) -> list { let rows = type_of(group) == "map" ? get(group, "rows", []) : [] type_of(rows) == "list" ? rows | filter((r) => type_of(r) == "map" && filled(str(get(r, "label", "") ?? "")) != null) : []}// The groups worth showing, each as { title, footer, rows }.fn grouped_list_groups(groups) -> list { let all = type_of(groups) == "list" ? groups : [] all | map((g) => { title: type_of(g) == "map" ? get(g, "title") : null, footer: type_of(g) == "map" ? get(g, "footer") : null, rows: grouped_list_rows(g) }) | filter((g) => len(g.rows) > 0)}// What a row is: "link" (has an address), "button" (the callback hears it) or "plain".fn grouped_list_kind(row, pressable: bool) -> str { if get(row, "disabled", false) == true { return "plain" } if filled(str(get(row, "href", "") ?? "")) != null { return "link" } pressable ? "button" : "plain"}// Whether the row shows its chevron: it may say, a red action never does, and// otherwise a row that goes somewhere does.fn grouped_list_chevron(row, kind: str) -> bool { let said = get(row, "chevron") if said == true || said == false { return said } get(row, "danger", false) != true && kind != "plain"}view GroupedListBody(row, chevron: bool) = <span.ui-grplist-body> <span.ui-grplist-text>{row.label}{get(row, "sub") != null && <span.ui-grplist-sub>{row.sub}</span>}</span> {get(row, "badge") != null && <span.ui-grplist-badge>{row.badge}</span>} {get(row, "value") != null && <span.ui-grplist-value>{row.value}</span>} {chevron && <svg.ui-grplist-chevron viewBox="0 0 8 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m1 1 6 6-6 6"/></svg>} </span>view GroupedListIcon(icon) = <span.ui-grplist-icon aria-hidden="true"> {starts_with(str(icon), "M") && len(str(icon)) > 2 ? <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d={str(icon)}/></svg> : str(icon)} </span>view GroupedList(groups = [], on_select = null, label: str? = null, id: str? = null, class: str? = null) { style global grouped_list_css <div class={ui_cls(["ui-grplist", class])} id={id} role={label != null ? "group" : null} aria-label={label}> <div.ui-grplist-cols> {for [i, g] in enumerate(grouped_list_groups(groups)) => <section.ui-grplist-group key={i}> {g.title != null && <h3.ui-grplist-title>{g.title}</h3>} <ul.ui-grplist-rows> {for [j, row] in enumerate(g.rows) => { let kind = grouped_list_kind(row, on_select != null) let cls = ui_cls(["ui-grplist-row", get(row, "danger", false) == true && "ui-grplist-danger", get(row, "disabled", false) == true && "ui-grplist-off"]) let mark = get(row, "icon") != null ? <GroupedListIcon icon={row.icon}/> : null let body = <GroupedListBody row={row} chevron={grouped_list_chevron(row, kind)}/> <li key={j}>{kind == "link" ? <a class={cls} href={row.href} @click={() => { on_select && on_select(row) }}>{mark}{body}</a> : (kind == "button" ? <button class={cls} type="button" @click={() => { on_select(row) }}>{mark}{body}</button> : <div class={cls} aria-disabled={get(row, "disabled", false) == true ? "true" : null}>{mark}{body}</div>)}</li> }} </ul> {g.footer != null && <p.ui-grplist-note>{g.footer}</p>} </section>} </div> </div>}Related
AlphaRailListA long list with sticky letter headers and an A to Z rail scrubbed by finger, with a bubble showing the letter and a haptic per step.1 view · 6 downloadsContactListPeople grouped by letter with avatars from initials, fast search across names and numbers, a call and message action per row.3 views · 8 downloadsDataSaverToggleA setting to keep lighter images and off autoplay, following the phone's save-data, with what it changes.4 views · 5 downloadsDragReorderPut a list in order by long press and drag, scrolling at its edges, and by keys or buttons too.2 views · 7 downloadsExpandingRowA row that opens in place with content pushed down smoothly, keeps only one open if asked, and scrolls itself into view.4 views · 7 downloadsHoverCursorRowRows with the iPad pointer's lift on a trackpad or mouse, none on touch, and right-click menus.3 views · 5 downloads
Thread
Thread 0 posts
On GroupedList: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.