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.
Demo
- Argentina
- Australia
- Austria
- Belgium
- Brazil
- Canada
- Chile
- Denmark
- Finland
- France
- Germany
- Greece
- Iceland
- India
- Ireland
- Italy
- Japan
- Mexico
- Netherlands
- New Zealand
- Norway
- Portugal
- South Korea
- Spain
- Sweden
- Switzerland
- United Kingdom
- United States
Type to filter; the arrow keys and Enter pick.
- C
- Elixir
- Go
- Haskell
- JavaScript
- Python
- Quillo
- Ruby
- Rust
- Zig
None picked.
No matches
No city yet.
- One
- Two
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.qwemain.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>}Related
AutocompleteA free-text box that offers matches as one types, with the arrow keys and Enter; whatever is typed may stay.5 views · 7 downloadsCheckboxCardsAny number of choices as cards with a title, a line of detail and a check, each a real checkbox a form sends.5 views · 6 downloadsDependentSelectTwo drop-downs where the second's options depend on the first, both real selects a form sends.5 views · 7 downloadsPhoneInputA country code select and a number box, sent together in international form (+351912345678) with the country's ISO code.6 views · 6 downloadsRadioCardsOne choice of several as cards with a title, a line of detail and a dot, each a real radio a form sends.5 views · 7 downloadsRadioGroupOne choice of several: real radio buttons in a fieldset, in a column, a row or as cards.5 views · 6 downloads
Thread
Thread 0 posts
On Combobox: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.