Mobile

ContactList

People grouped by letter with avatars from initials, fast search across names and numbers, a call and message action per row.

#mobile#interactive#tablet#list#search#people

Demo

Preview

A

E

G

K

L

N

O

R

Y

Z

#

Picked none; called none

H

  • Grace Hopper1 2

L

  • Ada Lovelace+1

P

  • Plato

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

N

  • No name

T

  • Twin
  • Twin

Nobody yet

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

Thread 0 posts

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

No posts yet. The first one opens the thread.