Forms

Combobox

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

#forms#input#select#search#interactive

Demo

Preview

Type to filter; the arrow keys and Enter pick.

None picked.

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

Example

The code of the preview above.

demos/combobox.qwe
let combobox_demo_countries = [    { label: "Argentina", value: "AR" }, { label: "Australia", value: "AU" }, { label: "Austria", value: "AT" },    { label: "Belgium", value: "BE" }, { label: "Brazil", value: "BR" }, { label: "Canada", value: "CA" },    { label: "Chile", value: "CL" }, { label: "Denmark", value: "DK" }, { label: "Finland", value: "FI" },    { label: "France", value: "FR" }, { label: "Germany", value: "DE" }, { label: "Greece", value: "GR" },    { label: "Iceland", value: "IS" }, { label: "India", value: "IN" }, { label: "Ireland", value: "IE" },    { label: "Italy", value: "IT" }, { label: "Japan", value: "JP" }, { label: "Mexico", value: "MX" },    { label: "Netherlands", value: "NL" }, { label: "New Zealand", value: "NZ" }, { label: "Norway", value: "NO" },    { label: "Portugal", value: "PT" }, { label: "South Korea", value: "KR" }, { label: "Spain", value: "ES" },    { label: "Sweden", value: "SE" }, { label: "Switzerland", value: "CH" }, { label: "United Kingdom", value: "GB" },    { label: "United States", value: "US" },]component ComboboxDemoLive() {    state lang = null    <div.demo-stack>        <Combobox id="cb-lang" name="lang" label="Favourite language" placeholder="Start typing"                  options={["C", "Elixir", "Go", "Haskell", "JavaScript", "Python", "Quillo", "Ruby", "Rust", "Zig"]}                  on_change={(v) => { lang = v }}/>        <p.demo-note>{lang == null ? "None picked." : `Picked ${lang}.`}</p>    </div>}let combobox_demo_cities = ["Amsterdam", "Athens", "Barcelona", "Berlin", "Dublin", "Kyoto", "Lisbon", "Lyon",                            "Madrid", "Porto", "Prague", "Rome", "Vienna"]// A search on the server, slowed a little so that "Searching…" shows.server fn combobox_demo_search(text: str) -> list with time {    sleep(300)    combobox_demo_cities | filter((c) => starts_with(lower(c), lower(trim(text))))}// Options found as one types: each search is numbered, and an answer to an older// one, come late, is dropped.component ComboboxDemoRemote() {    state found = []    state busy = false    state asked = 0    state city = null    <div.demo-stack>        <Combobox id="cb-city" name="city" label="City" placeholder="Two letters or more" remote={true} min_chars={2}                  loading={busy} options={found}                  on_input={(t) => {                      asked += 1                      let mine = asked                      busy = true                      let r = combobox_demo_search(t)                      if mine == asked {                          found = r                          busy = false                      }                  }}                  on_change={(v) => { city = v }}/>        <p.demo-note>{city == null ? "No city yet." : `Going to ${city}.`}</p>    </div>}view ComboboxDemo() =    <div.demo-stack.demo-narrow>        <Combobox id="cb-country" name="country" label="Country" value="PT" options={combobox_demo_countries}                  hint="Type to filter; the arrow keys and Enter pick." required={true}/>        <ComboboxDemoLive/>        <ComboboxDemoRemote/>        <Combobox id="cb-small" label="Small" size="sm" placeholder="Disabled" disabled={true}                  options={["One", "Two"]}/>    </div>

Source

ui/combobox.qwe
// Combobox: a text box that filters a list of options as it is typed, to pick one// from many (countries, time zones, people). It follows the ARIA combobox pattern:// the arrow keys move through the list, Enter picks, Escape closes (and, closed,// clears the text). The value picked goes in a hidden input named `name`, so a plain// <form> sends it.////   <Combobox id="country" name="country" label="Country" value="PT"//             options={[{ label: "Portugal", value: "PT" }, { label: "Spain", value: "ES" }]}/>//   <Combobox id="tz" label="Time zone" options={zones} on_change={(v) => { zone = v }}/>//// id: the text box's id, unique on the page; the list's ids start with it.// options: strings, or maps of { label, value }. value: the value picked to start with.// empty: what the list says when nothing matches ("No matches").// name, label, placeholder, hint, error, required, disabled, size ("sm" or "md") as// for every field. When the text box is left, text that names an option picks it;// other text goes back to the option picked, and no text picks none.// on_change(value): inside one of your components, called with the value picked// (null when it is cleared).//// Options found as one types (places from a map service, people from a server):// on_input(text) is called with the text once it has min_chars characters (1), and// the options you give back are shown as they come. remote: the options are already// the matches, so they are not filtered again here. loading: says "Searching…" while// they come.////   <Combobox id="where" label="Where" remote={true} min_chars={2} loading={busy} options={found}//             on_input={(t) => { busy = true; found = find_places(t); busy = false }}//             on_change={(v) => { place = v }}/>import ui/theme.qwe use ui_clslet combobox_css = css`.ui-combobox { position: relative; display: grid; gap: 6px; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-combobox-label { font-size: 13px; font-weight: 500; color: var(--ui-fg); }.ui-combobox-req { margin-left: 2px; color: var(--ui-danger); }.ui-combobox-hint { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-combobox-error { margin: 0; font-size: 13px; color: var(--ui-danger); }.ui-combobox-box { position: relative; display: flex; align-items: center; }.ui-combobox-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-combobox-control::placeholder { color: var(--ui-muted); opacity: 1; }.ui-combobox-control:hover { border-color: var(--ui-strong); }.ui-combobox-control:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-combobox-control[aria-invalid="true"] { border-color: var(--ui-danger); }.ui-combobox-control:disabled { opacity: .55; cursor: not-allowed; }.ui-combobox-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-combobox-toggle:disabled { cursor: not-allowed; opacity: .55; }.ui-combobox-toggle span {  width: 7px; height: 7px; margin-top: -4px; border: solid currentColor; border-width: 0 1.5px 1.5px 0;  transform: rotate(45deg); transition: transform .15s;}.ui-combobox-open .ui-combobox-toggle span { margin-top: 3px; transform: rotate(-135deg); }.ui-combobox-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-combobox-pop[hidden] { display: none; }.ui-combobox-list { max-height: 240px; overflow: auto; margin: 0; padding: 0; list-style: none; }.ui-combobox-opt {  display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 7px 10px;  border-radius: 4px; cursor: pointer; color: var(--ui-fg);}.ui-combobox-opt[aria-selected="true"] { background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-combobox-opt svg { flex: none; width: 14px; height: 14px; color: var(--ui-accent); }.ui-combobox-empty { padding: 7px 10px; color: var(--ui-muted); }.ui-combobox-sm .ui-combobox-control { height: 30px; font-size: 13px; padding-right: 30px; }.ui-combobox-sm .ui-combobox-toggle { width: 24px; height: 24px; right: 3px; }.ui-combobox-sm .ui-combobox-opt { padding: 5px 8px; font-size: 13px; }@media (prefers-reduced-motion: reduce) { .ui-combobox-control, .ui-combobox-toggle span { transition: none; } }`// Options as maps of { label, value }, from strings or such maps.fn combobox_options(options: list) -> list =    options | map((o) => type_of(o) == "map" ? { label: o.label ?? str(o.value), value: o.value ?? o.label } : { label: str(o), value: o })component Combobox(id: str, name: str? = null, label: str? = null, value = null, placeholder: str? = null,                   hint: str? = null, error: str? = null, required: bool = false, disabled: bool = false,                   size: str = "md", options: list = [], empty: str = "No matches", on_change = null,                   on_input = null, remote: bool = false, min_chars: int = 1, loading: bool = false,                   class: str? = null) {    style global combobox_css    let opts = combobox_options(options)    state chosen = value    state query = (find(combobox_options(options), (o) => o.value == value) ?? { label: "" }).label    // The label of the option picked, kept: remote options change as one types.    state chosen_label = query    state open = false    state typing = false    state active = 0    let needle = lower(trim(query))    let short = remote && typing && len(needle) < min_chars    let shown = short ? [] : remote || !typing || needle == "" ? opts : opts | filter((o) => contains(lower(o.label), needle))    // The options may change under the active one.    let act = active < len(shown) ? active : 0    let described = ui_cls([hint != null && `${id}-hint`, error != null && `${id}-error`])    let pick = (o) => {        chosen = o.value        chosen_label = o.label        query = o.label        typing = false        open = false        on_change && on_change(o.value)    }    let show = () => {        open = true        active = index_of(shown | map((o) => o.value), chosen) ?? 0    }    let reveal = (e, i: int) => {        let el = e.target.closest(".ui-combobox").querySelector(`[data-i="${i}"]`)        el && el.scrollIntoView({ block: "nearest" })    }    let leave = () => {        open = false        if typing {            typing = false            let same = find(opts, (o) => lower(o.label) == needle)            if needle == "" {                query = ""                chosen_label = ""                if chosen != null {                    chosen = null                    on_change && on_change(null)                }            } else if same != null {                if same.value != chosen { pick(same) } else { query = same.label }            } else {                query = chosen_label            }        }    }    <div class={ui_cls(["ui-combobox", size == "sm" && "ui-combobox-sm", open && "ui-combobox-open", class])}>        {label != null && <label.ui-combobox-label for={id}>            {label}{required && <span.ui-combobox-req aria-hidden="true">*</span>}        </label>}        <div.ui-combobox-box>            <input.ui-combobox-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-busy={loading ? "true" : null}                   aria-invalid={error != null ? "true" : null} aria-describedby={described != "" ? described : null}                   @input={(e) => {                       query = e.value                       typing = true                       open = true                       active = 0                       if on_input != null && len(trim(e.value)) >= min_chars { on_input(e.value) }                   }}                   @click={() => { if !open { show() } }}                   @blur={leave}                   @keydown={(e) => {                       let n = len(shown)                       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 == "Enter" && open {                           e.preventDefault()                           if n > 0 { pick(shown[act]) }                       }                       if e.key == "Escape" {                           if open {                               e.preventDefault()                               open = false                           } else if query != "" {                               e.preventDefault()                               query = ""                               typing = false                               if chosen != null {                                   chosen = null                                   on_change && on_change(null)                               }                           }                       }                       if e.key == "Tab" && open && typing && n > 0 && needle != "" { pick(shown[act]) }                   }}/>            <button.ui-combobox-toggle type="button" tabindex="-1" aria-label={open ? "Hide options" : "Show options"}                    disabled={disabled}                    @mousedown={(e) => { e.preventDefault() }}                    @click={(e) => {                        e.target.closest(".ui-combobox").querySelector("input").focus()                        if open { open = false } else { show() }                    }}><span aria-hidden="true"></span></button>            <div.ui-combobox-pop hidden={!open || short}>                <ul.ui-combobox-list id={`${id}-list`} role="listbox" aria-label={label}>                    {for [i, o] in enumerate(shown) => <li.ui-combobox-opt key={o.value} id={`${id}-opt-${i}`} data-i={i}                            role="option" aria-selected={i == act ? "true" : "false"}                            @mousedown={(e) => { e.preventDefault() }} @click={() => { pick(o) }}>                        <span>{o.label}</span>                        {o.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-combobox-empty role="status">{loading ? "Searching…" : empty}</div> : null}            </div>        </div>        {name != null && <input type="hidden" name={name} value={chosen ?? ""}/>}        {hint != null && <p.ui-combobox-hint id={`${id}-hint`}>{hint}</p>}        {error != null && <p.ui-combobox-error id={`${id}-error`} role="alert">{error}</p>}    </div>}
Thread
56Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.