SettingsList
Rows of toggles, values and links from data, with a search across them, and on_change(key, value).
Demo
Connections
Mobile data is used when Wi-Fi is off.
Sound
Café mode
For loud rooms.
About
- Version4.2.1
Changed: none, opened none
Connections
Mobile data is used when Wi-Fi is off.
Sound
Café mode
For loud rooms.
About
- Version4.2.1
Bad rows
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/settings_list.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/settings_list.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/settings_list.qwe
let settings_list_demo_groups = [ { title: "Connections", footer: "Mobile data is used when Wi-Fi is off.", rows: [ { key: "wifi", label: "Wi-Fi", type: "toggle", value: true, keywords: ["internet", "network"] }, { key: "wifi_ask", label: "Ask to join networks", type: "toggle", value: false, requires: "wifi" }, { key: "bluetooth", label: "Bluetooth", type: "toggle", value: false, detail: "Headphones and watches" }, { key: "roaming", label: "Data roaming", type: "value", value: "ask", options: [{ value: "off", label: "Off" }, { value: "ask", label: "Ask first" }, { value: "on", label: "Always" }], keywords: "abroad travel" } ] }, { title: "Sound", rows: [ { key: "tone", label: "Ringtone", type: "value", value: "bell", options: ["bell", "chime", "marimba"] }, { key: "volume", label: "Alert volume", type: "value", value: 50, options: [0, 25, 50, 75, 100] }, { key: "vibrate", label: "Vibrate on silent", type: "toggle", value: true }, { key: "guide", label: "Sound guide", type: "link", href: "#settings-sound-guide" } ] }, { title: "Café mode", footer: "For loud rooms.", rows: [ { key: "cafe", label: "Résumé when back", type: "toggle", value: false } ] }, { title: "About", rows: [ { key: "version", label: "Version", value: "4.2.1" }, { key: "legal", label: "Legal notices", type: "link" }, { key: "old", label: "Old sync", type: "toggle", value: false, disabled: true } ] }]component SettingsListDemoLive() { state log = "none" state opened = "none" <div.demo-stack> <div.demo-phone style="height: 560px"> <SettingsList id="settings-demo" groups={settings_list_demo_groups} values={{ bluetooth: true }} on_change={(k, v) => { log = `${k}=${type_of(v)}:${v}` }} on_open={(k) => { opened = k }}/> </div> <p.demo-note>Changed: <b data-log>{log}</b>, opened <b data-opened>{opened}</b></p> <div.demo-tablet style="height: 420px" data-wide> <SettingsList id="settings-wide" groups={settings_list_demo_groups}/> </div> <SettingsList id="settings-bad" search={false} groups={[null, { title: "Bad rows", rows: [null, { label: "No key", type: "toggle" }, { type: "link" }, { key: "fine", label: "A fine row", type: "value", value: "gone", options: ["here"] }, { key: "constructor", label: "Odd key", type: "toggle" }] }, { rows: "nope" }]}/> </div>}view SettingsListDemo() = <SettingsListDemoLive/>Source
ui/settings_list.qwe
// SettingsList: a phone's settings screen from data: rows of toggles, values to pick// and links, in groups, with a search across all of them that stays at the top. A// value row opens the phone's own picker, and a toggle is a real switch whose whole// row can be pressed. Search matches a row's words, what it is set to, its group and// any keywords, in any case and with or without accents, and rows stay under their// group's heading. In a wide pane the groups are listed down the side, to jump to.//// <SettingsList values={saved} on_change={(key, value) => { save_setting(key, value) }} groups={[// { title: "Sound", footer: "Applies to all your devices.", rows: [// { key: "sounds", label: "Play sounds", type: "toggle", value: true },// { key: "tone", label: "Ringtone", type: "value", value: "bell", options: ["bell", "chime", { value: "off", label: "None" }] },// { key: "guide", label: "Sound guide", type: "link", href: "/help/sound" }// ] }// ]}/>//// groups: a list of { title?, footer?, rows }. Each row: key (what on_change gives),// label, detail (a second line), type, value, keywords (words search finds it by,// as a list or a text), requires (the key of a toggle that must be on, or the// row is dimmed), disabled. type "toggle": on or off. "value": pick one of options, a// list of plain values or { value, label }, whose values keep their type (a number// stays a number). "link": goes to href; with no href, on_open(key) is called. "info":// words only, showing value. Rows with no label, and toggles and values with no key,// are left out; a value that is not among the options shows as chosen anyway.// values: a map of key to value, read once, over each row's own value.// search: show the search box (true). placeholder: its words ("Search settings").// empty: what it says when nothing matches ("No settings match"). label: what screen// readers call the list ("Settings"). id, class: on the outer element.// on_change(key, value): inside one of your components, whenever a toggle or a value// changes. on_open(key): a link row with no href.//// It fills the height it is given and scrolls itself, the search stuck on top so the// on-screen keyboard never hides it; with no height it grows and the page scrolls.import ui/theme.qwe use ui_clslet settings_list_css = css`.ui-setlist { position: relative; container-type: inline-size; box-sizing: border-box; height: 100%; overflow-y: auto; overscroll-behavior-y: contain; 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-setlist-search { position: sticky; top: 0; z-index: 3; display: flex; align-items: center; gap: 4px; padding: 8px max(12px, env(safe-area-inset-right)) 8px max(12px, env(safe-area-inset-left)); background: inherit; }.ui-setlist-field { display: flex; align-items: center; flex: 1; min-width: 0; padding: 0 10px; border-radius: var(--ui-radius); background: color-mix(in srgb, var(--ui-fg) 9%, transparent); color: var(--ui-muted); }.ui-setlist-field svg { flex: none; width: 18px; height: 18px; }/* 16 px: iOS zooms the page into any field with a smaller font. */.ui-setlist-input { flex: 1; width: 0; min-height: 44px; padding: 0 6px; border: 0; outline: none; background: transparent; color: var(--ui-fg); font: 16px/1.2 var(--ui-font); }.ui-setlist-input::-webkit-search-cancel-button { display: none; }.ui-setlist-field:focus-within { box-shadow: var(--ui-focus); }.ui-setlist-clear { flex: none; min-width: 60px; min-height: 44px; padding: 0 6px; border: 0; background: none; color: var(--ui-accent); font: 500 15px/1 var(--ui-font); cursor: pointer; touch-action: manipulation; }.ui-setlist-found { min-height: 18px; margin: 0; padding: 0 28px 4px; color: var(--ui-muted); font-size: 13px; }.ui-setlist-split { display: block; }.ui-setlist-side { display: none; }.ui-setlist-groups { min-width: 0; padding: 4px max(16px, env(safe-area-inset-right)) calc(20px + env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); }.ui-setlist-group { margin: 0 0 22px; }.ui-setlist-title { margin: 10px 16px 6px; color: var(--ui-muted); font: 500 13px/1.3 var(--ui-font); letter-spacing: .02em; text-transform: uppercase; }.ui-setlist-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-setlist-note { margin: 6px 16px 0; color: var(--ui-muted); font: 13px/1.4 var(--ui-font); }.ui-setlist-row { position: relative; display: flex; align-items: center; gap: 10px; box-sizing: border-box; width: 100%; min-height: 48px; padding: 8px 14px 8px 16px; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; text-decoration: none; -webkit-tap-highlight-color: transparent; }li + li > .ui-setlist-row::before { content: ""; position: absolute; top: 0; left: 16px; right: 0; border-top: 1px solid var(--ui-border); }a.ui-setlist-row, button.ui-setlist-row, label.ui-setlist-row { cursor: pointer; touch-action: manipulation; }a.ui-setlist-row:active, button.ui-setlist-row:active, label.ui-setlist-row:active { background: color-mix(in srgb, var(--ui-fg) 8%, transparent); }.ui-setlist-row:focus-visible, .ui-setlist-row:has(:focus-visible) { outline: none; box-shadow: inset 0 0 0 2px var(--ui-accent); border-radius: var(--ui-radius); }.ui-setlist-off { opacity: .5; }.ui-setlist-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }.ui-setlist-detail { display: block; margin-top: 2px; color: var(--ui-muted); font-size: 13px; }.ui-setlist-value { flex: 0 1 auto; max-width: 50%; color: var(--ui-muted); text-align: right; overflow-wrap: anywhere; }.ui-setlist-chevron { flex: none; width: 8px; height: 14px; color: var(--ui-strong); }/* The real control lies over the whole row, unseen: a press anywhere on the row is a press on it. */.ui-setlist-check, .ui-setlist-select { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }.ui-setlist-select { font-size: 16px; }.ui-setlist-check:disabled, .ui-setlist-select:disabled { cursor: default; }.ui-setlist-track { position: relative; flex: none; width: 51px; height: 31px; border-radius: 999px; background: var(--ui-strong); transition: background .16s; }.ui-setlist-track::after { content: ""; position: absolute; top: 2px; left: 2px; width: 27px; height: 27px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .3); transition: transform .16s; }.ui-setlist-check:checked + .ui-setlist-track { background: var(--ui-success); }.ui-setlist-check:checked + .ui-setlist-track::after { transform: translateX(20px); }.ui-setlist-none { margin: 40px 16px; color: var(--ui-muted); text-align: center; }@container (min-width: 640px) { .ui-setlist-split { display: grid; grid-template-columns: 220px minmax(0, 1fr); align-items: start; } .ui-setlist-side { position: sticky; top: 8px; display: grid; gap: 2px; margin: 0; padding: 8px 0 8px 16px; list-style: none; } .ui-setlist-search ~ .ui-setlist-split .ui-setlist-side { top: 64px; } .ui-setlist-jump { display: block; box-sizing: border-box; width: 100%; min-height: 44px; padding: 10px 12px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: inherit; font: 500 15px/1.3 var(--ui-font); text-align: left; cursor: pointer; } .ui-setlist-jump:active { background: color-mix(in srgb, var(--ui-fg) 8%, transparent); } .ui-setlist-jump[aria-current="true"] { background: var(--ui-accent-soft); color: var(--ui-accent); } .ui-setlist-jump:focus-visible { outline: none; box-shadow: var(--ui-focus); }}@media (hover: hover) { a.ui-setlist-row:hover, button.ui-setlist-row:hover, label.ui-setlist-row:hover { background: color-mix(in srgb, var(--ui-fg) 5%, transparent); } }@media (prefers-reduced-motion: reduce) { .ui-setlist-track, .ui-setlist-track::after { transition: none; } }`// Text as search reads it: lower case, without the common Latin accents.let settings_list_accents = { "á": "a", "à": "a", "â": "a", "ä": "a", "ã": "a", "å": "a", "ā": "a", "ă": "a", "ą": "a", "ç": "c", "ć": "c", "č": "c", "ď": "d", "đ": "d", "ð": "d", "é": "e", "è": "e", "ê": "e", "ë": "e", "ē": "e", "ę": "e", "ě": "e", "ğ": "g", "í": "i", "ì": "i", "î": "i", "ï": "i", "ī": "i", "ı": "i", "ľ": "l", "ł": "l", "ñ": "n", "ń": "n", "ň": "n", "ó": "o", "ò": "o", "ô": "o", "ö": "o", "õ": "o", "ø": "o", "ō": "o", "ő": "o", "ř": "r", "ś": "s", "š": "s", "ş": "s", "ș": "s", "ť": "t", "ț": "t", "ú": "u", "ù": "u", "û": "u", "ü": "u", "ū": "u", "ů": "u", "ű": "u", "ý": "y", "ÿ": "y", "ź": "z", "ż": "z", "ž": "z", "ß": "ss", "œ": "oe", "æ": "ae", "þ": "th" }fn settings_list_fold(text) -> str = split(lower(str(text ?? "")), "") | map((c) => get(settings_list_accents, c, c)) | join("")// The options of a value row as { value, label }.fn settings_list_options(row) -> list { let given = get(row, "options", []) if type_of(given) != "list" { return [] } given | filter((o) => o != null) | map((o) => type_of(o) == "map" ? { value: get(o, "value"), label: str(get(o, "label") ?? get(o, "value") ?? "") } : { value: o, label: str(o) })}// The words shown for a row's value.fn settings_list_shown(row, value) -> str { if value == null { return "" } let hit = settings_list_options(row) | find((o) => o.value == value) hit != null ? hit.label : str(value)}fn settings_list_row_ok(r) -> bool { if type_of(r) != "map" || filled(str(get(r, "label") ?? "")) == null { return false } let kind = get(r, "type", "info") kind == "link" || kind == "info" || get(r, "key") != null}// The groups with only rows that can be shown, each with a number of its own.fn settings_list_groups(groups) -> list { let all = type_of(groups) == "list" ? groups : [] let clean = all | filter((g) => type_of(g) == "map") | map((g) => { let rows = get(g, "rows", []) let good = type_of(rows) == "list" ? rows | filter(settings_list_row_ok) : [] // Each row's place, for a key of its own: two rows may share a setting. { title: get(g, "title"), footer: get(g, "footer"), rows: enumerate(good) | map((p) => { ...p[1], at: p[0] }) } }) enumerate(clean | filter((g) => len(g.rows) > 0)) | map((p) => { ...p[1], n: p[0] })}// Each key's value at the start: the row's, then what `values` says. (Built with `set`,// quadratic, once: a settings screen has dozens of rows.)fn settings_list_values(groups: list, values) -> map { let given = type_of(values) == "map" ? values : {} let out = {} for g in groups { for r in g.rows { if get(r, "key") != null { let k = str(r.key) out = set(out, k, has(given, k) ? get(given, k) : get(r, "value")) } } } out}// Everything search reads for a row, folded, worked out once.fn settings_list_haystack(group, row, value) -> str { let words = get(row, "keywords", []) let extra = type_of(words) == "list" ? join(words, " ") : str(words ?? "") settings_list_fold(join([group.title ?? "", row.label, get(row, "detail") ?? "", settings_list_shown(row, value), extra], " "))}// The groups with the rows that have every word of the query. A blank query keeps all.fn settings_list_search(groups: list, vals: map, query: str) -> list { let words = split(trim(settings_list_fold(query)), " ") | filter((w) => w != "") if len(words) == 0 { return groups } groups | map((g) => { ...g, rows: g.rows | filter((r) => { let hay = settings_list_haystack(g, r, get(vals, str(get(r, "key")))) all(words, (w) => contains(hay, w)) }) }) | filter((g) => len(g.rows) > 0)}fn settings_list_count(groups: list) -> int = groups | map((g) => len(g.rows)) | sum()// Whether a row can be used: not disabled, and the toggle it needs, if any, is on.fn settings_list_open(row, vals: map) -> bool { if get(row, "disabled", false) == true { return false } let need = get(row, "requires") need == null || get(vals, str(need)) == true}view SettingsListChevron() = <svg.ui-setlist-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>view SettingsListText(row) = <span.ui-setlist-text>{row.label}{get(row, "detail") != null && <span.ui-setlist-detail>{row.detail}</span>}</span>component SettingsList(groups = [], values = null, search: bool = true, placeholder: str = "Search settings", empty: str = "No settings match", label: str = "Settings", on_change = null, on_open = null, id: str? = null, class: str? = null) { style global settings_list_css let clean = settings_list_groups(groups) state vals = settings_list_values(settings_list_groups(groups), values) state query = "" state box = null state at = 0 let shown = settings_list_search(clean, vals, query) let hits = settings_list_count(shown) let titled = clean | filter((g) => g.title != null) let change = (key, value) => { vals = set(vals, str(key), value) on_change && on_change(key, value) } let jump = (n) => { at = n let el = box.querySelector(`[data-group="${n}"]`) if el != null { let bar = box.querySelector(".ui-setlist-search") let room = bar != null ? bar.offsetHeight : 0 let still = js("matchMedia", "(prefers-reduced-motion: reduce)").matches box.scrollTo({ top: el.offsetTop - room, behavior: still ? "instant" : "smooth" }) } } <div class={ui_cls(["ui-setlist", class])} id={id} role="group" aria-label={label} @mount={(el) => { box = el }}> {search && <div.ui-setlist-search role="search"> <div.ui-setlist-field> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4.5 4.5"/></svg> <input.ui-setlist-input type="search" value={query} placeholder={placeholder} aria-label={placeholder} enterkeyhint="search" autocomplete="off" autocapitalize="none" spellcheck="false" @input={(e) => { query = e.value }} @keydown={(e) => { // Search goes as one types, so Enter only puts the keyboard away. if e.key == "Enter" { e.preventDefault(); e.target.blur() } if e.key == "Escape" { query = "" } }}/> </div> {query != "" && <button.ui-setlist-clear type="button" @click={() => { query = "" }}>Clear</button>} </div>} {query != "" && <p.ui-setlist-found role="status">{hits == 1 ? "1 setting" : `${hits} settings`}</p>} <div.ui-setlist-split> <ul.ui-setlist-side> {for g in titled => <li key={g.n}> <button.ui-setlist-jump type="button" aria-current={at == g.n ? "true" : null} @click={() => { query = ""; jump(g.n) }}>{g.title}</button> </li>} </ul> <div.ui-setlist-groups> {len(shown) == 0 && <p.ui-setlist-none>{empty} “{query}”</p>} {for g in shown => <section.ui-setlist-group key={g.n} data-group={g.n}> {g.title != null && <h3.ui-setlist-title>{g.title}</h3>} <ul.ui-setlist-rows> {for r in g.rows => { let kind = get(r, "type", "info") let live = settings_list_open(r, vals) let cls = ui_cls(["ui-setlist-row", !live && "ui-setlist-off"]) let val = get(vals, str(get(r, "key"))) <li key={r.at}>{kind == "toggle" ? <label class={cls} data-key={str(r.key)}> <SettingsListText row={r}/> <input.ui-setlist-check type="checkbox" role="switch" checked={val == true} disabled={!live} aria-label={r.label} @change={(e) => { change(r.key, e.checked) }}/> <span.ui-setlist-track aria-hidden="true"></span> </label> : (kind == "value" ? <label class={cls} data-key={str(r.key)}> <SettingsListText row={r}/> <span.ui-setlist-value>{settings_list_shown(r, val)}</span> <SettingsListChevron/> <select.ui-setlist-select disabled={!live} aria-label={r.label} @change={(e) => { let o = settings_list_options(r)[int(e.value)] change(r.key, o.value) }}> {index_of(settings_list_options(r) | map((o) => o.value), val) == null && <option value="" selected={true} disabled={true}>{settings_list_shown(r, val)}</option>} {for [i, o] in enumerate(settings_list_options(r)) => <option key={i} value={str(i)} selected={o.value == val}>{o.label}</option>} </select> </label> : (kind == "link" && live ? (get(r, "href") != null ? <a class={cls} href={r.href} data-key={str(get(r, "key") ?? "")}><SettingsListText row={r}/>{get(r, "value") != null && <span.ui-setlist-value>{r.value}</span>}<SettingsListChevron/></a> : <button class={cls} type="button" data-key={str(get(r, "key") ?? "")} @click={() => { on_open && on_open(get(r, "key")) }}><SettingsListText row={r}/>{get(r, "value") != null && <span.ui-setlist-value>{r.value}</span>}<SettingsListChevron/></button>) : <div class={cls} data-key={str(get(r, "key") ?? "")}><SettingsListText row={r}/>{val != null ? <span.ui-setlist-value>{settings_list_shown(r, val)}</span> : (get(r, "value") != null && <span.ui-setlist-value>{r.value}</span>)}</div>))} </li> }} </ul> {g.footer != null && <p.ui-setlist-note>{g.footer}</p>} </section>} </div> </div> </div>}Related
ContactListPeople 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 downloadsEmptyScreenEmpty states for first use, none-found and all-done, each with what to do next.3 views · 8 downloadsFilterChipsBarA sideways scroller of filter chips staying under the header, the active chip kept in view, a count of results, and clear.2 views · 6 downloadsFullscreenPickerA full-screen list to pick from, with search and a header with Done, that stays clear of the on-screen keyboard; on a tablet, a card.2 views · 6 downloadsGroupedListiOS-style inset grouped rows with headers, footers and disclosure chevrons, full-row tap targets, and dark mode.2 views · 7 downloads
Thread
Thread 0 posts
On SettingsList: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.