Forms

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.

#smart#interactive#combobox#search#fuzzy#forms

Demo

Preview

Pick someone to see on_change.

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

Thread 0 posts

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

No posts yet. The first one opens the thread.