Mobile

AlphaRailList

A 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.

#mobile#interactive#tablet#list#gesture#haptic

Demo

Preview

A

B

C

D

E

F

G

H

I

J

K

L

M

N

O

P

R

S

T

U

V

W

Y

Z

#

98 names

Picked none; went to none

A

  • AdaMaths

Z

  • ZedLast
  • ZedTwin

#

  • 4

4 names

No artists yet

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/alpha_rail_list.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/alpha_rail_list.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/alpha_rail_list.qwe
let alpha_rail_list_demo_names = ["Abba", "AC/DC", "Adele", "Aretha Franklin", "Arcade Fire", "Bach", "Beck", "Björk", "Blur", "Bob Dylan", "Bowie", "Cat Power", "Chic", "Coldplay", "Cream", "Daft Punk", "David Byrne", "Debussy", "Depeche Mode", "Dolly Parton",    "Eagles", "Elbow", "Ella Fitzgerald", "Eno", "Erykah Badu", "Fela Kuti", "Fleetwood Mac", "Fugazi", "Gang of Four", "Genesis", "Gorillaz", "Grimes", "Hendrix", "Herbie Hancock", "Hole", "Hot Chip", "Iggy Pop", "Imogen Heap", "Interpol", "Iron Maiden",    "Jay-Z", "Joni Mitchell", "Joy Division", "Kate Bush", "Kendrick Lamar", "Kraftwerk", "Kings of Leon", "LCD Soundsystem", "Led Zeppelin", "Leonard Cohen", "Lorde", "Madonna", "Massive Attack", "Miles Davis", "Muse", "Nas", "Neil Young", "New Order", "Nina Simone", "Nirvana",    "Oasis", "OutKast", "Patti Smith", "Pink Floyd", "Pixies", "Portishead", "Prince", "Radiohead", "R.E.M.", "Rihanna", "Roxy Music", "Sade", "Sigur Rós", "Solange", "Sonic Youth", "Stevie Wonder", "Talking Heads", "Television", "The Cure", "Tom Waits",    "U2", "Ultravox", "Underworld", "Vampire Weekend", "Velvet Underground", "Vivaldi", "Weezer", "Wilco", "Wu-Tang Clan", "Yeah Yeah Yeahs", "Yes", "Yo La Tengo", "Zappa", "ZZ Top", "1975", "2Pac", "808 State", "Ænima Tribute"]component AlphaRailListDemoLive() {    state picked = "none"    state went = "none"    <div.demo-stack>        <div.demo-phone style="height: 600px" data-frame>            <AlphaRailList id="alrail-demo" items={alpha_rail_list_demo_names} height="100%" label="Artists"                           on_select={(a) => { picked = a }} on_letter={(l) => { went = l }}/>        </div>        <p.demo-note>Picked <b data-picked>{picked}</b>; went to <b data-went>{went}</b></p>        <div.demo-phone style="height: 260px">            <AlphaRailList id="alrail-short" height="100%" haptics={false} items={[{ id: 1, name: "Ada", sub: "Maths" }, { id: 2, name: "Zed", sub: "Last" }, { id: 3, name: "Zed", sub: "Twin" }, null, { name: "" }, 4]}/>        </div>        <AlphaRailList id="alrail-none" height="120px" empty="No artists yet"/>    </div>}view AlphaRailListDemo() = <AlphaRailListDemoLive/>

Source

ui/alpha_rail_list.qwe
// AlphaRailList: a long list in alphabetical order with sticky letter headings and// the A to Z rail down its edge, as in a phone's Contacts or Music. Put a finger on the// rail and slide: a bubble beside the finger shows the letter, the list jumps with it,// and each new letter is felt as a haptic. A letter with nothing under it dims, and the// list goes to the nearest that has some. The rail is a slider to the keyboard and// to a screen reader: arrows, Page keys, Home, End, or the letter itself. On a short// screen, a phone turned sideways, the rail folds to every second or third letter with// dots between, and all still work under the finger. In a wide pane rows stand in two columns.////   <AlphaRailList items={artists} height="100%" on_select={(a) => { open_artist(a.id) }}/>//// items: a list of strings, or of { id?, name, sub? }. height: how tall it stands, a// CSS length ("100dvh"); "100%" fills a parent that has a height. letters: the alphabet// ("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), and # is added for what starts with anything else.// empty: what shows with no items ("Nothing here"). haptics: the felt tick (true).// label: what screen readers call the list ("Names"). id, class: on the outer element.// on_select(item): inside one of your components, a tap on a row, with what was given// in items; rows are plain text without it. on_letter(letter): a jump by rail or key// has ended on a letter.//// Haptics: quilloNative.haptic in the apps, navigator.vibrate on Android's browser.// iOS's browser has neither; the list says so at its end, and the bubble does the work.// Sections off screen are not laid out, so thousands of rows stay light.import ui/theme.qwe use ui_clslet alpha_rail_list_css = css`.ui-alrail { position: relative; container-type: inline-size; box-sizing: border-box; overflow: hidden; background: var(--ui-bg); color: var(--ui-fg); font: 16px/1.3 var(--ui-font); }.ui-alrail-list { position: relative; box-sizing: border-box; height: 100%; padding-right: 44px; overflow-y: auto; overscroll-behavior-y: contain; }.ui-alrail-head { position: sticky; top: 0; z-index: 1; margin: 0 -44px 0 0; padding: 3px 16px; background: var(--ui-subtle); color: var(--ui-muted); font: 600 13px/1.5 var(--ui-font); }.ui-alrail-rows { margin: 0; padding: 0; list-style: none; content-visibility: auto; }.ui-alrail-row { display: block; box-sizing: border-box; width: 100%; min-height: 48px; padding: 7px 8px 7px max(16px, env(safe-area-inset-left)); border: 0; border-bottom: 1px solid var(--ui-border); background: transparent; color: inherit; font: inherit; text-align: left; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }button.ui-alrail-row { cursor: pointer; }button.ui-alrail-row:active { background: color-mix(in srgb, var(--ui-fg) 8%, transparent); }.ui-alrail-row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--ui-accent); }.ui-alrail-sub { display: block; color: var(--ui-muted); font-size: 13px; }.ui-alrail-foot { margin: 0; padding: 14px 16px calc(14px + env(safe-area-inset-bottom)); color: var(--ui-muted); font-size: 13px; text-align: center; }.ui-alrail-none { margin: 40px 16px; color: var(--ui-muted); text-align: center; }/* none: the whole gesture is the rail's; the list beside it still scrolls by itself. */.ui-alrail-rail { position: absolute; top: 0; right: 0; bottom: 0; z-index: 2; display: flex; box-sizing: border-box; width: 44px; padding: 8px 0 calc(8px + env(safe-area-inset-bottom)); touch-action: none; cursor: grab; outline: none;  -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent; }.ui-alrail-rail:focus-visible .ui-alrail-letters { box-shadow: var(--ui-focus); }.ui-alrail-letters { display: flex; flex: 1; flex-direction: column; margin: 0 8px; border-radius: 999px; pointer-events: none; }.ui-alrail-rail[data-on] .ui-alrail-letters { background: color-mix(in srgb, var(--ui-fg) 10%, transparent); }.ui-alrail-letter { display: flex; align-items: center; justify-content: center; flex: 1; min-height: 0; color: var(--ui-accent); font: 600 11px/1 var(--ui-font); }.ui-alrail-letter[data-none] { color: var(--ui-strong); }.ui-alrail-letter[data-at] { color: var(--ui-accent-fg); }.ui-alrail-letter[data-at] span { display: grid; place-items: center; min-width: 15px; height: 15px; border-radius: 50%; background: var(--ui-accent); }.ui-alrail-bubble { position: absolute; top: 0; right: 60px; z-index: 3; display: grid; place-items: center; width: 64px; height: 64px; margin-top: -32px; border-radius: 50% 50% 8px 50%; background: var(--ui-accent); color: var(--ui-accent-fg); font: 600 30px/1 var(--ui-font); box-shadow: var(--ui-shadow-lg); pointer-events: none; opacity: 0; transition: opacity .12s; }.ui-alrail-bubble[data-on] { opacity: 1; }.ui-alrail-bubble[data-none] { background: var(--ui-strong); color: var(--ui-fg); }@container (min-width: 640px) {  .ui-alrail-rows { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 12px; }}@media (hover: hover) { button.ui-alrail-row:hover { background: color-mix(in srgb, var(--ui-fg) 4%, transparent); } }@media (prefers-reduced-motion: reduce) { .ui-alrail-bubble { transition: none; } }`let alpha_rail_list_accents = { "á": "a", "à": "a", "â": "a", "ä": "a", "ã": "a", "å": "a", "ā": "a", "ą": "a", "ç": "c", "ć": "c", "č": "c", "é": "e", "è": "e", "ê": "e", "ë": "e", "ē": "e", "ę": "e", "í": "i", "ì": "i", "î": "i", "ï": "i",    "ł": "l", "ñ": "n", "ń": "n", "ó": "o", "ò": "o", "ô": "o", "ö": "o", "õ": "o", "ø": "o", "ś": "s", "š": "s", "ú": "u", "ù": "u", "û": "u", "ü": "u", "ý": "y", "ÿ": "y", "ź": "z", "ż": "z", "ž": "z", "ß": "ss", "œ": "oe", "æ": "ae" }fn alpha_rail_list_fold(text) -> str = split(lower(str(text ?? "")), "") | map((c) => get(alpha_rail_list_accents, c, c)) | join("")// The rail: each letter, then #.fn alpha_rail_list_rail(letters: str) -> list = (split(upper(letters), "") | filter((l) => l != "#")) + ["#"]// The items as rows, in order: the letters first, by folded name, and the rest last.fn alpha_rail_list_rows(items, rail: list) -> list {    let given = type_of(items) == "list" ? items | filter((x) => x != null) : []    let made = given | map((x) => {        let is_map = type_of(x) == "map"        let name = trim(str(is_map ? (get(x, "name") ?? "") : x))        let fold = alpha_rail_list_fold(name)        let first_l = upper(slice(fold, 0, 1))        let letter = first_l != "#" && contains(rail, first_l) ? first_l : "#"        { item: x, name, sub: is_map ? get(x, "sub") : null, fold, letter }    })    let kept = made | filter((r) => r.name != "")    enumerate(kept) | map((p) => { ...p[1], n: p[0] }) | sort_by((r) => `${r.letter == "#" ? "1" : "0"}${r.fold}`)}// The rows under their letters. The rail's letters are never numbers, so the map's order holds no surprise.fn alpha_rail_list_sections(rows: list) -> list {    let parts = group_by(rows, (r) => r.letter)    rows | map((r) => r.letter) | unique | map((l) => { letter: l, rows: get(parts, l, []) })}// Which of n letters a point on the rail is over.fn alpha_rail_list_index(y: num, top: num, tall: num, n: int) -> int {    if tall <= 0 || n <= 0 { return 0 }    max([0, min([n - 1, floor((y - top) / tall * n)])])}// The letter that has rows nearest a place on the rail: the place itself, then// the closest, going down first on a tie. Null if none has any.fn alpha_rail_list_nearest(rail: list, have: list, i: int) -> str? {    let places = enumerate(rail) | filter((p) => contains(have, p[1]))    if len(places) == 0 { return null }    (places | sort_by((p) => abs(p[0] - i) * 2 + (p[0] < i ? 1 : 0)))[0][1]}// The letter a step away, among those with rows.fn alpha_rail_list_step(have: list, at: str?, by: int) -> str? {    if len(have) == 0 { return null }    let i = index_of(have, at) ?? 0    have[max([0, min([len(have) - 1, i + by])])]}// Which section stands at a scroll position: the last that starts at or above it. Binary, as this runs on each scroll.fn alpha_rail_list_current(tops: list, y: num) -> int {    let lo = 0    let hi = len(tops) - 1    while lo < hi {        let mid = ceil((lo + hi) / 2)        if tops[mid] <= y { lo = mid } else { hi = mid - 1 }    }    lo}// How many letters to one that shows, so each has at least 13 px.fn alpha_rail_list_fold_by(tall: num, n: int) -> int {    if n <= 0 || tall / n >= 13 { return 1 }    min([4, ceil(n * 13 / max([tall, 1]))])}fn alpha_rail_list_haptic() {    let felt = js("quilloNative.haptic", "selection")    if is_err(felt) && js("navigator.userActivation.hasBeenActive") == true { js("navigator.vibrate", 6) }}component AlphaRailList(items = [], height: str = "100dvh", letters: str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ", empty: str = "Nothing here", haptics: bool = true,                        label: str = "Names", on_select = null, on_letter = null, id: str? = null, class: str? = null) {    style global alpha_rail_list_css    let rail = alpha_rail_list_rail(letters)    let rows = alpha_rail_list_rows(items, alpha_rail_list_rail(letters))    let sections = alpha_rail_list_sections(rows)    let have = sections | map((s) => s.letter)    state at = null    state under = null        // the rail's place under the finger, while scrubbing    state box = null    state rail_el = null    state bubble = null    state step = 1    state touching = false    state feel = ""    let shown_at = at ?? (len(have) > 0 ? have[0] : null)    // The list goes to a letter's section, at once: a scrub has no time for smooth.    let go = (letter) => {        if letter != null && box != null {            let s = box.querySelector(`[data-letter="${letter}"]`)            if s != null { box.scrollTop = s.offsetTop }            at = letter        }    }    // A place on the rail under the finger. The bubble is moved directly, not by state.    let scrub = (e) => {        let r = rail_el.firstElementChild.getBoundingClientRect()        let i = alpha_rail_list_index(e.clientY, r.top, r.height, len(rail))        let top_of = rail_el.getBoundingClientRect().top        bubble.style.transform = `translateY(${round(r.top - top_of + (i + 0.5) * r.height / len(rail))}px)`        if i != under {            under = i            let letter = rail[i]            bubble.textContent = letter            if contains(have, letter) { bubble.removeAttribute("data-none") } else { bubble.setAttribute("data-none", "") }            go(alpha_rail_list_nearest(rail, have, i))            if haptics { alpha_rail_list_haptic() }        }    }    let end = () => {        if touching {            touching = false            under = null            bubble.removeAttribute("data-on")            on_letter && on_letter(at)        }    }    <div class={ui_cls(["ui-alrail", class])} id={id} style={`height: ${height}`}         @mount={(el) => {             // Read as fields: js("navigator.vibrate") would call it.             let w = el.ownerDocument.defaultView             feel = w.quilloNative != null ? "app" : (w.navigator.vibrate != null ? "vibrate" : "none")             // Folds the rail when the height changes, as on turning the phone.             let ro = js_new("ResizeObserver", () => {                 if rail_el != null { step = alpha_rail_list_fold_by(rail_el.clientHeight - 16, len(rail)) }             })             ro.observe(el)             () => ro.disconnect()         }}>        <div.ui-alrail-list role="group" aria-label={label} data-haptic={feel}             @mount={(el) => { box = el }}             @scroll={(e) => {                 if !touching {                     let parts = js("Array.from", e.target.querySelectorAll("[data-letter]"))                     if len(parts) > 0 {                         let i = alpha_rail_list_current(parts | map((p) => p.offsetTop), e.target.scrollTop + 1)                         at = parts[i].dataset.letter                     }                 }             }}>            {len(rows) == 0 && <p.ui-alrail-none>{empty}</p>}            {for s in sections => <section key={s.letter} data-letter={s.letter}>                <h3.ui-alrail-head>{s.letter}</h3>                <ul.ui-alrail-rows style={`contain-intrinsic-size: auto ${len(s.rows) * 49}px`}>                    {for r in s.rows => <li key={r.n}>                        {on_select != null                            ? <button.ui-alrail-row type="button" @click={() => { on_select(r.item) }}>{r.name}{r.sub != null && <span.ui-alrail-sub>{r.sub}</span>}</button>                            : <div.ui-alrail-row>{r.name}{r.sub != null && <span.ui-alrail-sub>{r.sub}</span>}</div>}                    </li>}                </ul>            </section>}            {len(rows) > 0 && <p.ui-alrail-foot>{len(rows)} {len(rows) == 1 ? "name" : "names"}{feel == "none" && haptics ? ". This device has no haptics, so the bubble shows each letter." : ""}</p>}        </div>        {len(rows) > 0 && <div.ui-alrail-rail role="slider" tabindex="0" aria-orientation="vertical" aria-label={`Jump to a letter in ${label}`}             aria-valuemin="0" aria-valuemax={len(rail) - 1} aria-valuenow={index_of(rail, shown_at) ?? 0}             aria-valuetext={shown_at ?? ""} data-on={touching ? "" : null}             @mount={(el) => {                 rail_el = el                 step = alpha_rail_list_fold_by(el.clientHeight - 16, len(rail))             }}             @pointerdown={(e) => {                 if e.button == 0 {                     e.preventDefault()                     e.target.closest("[role=slider]").setPointerCapture(e.pointerId)                     touching = true                     under = null                     bubble.setAttribute("data-on", "")                     scrub(e)                 }             }}             @pointermove={(e) => { if touching { scrub(e) } }}             @pointerup={() => { end() }}             @pointercancel={() => { end() }}             @lostpointercapture={() => { end() }}             @keydown={(e) => {                 let to = match e.key {                     "ArrowDown" => alpha_rail_list_step(have, shown_at, 1)                     "ArrowRight" => alpha_rail_list_step(have, shown_at, 1)                     "ArrowUp" => alpha_rail_list_step(have, shown_at, -1)                     "ArrowLeft" => alpha_rail_list_step(have, shown_at, -1)                     "PageDown" => alpha_rail_list_step(have, shown_at, 5)                     "PageUp" => alpha_rail_list_step(have, shown_at, -5)                     "Home" => have[0]                     "End" => have[len(have) - 1]                     _ => len(e.key) == 1 ? alpha_rail_list_nearest(rail, have, index_of(rail, upper(e.key)) ?? -1) : null                 }                 let known = len(e.key) > 1 || contains(rail, upper(e.key))                 if to != null && known {                     e.preventDefault()                     go(to)                     on_letter && on_letter(to)                 }             }}>            <div.ui-alrail-letters aria-hidden="true">                {for [i, l] in enumerate(rail) => <div.ui-alrail-letter key={l} data-none={contains(have, l) ? null : ""} data-at={l == shown_at ? "" : null}>                    <span>{i % step == 0 ? l : "•"}</span>                </div>}            </div>        </div>}        <div.ui-alrail-bubble aria-hidden="true" @mount={(el) => { bubble = el }}></div>    </div>}
Thread
16Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.