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.
Demo
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.qwemain.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>}Related
DragReorderPut a list in order by long press and drag, scrolling at its edges, and by keys or buttons too.2 views · 7 downloadsSelectionModeLong press starts selecting, tap adds, a toolbar shows the count and actions, and back leaves the mode.4 views · 7 downloadsSwipeRowA list row with actions behind it each way, a full swipe to run one, and a More button and keys to the same actions.2 views · 8 downloadsActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloadsAlertInboxAlerts by severity, swiped to acknowledge or snooze, with buttons and keys to the same, and a badge that stays right as snoozes run out.3 views · 8 downloadsApprovalQueueOne request at a time, swiped to approve or reject, with a required reason, undo and a count of what is left.3 views · 6 downloads
Thread
Thread 0 posts
On AlphaRailList: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.