Mobile

GroupedList

iOS-style inset grouped rows with headers, footers and disclosure chevrons, full-row tap targets, and dark mode.

#mobile#no-js#tablet#list#settings#ios

Demo

Preview

Account

Your name shows on orders and receipts.

Help

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

Thread 0 posts

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

No posts yet. The first one opens the thread.