ContactList
People grouped by letter with avatars from initials, fast search across names and numbers, a call and message action per row.
Demo
Picked none; called none
A
- Ana Abbott+1 555 1000
- Ana Abbott+1 555 1400
- Ana Abbott+1 555 1800
- Ana Abbott+1 555 2200
- Ana Abbott+1 555 2600
- Ana Baker+1 555 1020
- Ana Baker+1 555 1420
- Ana Baker+1 555 1820
- Ana Baker+1 555 2220
- Ana Baker+1 555 2620
- Ana Costa+1 555 1040
- Ana Costa+1 555 1440
- Ana Costa+1 555 1840
- Ana Costa+1 555 2240
- Ana Costa+1 555 2640
- Ana Dahl+1 555 1060
- Ana Dahl+1 555 1460
- Ana Dahl+1 555 1860
- Ana Dahl+1 555 2260
- Ana Dahl+1 555 2660
- Ana Evans+1 555 1080
- Ana Evans+1 555 1480
- Ana Evans+1 555 1880
- Ana Evans+1 555 2280
- Ana Evans+1 555 2680
- Ana Fox+1 555 1100
- Ana Fox+1 555 1500
- Ana Fox+1 555 1900
- Ana Fox+1 555 2300
- Ana Fox+1 555 2700
- Ana Gray+1 555 1120
- Ana Gray+1 555 1520
- Ana Gray+1 555 1920
- Ana Gray+1 555 2320
- Ana Gray+1 555 2720
- Ana Hill+1 555 1140
- Ana Hill+1 555 1540
- Ana Hill+1 555 1940
- Ana Hill+1 555 2340
- Ana Hill+1 555 2740
- Ana Ito+1 555 1160
- Ana Ito+1 555 1560
- Ana Ito+1 555 1960
- Ana Ito+1 555 2360
- Ana Ito+1 555 2760
- Ana Jones+1 555 1180
- Ana Jones+1 555 1580
- Ana Jones+1 555 1980
- Ana Jones+1 555 2380
- Ana Jones+1 555 2780
- Ana Khan+1 555 1200
- Ana Khan+1 555 1600
- Ana Khan+1 555 2000
- Ana Khan+1 555 2400
- Ana Khan+1 555 2800
- Ana Lund+1 555 1220
- Ana Lund+1 555 1620
- Ana Lund+1 555 2020
- Ana Lund+1 555 2420
- Ana Lund+1 555 2820
- Ana Moss+1 555 1240
- Ana Moss+1 555 1640
- Ana Moss+1 555 2040
- Ana Moss+1 555 2440
- Ana Moss+1 555 2840
- Ana Novak+1 555 1260
- Ana Novak+1 555 1660
- Ana Novak+1 555 2060
- Ana Novak+1 555 2460
- Ana Novak+1 555 2860
- Ana Ortiz+1 555 1280
- Ana Ortiz+1 555 1680
- Ana Ortiz+1 555 2080
- Ana Ortiz+1 555 2480
- Ana Ortiz+1 555 2880
- Ana Park+1 555 1300
- Ana Park+1 555 1700
- Ana Park+1 555 2100
- Ana Park+1 555 2500
- Ana Park+1 555 2900
- Ana Reed+1 555 1320
- Ana Reed+1 555 1720
- Ana Reed+1 555 2120
- Ana Reed+1 555 2520
- Ana Reed+1 555 2920
- Ana Silva+1 555 1340
- Ana Silva+1 555 1740
- Ana Silva+1 555 2140
- Ana Silva+1 555 2540
- Ana Silva+1 555 2940
- Ana Tran+1 555 1360
- Ana Tran+1 555 1760
- Ana Tran+1 555 2160
- Ana Tran+1 555 2560
- Ana Tran+1 555 2960
- Ana Wood+1 555 1380
- Ana Wood+1 555 1780
- Ana Wood+1 555 2180
- Ana Wood+1 555 2580
- Ana Wood+1 555 2980
B
- Ben Abbott+1 555 1261
- Ben Abbott+1 555 1661
- Ben Abbott+1 555 2061
- Ben Abbott+1 555 2461
- Ben Abbott+1 555 2861
- Ben Baker+1 555 1281
- Ben Baker+1 555 1681
- Ben Baker+1 555 2081
- Ben Baker+1 555 2481
- Ben Baker+1 555 2881
- Ben Costa+1 555 1301
- Ben Costa+1 555 1701
- Ben Costa+1 555 2101
- Ben Costa+1 555 2501
- Ben Costa+1 555 2901
- Ben Dahl+1 555 1321
- Ben Dahl+1 555 1721
- Ben Dahl+1 555 2121
- Ben Dahl+1 555 2521
- Ben Dahl+1 555 2921
Get it
It needs theme.qwe, as every component does, and avatar.qwe; 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/avatar.qwe$ curl -O https://quillolang.org/components/ui/contact_list.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/contact_list.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/contact_list.qwe
let contact_list_demo_people = [ { id: 1, name: "Ada Lovelace", phone: "+44 20 7946 0018" }, { id: 2, name: "Alan Turing", phone: "+44 20 7946 0954" }, { id: 3, name: "Émile Borel", phone: "+33 1 70 18 99 00", org: "Sorbonne" }, { id: 4, name: "edsger dijkstra", phone: "(020) 555-0123" }, { id: 5, name: "Grace Hopper", phone: "+1 212 555 0147", phones: ["+1 646 555 0199"] }, { id: 6, name: "Katherine Johnson", phone: "+1 757 555 0110" }, { id: 7, name: "Linus Pauling" }, { org: "Night Pharmacy", phone: "0800 555 111" }, { id: 9, name: "Niklaus Wirth", phone: "+41 44 555 01 22" }, { id: 10, name: "Ørsted Hans", phone: "+45 32 55 01 01" }, { id: 11, name: "Radia Perlman", phone: "+1 415 555 0132" }, { id: 12, name: "Żaneta Nowak", phone: "+48 22 555 01 01" }, { id: 13, name: "李雷", phone: "+86 10 5555 0101" }, { id: 14, name: "007 Bond", phone: "+44 20 7946 0007" }, { id: 15, name: "Yukihiro Matsumoto", phone: "+81 3 5555 0100" }]// A long list to prove the search quick: names made from two short lists.fn contact_list_demo_many(n: int) -> list { let firsts = ["Ana", "Ben", "Chloe", "Dan", "Eva", "Finn", "Gus", "Hana", "Ivan", "Jade", "Kofi", "Lena", "Mia", "Nils", "Omar", "Pia", "Quinn", "Rosa", "Sam", "Tara"] let lasts = ["Abbott", "Baker", "Costa", "Dahl", "Evans", "Fox", "Gray", "Hill", "Ito", "Jones", "Khan", "Lund", "Moss", "Novak", "Ortiz", "Park", "Reed", "Silva", "Tran", "Wood"] range(0, n) | map((i) => { id: i, name: `${firsts[i % 20]} ${lasts[(i * 7 + floor(i / 20)) % 20]}`, phone: `+1 555 ${1000 + i}` })}component ContactListDemoLive() { state picked = "none" state called = "none" <div.demo-stack> <div.demo-phone style="height: 560px"> <ContactList id="contacts-demo" people={contact_list_demo_people} on_select={(p) => { picked = p.name ?? p.org }}/> </div> <p.demo-note>Picked <b data-picked>{picked}</b>; called <b data-called>{called}</b></p> <div.demo-phone style="height: 300px"> <ContactList id="contacts-last" by="last" search={false} people={[{ name: "Ada Lovelace", phone: "+1" }, { name: "Grace Hopper", phone: "1 2" }, { name: "Plato" }]} on_call={(p) => { called = p.name }} on_message={(p) => { called = `${p.name} by message` }}/> </div> <div.demo-phone style="height: 420px"> <ContactList id="contacts-many" people={contact_list_demo_many(2000)} limit={120}/> </div> <div style="height: 120px"><ContactList id="contacts-bad" people={[null, 4, {}, { name: "Twin" }, { name: "Twin" }]} search={false}/></div> <div style="height: 120px"><ContactList id="contacts-empty" empty="Nobody yet" search={false}/></div> </div>}view ContactListDemo() = <ContactListDemoLive/>Source
ui/contact_list.qwe
// ContactList: a phone's address book. People stand under sticky letters, each with// an avatar made from their initials and a Call and a Message button of full size;// search stays on top and finds as one types, across names (in any case, with or// without accents) and numbers (however they are spaced). It stays quick with// thousands of people: what search reads is worked out once, sections off screen are// not laid out, and the list shows `limit` rows until asked for the rest.//// <ContactList people={[{ id: 1, name: "Ada Lovelace", phone: "+44 20 7946 0018" }]}// on_select={(p) => { navigate(`/people/${p.id}`) }}/>//// people: a list of { id?, name, phone?, phones?, org?, src? }. phones: more numbers,// as a list; search reads them all, and the first number is the one called.// org: shown when there is no name, and searched. src: a picture over the initials.// by: sort by "first" name (default) or "last". search: show the search box (true).// placeholder: its words ("Search name or number"). limit: rows shown before a button// offers the rest (600). empty: what shows with no one ("No contacts"). label: what// screen readers call the list ("Contacts"). id, class: on the outer element.// on_select(person): inside one of your components, a tap on a person; the row is// plain text without it. on_call(person), on_message(person): take over Call and// Message; without them they are tel: and sms: links, for the phone's own apps.//// In Quillo's iOS and Android apps a tel: or sms: link goes nowhere, as the web view// lets only web addresses out and there is no quilloNative call for it. So there,// without on_call and on_message, the button gives the number to the system's// share sheet, and says so on screen.import ui/theme.qwe use ui_clsimport ui/avatar.qwe use Avatarlet contact_list_css = css`.ui-contlist { position: relative; container-type: inline-size; box-sizing: border-box; height: 100%; overflow-y: auto; overscroll-behavior-y: contain; background: var(--ui-bg); color: var(--ui-fg); font: 16px/1.3 var(--ui-font); }.ui-contlist-search { position: sticky; top: 0; z-index: 3; display: flex; align-items: center; gap: 4px; box-sizing: border-box; height: 60px; padding: 8px max(12px, env(safe-area-inset-right)) 8px max(12px, env(safe-area-inset-left)); background: var(--ui-bg); }.ui-contlist-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) 8%, transparent); color: var(--ui-muted); }.ui-contlist-field:focus-within { box-shadow: var(--ui-focus); }.ui-contlist-field svg { flex: none; width: 18px; height: 18px; }/* 16 px: iOS zooms the page into any field with a smaller font. */.ui-contlist-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-contlist-input::-webkit-search-cancel-button { display: none; }.ui-contlist-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-contlist-found { margin: 0; padding: 0 16px 4px; color: var(--ui-muted); font-size: 13px; }.ui-contlist-note { position: sticky; top: 60px; z-index: 3; margin: 0 12px 6px; padding: 8px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-info-soft); color: var(--ui-fg); font-size: 14px; }/* A letter's section makes no box, so the letters stick to the whole list and stack: the next slides over the last, and one always stands under the search, where a letter held by its own section would be pushed out first and leave a gap at each change of letter. */.ui-contlist-section { display: contents; }.ui-contlist-letter { position: sticky; top: 60px; z-index: 2; margin: 0; padding: 3px 16px; background: var(--ui-subtle); color: var(--ui-muted); font: 600 13px/1.4 var(--ui-font); }.ui-contlist-bare .ui-contlist-letter { top: 0; }.ui-contlist-rows { margin: 0; padding: 0; list-style: none; content-visibility: auto; contain-intrinsic-size: auto 600px; }.ui-contlist-row { display: flex; align-items: center; gap: 2px; min-height: 60px; padding: 0 8px 0 0; border-bottom: 1px solid var(--ui-border); }.ui-contlist-who { display: flex; align-items: center; flex: 1; gap: 12px; box-sizing: border-box; min-width: 0; min-height: 60px; padding: 8px 8px 8px max(16px, env(safe-area-inset-left)); border: 0; background: transparent; color: inherit; font: inherit; text-align: left; touch-action: manipulation; -webkit-tap-highlight-color: transparent; }button.ui-contlist-who { cursor: pointer; }button.ui-contlist-who:active { background: color-mix(in srgb, var(--ui-fg) 8%, transparent); }.ui-contlist-who:focus-visible, .ui-contlist-act:focus-visible, .ui-contlist-all:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-contlist-text { flex: 1; min-width: 0; }.ui-contlist-name, .ui-contlist-num { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-contlist-name { font-weight: 500; }.ui-contlist-num { color: var(--ui-muted); font-size: 14px; font-variant-numeric: tabular-nums; }.ui-contlist-act { display: grid; place-items: center; flex: none; box-sizing: border-box; width: 44px; height: 44px; border-radius: 50%; color: var(--ui-accent); text-decoration: none; touch-action: manipulation; -webkit-tap-highlight-color: transparent; }.ui-contlist-act:active { background: var(--ui-accent-soft); }.ui-contlist-act svg { width: 22px; height: 22px; }.ui-contlist-all { display: block; min-height: 46px; margin: 12px auto; padding: 0 18px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-accent); font: 500 15px/1 var(--ui-font); cursor: pointer; }.ui-contlist-none { margin: 40px 16px; color: var(--ui-muted); text-align: center; }.ui-contlist-end { height: env(safe-area-inset-bottom); }@container (min-width: 640px) { .ui-contlist-rows { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 8px; padding: 0 8px; }}@media (hover: hover) { button.ui-contlist-who:hover { background: color-mix(in srgb, var(--ui-fg) 4%, transparent); } .ui-contlist-act:hover { background: var(--ui-accent-soft); } }`// Text as sorting and search read it: lower case, without the common Latin accents.let contact_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 contact_list_fold(text) -> str = split(lower(str(text ?? "")), "") | map((c) => get(contact_list_accents, c, c)) | join("")// Only the digits of a number.fn contact_list_digits(text) -> str = re.replace(str(text ?? ""), "[^0-9]", "")// A number as a tel: or sms: address takes it: digits, with the + kept. Nothing if it has no digits.fn contact_list_dial(text) -> str { let digits = contact_list_digits(text) if digits == "" { return "" } starts_with(trim(str(text)), "+") ? `+${digits}` : digits}// All of a person's numbers, the main one first.fn contact_list_numbers(p) -> list { let more = get(p, "phones", []) let all = [get(p, "phone")] + (type_of(more) == "list" ? more : []) all | filter((n) => n != null && contact_list_digits(n) != "") | map((n) => str(n))}// The word a person sorts by, first or last.fn contact_list_sort_word(fold: str, by: str) -> str { let words = split(fold, " ") | filter((w) => w != "") if by != "last" || len(words) < 2 { return fold } join([words[len(words) - 1]] + take(words, len(words) - 1), " ")}// The letter a person stands under: a to z, else #.fn contact_list_letter(key: str) -> str { let c = slice(key, 0, 1) re.test(c, "^[a-z]$") ? upper(c) : "#"}// What the list keeps of a person: their name, what sorts them, and all that search reads.fn contact_list_entry(p, by: str) -> map { let name = trim(str(get(p, "name") ?? "")) let org = str(get(p, "org") ?? "") let shown = name != "" ? name : (org != "" ? org : "No name") let fold = contact_list_fold(shown) let key = contact_list_sort_word(fold, by) let numbers = contact_list_numbers(p) { person: p, name: shown, numbers, key, letter: contact_list_letter(key), hay: `${fold} ${contact_list_fold(org)}`, digits: join(numbers | map(contact_list_digits), " ") }}// Everyone, in order: letters first and # last, by name within a letter. The sort// compares folded text only, so server and browser agree in any locale.fn contact_list_index(people, by: str) -> list { let given = type_of(people) == "list" ? people | filter((p) => type_of(p) == "map") : [] enumerate(given) | map((p) => { ...contact_list_entry(p[1], by), n: p[0] }) | sort_by((e) => `${e.letter == "#" ? 1 : 0}${e.key}`)}// Who matches: every word in the name or organisation, or the digits in a number.// A query of digits and punctuation alone is a number; with three digits or more it// may also be found inside a name's words, as "4th".fn contact_list_find(entries: list, query: str) -> list { let q = trim(contact_list_fold(query)) if q == "" { return entries } let words = split(q, " ") | filter((w) => w != "") let digits = contact_list_digits(q) let number = digits != "" && re.test(q, "^[0-9 +()./-]+$") entries | filter((e) => (number && contains(e.digits, digits)) || all(words, (w) => contains(e.hay, w)))}// The rows under their letters, in order. A letter is never a number, so group_by keeps its order.fn contact_list_sections(rows: list) -> list { let parts = group_by(rows, (e) => e.letter) rows | map((e) => e.letter) | unique | map((l) => { letter: l, rows: get(parts, l, []) })}view ContactListWho(e) = [<Avatar name={e.name} src={get(e.person, "src")}/>, <span.ui-contlist-text> <span.ui-contlist-name>{e.name}</span> {len(e.numbers) > 0 && <span.ui-contlist-num>{e.numbers[0]}</span>} </span>]component ContactList(people = [], by: str = "first", search: bool = true, placeholder: str = "Search name or number", limit: int = 600, empty: str = "No contacts", label: str = "Contacts", on_select = null, on_call = null, on_message = null, id: str? = null, class: str? = null) { style global contact_list_css let index = contact_list_index(people, by) state query = "" state whole = false state told = "" let found = contact_list_find(index, query) let rows = whole ? found : take(found, limit) let sections = contact_list_sections(rows) // preventDefault must come before anything waits, so whether this is the app is // read from the event's own window, as a field, not asked of js(), which waits. let act = (e, handler, person, what: str) => { if handler != null { e.preventDefault() handler(person) } else if e.view.quilloNative != null { e.preventDefault() let number = contact_list_numbers(person)[0] told = `This app cannot ${what}: ${number} is in the share sheet.` js("quilloNative.share", number, null) } } <div class={ui_cls(["ui-contlist", !search && "ui-contlist-bare", class])} id={id}> {search && <div.ui-contlist-search role="search"> <div.ui-contlist-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-contlist-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) => { if e.key == "Enter" { e.preventDefault(); e.target.blur() } if e.key == "Escape" { query = "" } }}/> </div> {query != "" && <button.ui-contlist-clear type="button" @click={() => { query = "" }}>Clear</button>} </div>} {told != "" && <p.ui-contlist-note role="status">{told}</p>} {query != "" && <p.ui-contlist-found role="status">{len(found) == 1 ? "1 person" : `${len(found)} people`}</p>} {len(found) == 0 && <p.ui-contlist-none>{query != "" ? `No one matches “${query}”` : empty}</p>} <div aria-label={label} role="group"> {for s in sections => <section.ui-contlist-section key={s.letter} data-letter={s.letter}> <h3.ui-contlist-letter>{s.letter}</h3> <ul.ui-contlist-rows> {for e in s.rows => <li.ui-contlist-row key={e.n}> {on_select != null ? <button.ui-contlist-who type="button" @click={() => { on_select(e.person) }}><ContactListWho e={e}/></button> : <div.ui-contlist-who><ContactListWho e={e}/></div>} {len(e.numbers) > 0 && <a.ui-contlist-act href={`sms:${contact_list_dial(e.numbers[0])}`} aria-label={`Message ${e.name}`} @click={(ev) => { act(ev, on_message, e.person, "send a message") }}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8 8 0 0 1-8 8H4l2.3-3A8 8 0 1 1 21 12z"/></svg> </a>} {len(e.numbers) > 0 && <a.ui-contlist-act href={`tel:${contact_list_dial(e.numbers[0])}`} aria-label={`Call ${e.name}`} @click={(ev) => { act(ev, on_call, e.person, "start a call") }}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 16.5v3a2 2 0 0 1-2.2 2A18 18 0 0 1 2.5 5.2 2 2 0 0 1 4.5 3h3a2 2 0 0 1 2 1.7c.1 1 .4 1.9.7 2.8a2 2 0 0 1-.5 2L8.500 11a16 16 0 0 0 4.500 4.500l1.500-1.200a2 2 0 0 1 2-.5c.9.3 1.800.6 2.800.7a2 2 0 0 1 1.700 2z"/></svg> </a>} </li>} </ul> </section>} </div> {!whole && len(found) > limit && <button.ui-contlist-all type="button" @click={() => { whole = true }}>Show all {len(found)}</button>} <div.ui-contlist-end></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 downloadsDragReorderPut a list in order by long press and drag, scrolling at its edges, and by keys or buttons too.2 views · 7 downloadsEmptyScreenEmpty states for first use, none-found and all-done, each with what to do next.3 views · 9 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 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 downloads
Thread
Thread 0 posts
On ContactList: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.