FuzzySelect
A combobox that ranks options by fuzzy subsequence score with matched letters highlighted and recent picks first, ignoring case and accents, with full ARIA keyboard behaviour and a real field.
Demo
- Ana Núñez
- Ben Okafor
- Bo Li
- Chloé Martin
Pick someone to see on_change.
- Part 0 of the catalogue
- Part 1 of the catalogue
- Part 2 of the catalogue
- Part 3 of the catalogue
- Part 4 of the catalogue
- Part 5 of the catalogue
- Part 6 of the catalogue
- Part 7 of the catalogue
- Part 8 of the catalogue
- Part 9 of the catalogue
- Part 10 of the catalogue
- Part 11 of the catalogue
- Part 12 of the catalogue
- Part 13 of the catalogue
- Part 14 of the catalogue
- Part 15 of the catalogue
- Part 16 of the catalogue
- Part 17 of the catalogue
- Part 18 of the catalogue
- Part 19 of the catalogue
2980 more: keep typing to narrow them down.
Get it
It needs theme.qwe, as every component does, and slug_input.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/slug_input.qwe$ curl -O https://quillolang.org/components/ui/fuzzy_select.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/fuzzy_select.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/fuzzy_select.qwe
let fuzzy_select_demo_cities = [{ label: "Lisbon", value: "LIS", hint: "Portugal" }, { label: "London", value: "LON", hint: "United Kingdom" }, { label: "Los Angeles", value: "LAX", hint: "United States" }, { label: "Luxembourg", value: "LUX" }, { label: "São Paulo", value: "SAO", hint: "Brazil" }, { label: "Salzburg", value: "SZG" }, { label: "San Salvador", value: "SAL" }, { label: "Zürich", value: "ZRH", hint: "Switzerland" }, { label: "Kraków", value: "KRK" }, { label: "Reykjavík", value: "REK" }, { label: "New York", value: "NYC" }, { label: "Newark", value: "EWR" }, { label: "Auckland", value: "AKL", hint: "New Zealand" }, { label: "Straße des 17. Juni", value: "STR" }, { label: "Москва", value: "MOW" }]component FuzzySelectDemoLive() { state got = null <div.demo-stack> <FuzzySelect id="fuzzy-select-demo-live" name="assignee" label="Assignee" options={["Ana Núñez", "Ben Okafor", "Bo Li", "Chloé Martin"]} value="Bo Li" on_change={(v) => { got = { v } }}/> <p.demo-note>{got == null ? "Pick someone to see on_change." : `on_change: ${got.v ?? "nothing"}`}</p> </div>}view FuzzySelectDemo() = <div class="demo-stack demo-narrow"> <form> <FuzzySelect id="fuzzy-select-demo" name="city" label="City" options={fuzzy_select_demo_cities} recent={["ZRH", "LON"]} placeholder="Try sao, nyc or krk"/> </form> <FuzzySelectDemoLive/> <FuzzySelect id="fuzzy-select-demo-many" label="Part number" options={range(3000) | map((i) => `Part ${i} of the catalogue`)} limit={20}/> </div>Source
ui/fuzzy_select.qwe
// FuzzySelect: a combobox that finds what is meant from a few letters. It ranks options by// a fuzzy subsequence score ("nzl" finds New Zealand, "grm" Germany): the letters must come// in order, and it counts for more when they follow one another and start words. Case and// accents are ignored ("sao" finds São Paulo, "strasse" finds Straße), matched letters are// highlighted, and recent picks come first. It follows the ARIA combobox pattern, and the// value goes in a hidden field.//// <FuzzySelect id="city" name="city" label="City" options={cities} recent={["LIS"]}/>// <FuzzySelect id="owner" label="Owner" options={people} on_change={(v) => { owner = v }}/>//// id: the text box's id, unique on the page; the list's ids start with it. name: the// hidden field that holds the value. label, hint, placeholder, required, disabled as for// every field. options: strings, or maps of { label, value, hint }, a hint being a second// line, not searched. value: the value picked to start with. recent: values picked// lately, the latest first, shown first and worth more in the ranking; what is picked here// joins them while the page stays. limit: the most rows shown (50); it says how many more// there are. empty: what the list says when nothing matches ("No matches"). class: more// classes. on_change(value): inside one of your components, the value picked, null when// it is cleared.//// The keys: Down and Up move, opening the list first (Alt+Down opens it and stays put,// Alt+Up closes it); Page Down and Page Up move five; Enter picks; Escape closes, and// closed clears; Tab picks what the typing led to and moves on. Home and End stay with// the text. The count of matches is announced. Leaving the box, text that names an// option picks it, other text goes back to what was picked, and no text picks none.//// fuzzy_select_score(query, label) gives { score, at } or null: at holds the positions// of the matched characters. Each match scores 1, and 12 more next to the last, 10 at the// start of a word (16 at the very start); each letter skipped costs 1, at the start at// most 5. fuzzy_select_rank(rows, query, recent, limit) ranks what// fuzzy_select_index(options) makes. Only the first 5,000 options and 200 characters of a// label are searched, and 64 of a query, so a key press stays quick; it says so.import ui/slug_input.qwe use slug_input_lettersimport ui/theme.qwe use ui_clslet fuzzy_select_css = css`.ui-fuzzysel { position: relative; display: grid; gap: 6px; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-fuzzysel-label { font-size: 13px; font-weight: 500; }.ui-fuzzysel-req { margin-left: 2px; color: var(--ui-danger); }.ui-fuzzysel-hint { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-fuzzysel-box { position: relative; display: flex; align-items: center; }.ui-fuzzysel-control { box-sizing: border-box; width: 100%; height: 38px; margin: 0; padding: 0 36px 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1 var(--ui-font); transition: border-color .15s, box-shadow .15s; }.ui-fuzzysel-control::placeholder { color: var(--ui-muted); opacity: 1; }.ui-fuzzysel-control:hover { border-color: var(--ui-strong); }.ui-fuzzysel-control:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-fuzzysel-control:disabled { opacity: .55; cursor: not-allowed; }.ui-fuzzysel-toggle { position: absolute; right: 4px; display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-muted); cursor: pointer; }.ui-fuzzysel-toggle:disabled { cursor: not-allowed; opacity: .55; }.ui-fuzzysel-toggle span { width: 7px; height: 7px; margin-top: -4px; border: solid currentColor; border-width: 0 1.5px 1.5px 0; transform: rotate(45deg); }.ui-fuzzysel-open .ui-fuzzysel-toggle span { margin-top: 3px; transform: rotate(-135deg); }.ui-fuzzysel-pop { position: absolute; z-index: 20; top: 100%; left: 0; right: 0; margin-top: 4px; padding: 4px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); box-shadow: var(--ui-shadow); }.ui-fuzzysel-pop[hidden] { display: none; }.ui-fuzzysel-list { max-height: 260px; overflow: auto; margin: 0; padding: 0; list-style: none; }.ui-fuzzysel-opt { display: flex; align-items: center; gap: 10px; padding: 7px 10px; border-radius: 4px; cursor: pointer; color: var(--ui-fg); overflow-wrap: anywhere; }.ui-fuzzysel-opt[aria-selected="true"] { background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-fuzzysel-text { flex: 1; min-width: 0; }.ui-fuzzysel-text mark { background: none; color: inherit; font-weight: 700; text-decoration: underline; text-underline-offset: 2px; }.ui-fuzzysel-sub { display: block; color: var(--ui-muted); font-size: 12px; }.ui-fuzzysel-tag { flex: none; color: var(--ui-muted); font-size: 11px; }.ui-fuzzysel-opt svg { flex: none; width: 14px; height: 14px; color: var(--ui-accent); }.ui-fuzzysel-empty, .ui-fuzzysel-more { padding: 7px 10px; color: var(--ui-muted); font-size: 13px; }@media (prefers-reduced-motion: reduce) { .ui-fuzzysel-control { transition: none; } }`let fuzzy_select_scan = 5000let fuzzy_select_label_limit = 200let fuzzy_select_query_limit = 64let fuzzy_select_recent_bonus = 15let fuzzy_select_recent_kept = 5// A word's letters as searching sees them, each with the character it came from: plain,// small letters, so one ß gives two letters and a mark that sits on its letter gives none.fn fuzzy_select_fold(text: str) { let letters = [] let from = [] for [i, c] in enumerate(split(text, "")) { let low = lower(c) for f in split(slug_input_letters[low] ?? low, "") { if f != "" && !(f >= "̀" && f <= "ͯ") { letters = push(letters, f) from = push(from, i) } } } { letters, from }}fn fuzzy_select_wordy(c) = lower(c) != upper(c) || (c >= "0" && c <= "9")// What starting at each letter is worth: at the start of a word, or at a capital after a// small letter (as in "QuilloLang").fn fuzzy_select_bonuses(text: str, fold) { let source = split(text, "") fold.from | map((i) => { let here = source[i] let before = i > 0 ? source[i - 1] : "" i == 0 ? 16 : !fuzzy_select_wordy(before) ? 10 : here != lower(here) && before == lower(before) && fuzzy_select_wordy(before) ? 10 : 0 })}fn fuzzy_select_query(text) = (fuzzy_select_fold(slice(str(text ?? ""), 0, fuzzy_select_query_limit)).letters) | filter((c) => trim(c) != "")// The best way to fit the letters `q` into `hay`, by one pass per letter over the label:// each place keeps its best score and where the letter before it was, and a running best// stands for "anywhere further back", less 1 for each letter skipped.fn fuzzy_select_best(q: list, hay: list, bonus: list) { let n = len(q) let m = len(hay) if n == 0 || n > m { return null } let walked = 0 let k = 0 while k < m && walked < n { if hay[k] == q[walked] { walked += 1 } k += 1 } if walked < n { return null } let prev = [] let trail = [] for i in 0..n { let cur = [] let from = [] let run = null let run_at = -1 let wanted = q[i] for j in 0..m { if i > 0 && j >= 2 { if run != null { run -= 1 } let back = prev[j - 2] if back != null && (run == null || back - 1 >= run) { run = back - 1 run_at = j - 2 } } if hay[j] != wanted { cur = push(cur, null) from = push(from, -1) } else if i == 0 { cur = push(cur, 1 + bonus[j] - min([j, 5])) from = push(from, -1) } else { let next_to = j >= 1 ? prev[j - 1] : null let close = next_to != null ? next_to + 12 : null if close != null && (run == null || close >= run) { cur = push(cur, close + 1 + bonus[j]) from = push(from, j - 1) } else if run != null { cur = push(cur, run + 1 + bonus[j]) from = push(from, run_at) } else { cur = push(cur, null) from = push(from, -1) } } } prev = cur trail = push(trail, from) } let best = null let end = -1 for [j, s] in enumerate(prev) { if s != null && (best == null || s > best) { best = s end = j } } if best == null { return null } let at = [] let step = n - 1 let place = end while step >= 0 { at = [place, ...at] place = trail[step][place] step -= 1 } { score: best, at }}// The score of one label, for a program's own use.fn fuzzy_select_score(query, label) { let text = slice(str(label ?? ""), 0, fuzzy_select_label_limit) let fold = fuzzy_select_fold(text) let hit = fuzzy_select_best(fuzzy_select_query(query), fold.letters, fuzzy_select_bonuses(text, fold)) hit == null ? null : { score: hit.score, at: unique(hit.at | map((j) => fold.from[j])) }}// Options made ready to search once, so that a key press only scores them.fn fuzzy_select_index(options) { let kept = (type_of(options) == "list" ? options : []) | filter((o) => o != null) let rows = [] for o in slice(kept, 0, fuzzy_select_scan) { let is_map = type_of(o) == "map" let label = is_map ? str(o.label ?? o.value ?? "") : str(o) let value = is_map ? o.value ?? o.label : o let text = slice(label, 0, fuzzy_select_label_limit) let fold = fuzzy_select_fold(text) rows = push(rows, { label, value, sub: is_map ? o.hint : null, letters: fold.letters, from: fold.from, bonus: fuzzy_select_bonuses(text, fold) }) } rows}// The rows to show for a query: { row, at, recent }, the best first, and how many// matched. A recent pick gets a head start, not first place at any price: a poor match// should not sit over the exact one.fn fuzzy_select_rank(rows: list, query, recent: list = [], limit: int = 50) { let q = fuzzy_select_query(query) let is_recent = (v) => contains(recent, v) if len(q) == 0 { let lately = recent | map((v) => find(rows, (r) => r.value == v)) | filter((r) => r != null) let others = rows | filter((r) => !is_recent(r.value)) let all_rows = [...lately, ...others] return { total: len(all_rows), shown: slice(all_rows, 0, limit) | map((r) => { row: r, at: [], recent: is_recent(r.value) }) } } let found = [] for r in rows { let hit = fuzzy_select_best(q, r.letters, r.bonus) if hit != null { let lately = is_recent(r.value) found = push(found, { row: r, at: unique(hit.at | map((j) => r.from[j])), recent: lately, score: hit.score + (lately ? fuzzy_select_recent_bonus : 0) }) } } // Stable, so of equals the shorter goes first, then the order given. let ranked = sort_by(found, (f) => 0 - f.score * 1000 + min([len(f.row.label), 999])) { total: len(ranked), shown: slice(ranked, 0, limit) }}// A label cut into runs of plain and matched text.fn fuzzy_select_parts(label: str, at: list) { let parts = [] let piece = "" let on = false for [i, c] in enumerate(split(label, "")) { let hit = contains(at, i) if hit != on && piece != "" { parts = push(parts, { text: piece, hit: on }) piece = "" } on = hit piece += c } piece != "" ? push(parts, { text: piece, hit: on }) : parts}fn fuzzy_select_label_of(rows: list, value) = (find(rows, (r) => r.value == value) ?? { label: "" }).labelcomponent FuzzySelect(id: str, name: str? = null, label: str? = null, value = null, options: list = [], recent: list = [], placeholder: str? = null, hint: str? = null, required: bool = false, disabled: bool = false, limit: int = 50, empty: str = "No matches", on_change = null, class: str? = null) { style global fuzzy_select_css let rows = fuzzy_select_index(options) state chosen = value state query = fuzzy_select_label_of(fuzzy_select_index(options), value) state chosen_label = query state open = false state typing = false state active = 0 state picks = recent let result = fuzzy_select_rank(rows, typing ? query : "", picks, limit) let shown = result.shown let act = active < len(shown) ? active : 0 let extra = result.total - len(shown) let cut_off = len(options) > fuzzy_select_scan let said = !open ? "" : result.total == 0 ? empty : result.total == 1 ? "1 match" : `${result.total} matches` let described = ui_cls([hint != null && `${id}-hint`]) let pick = (r) => { chosen = r.value chosen_label = r.label query = r.label typing = false open = false picks = slice(unique([r.value, ...picks]), 0, fuzzy_select_recent_kept) on_change && on_change(r.value) } let show = () => { open = true active = index_of(shown | map((s) => s.row.value), chosen) ?? 0 } let reveal = (e, i: int) => { let el = e.target.closest(".ui-fuzzysel").querySelector(`[data-i="${i}"]`) el && el.scrollIntoView({ block: "nearest" }) } let clear = () => { query = "" chosen_label = "" typing = false if chosen != null { chosen = null on_change && on_change(null) } } let leave = () => { open = false if typing { typing = false let needle = lower(trim(query)) let same = find(rows, (r) => lower(r.label) == needle) if needle == "" { clear() } else if same != null { if same.value != chosen { pick(same) } else { query = same.label } } else { query = chosen_label } } } <div class={ui_cls(["ui-fuzzysel", open && "ui-fuzzysel-open", class])}> {label != null && <label.ui-fuzzysel-label for={id}> {label}{required && <span.ui-fuzzysel-req aria-hidden="true">*</span>} </label>} <div.ui-fuzzysel-box> <input.ui-fuzzysel-control type="text" id={id} role="combobox" value={query} placeholder={placeholder} required={required} disabled={disabled} autocomplete="off" spellcheck="false" aria-autocomplete="list" aria-expanded={open ? "true" : "false"} aria-controls={`${id}-list`} aria-activedescendant={open && len(shown) > 0 ? `${id}-opt-${act}` : null} aria-describedby={described != "" ? described : null} @input={(e) => { query = e.value typing = true open = true active = 0 }} @click={() => { if !open { show() } }} @blur={leave} @keydown={(e) => { let n = len(shown) if (e.key == "ArrowDown" || e.key == "ArrowUp") && e.altKey { e.preventDefault() if e.key == "ArrowDown" { show() } else { open = false } } else if e.key == "ArrowDown" || e.key == "ArrowUp" { e.preventDefault() if !open { show() } else if n > 0 { active = e.key == "ArrowDown" ? (act + 1) % n : (act + n - 1) % n reveal(e, active) } } if (e.key == "PageDown" || e.key == "PageUp") && open && n > 0 { e.preventDefault() active = e.key == "PageDown" ? min([act + 5, n - 1]) : max([act - 5, 0]) reveal(e, active) } if e.key == "Enter" && open { e.preventDefault() if n > 0 { pick(shown[act].row) } } if e.key == "Escape" { if open { e.preventDefault() open = false } else if query != "" { e.preventDefault() clear() } } if e.key == "Tab" && open && typing && n > 0 && trim(query) != "" { pick(shown[act].row) } }}/> <button.ui-fuzzysel-toggle type="button" tabindex="-1" aria-label={open ? "Hide options" : "Show options"} disabled={disabled} @mousedown={(e) => { e.preventDefault() }} @click={(e) => { e.target.closest(".ui-fuzzysel").querySelector("input").focus() if open { open = false } else { show() } }}><span aria-hidden="true"></span></button> <div.ui-fuzzysel-pop hidden={!open}> <ul.ui-fuzzysel-list id={`${id}-list`} role="listbox" aria-label={label ?? "Options"}> {for [i, s] in enumerate(shown) => <li.ui-fuzzysel-opt key={str(s.row.value)} id={`${id}-opt-${i}`} data-i={i} role="option" aria-selected={i == act ? "true" : "false"} @mousedown={(e) => { e.preventDefault() }} @click={() => { pick(s.row) }}> <span.ui-fuzzysel-text> {for p in fuzzy_select_parts(s.row.label, s.at) => p.hit ? <mark>{p.text}</mark> : <span>{p.text}</span>} {s.row.sub != null && <span.ui-fuzzysel-sub>{s.row.sub}</span>} </span> {s.recent && <span.ui-fuzzysel-tag>Recent</span>} {s.row.value == chosen && <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7"/></svg>} </li>} </ul> {len(shown) == 0 ? <div.ui-fuzzysel-empty>{empty}</div> : null} {extra > 0 ? <div.ui-fuzzysel-more>{extra} more: keep typing to narrow them down.</div> : null} {cut_off ? <div.ui-fuzzysel-more>Only the first {fuzzy_select_scan} options are searched.</div> : null} </div> </div> <span.ui-sr role="status" aria-live="polite">{said}</span> {name != null && <input type="hidden" name={name} value={chosen ?? ""}/>} {hint != null && <p.ui-fuzzysel-hint id={`${id}-hint`}>{hint}</p>} </div>}Related
AmountInputReads 1.5k, 2m, 1,234.50, 1.234,50, $12 and EUR 40, decides which mark groups, shows the normalised amount, flags what could go two ways, and sends a real decimal.3 views · 8 downloadsAutocompleteA free-text box that offers matches as one types, with the arrow keys and Enter; whatever is typed may stay.5 views · 7 downloadsBarcodeInputTells EAN-13, EAN-8, UPC-A, ISBN-10 and ISBN-13 apart by shape, checks the check digit, says what the right one would be, and converts ISBN-10 to ISBN-13.2 views · 5 downloadsComboboxA text box that filters a list of options as you type, or asks for them (a server, a map service), with the arrow keys, Enter and Escape.5 views · 6 downloadsContactPastePaste an email signature or a blurb: it pulls out the name, email, phone, website, company and address into editable fields, marks how sure each guess is, and never overwrites an edit.5 views · 6 downloadsEmailTypoInputChecks an address's shape, offers did you mean gmail.com by edit distance with one click to fix, warns of throwaway domains, and stays quiet on a valid uncommon domain.3 views · 6 downloads
Thread
Thread 0 posts
On FuzzySelect: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.