Forms

Autocomplete

A free-text box that offers matches as one types, with the arrow keys and Enter; whatever is typed may stay.

#forms#input#search#keyboard#interactive

Demo

Preview

Pick one, or type a city that is not on the list.

Type a tag; any text is kept.

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

Example

The code of the preview above.

demos/autocomplete.qwe
let autocomplete_demo_cities = ["Amsterdam", "Athens", "Auckland", "Barcelona", "Berlin", "Bordeaux", "Dublin", "Kyoto", "Lisbon",                                "London", "Los Angeles", "Lyon", "Madrid", "New York", "Porto", "Prague", "Rome", "Vienna"]component AutocompleteDemoLive() {    state picked = null    state text = ""    <div.demo-stack>        <Autocomplete name="ac-demo-tag" label="Tag" mode="any" limit={4}                      options={["design", "databases", "devops", "documentation", "testing", "type-systems", "web"]}                      on_change={(t) => { text = t }} on_select={(t) => { picked = t }}/>        <p.demo-note>{text == "" ? "Type a tag; any text is kept." : `Text: ${text}${picked != null ? ` · last picked: ${picked}` : ""}`}</p>    </div>}view AutocompleteDemo() {    <div.demo-stack.demo-narrow>        <Autocomplete name="ac-demo-city" label="City" options={autocomplete_demo_cities} placeholder="Where to?"                      hint="Pick one, or type a city that is not on the list."/>        <AutocompleteDemoLive/>        <Autocomplete name="ac-demo-sm" label="Small" size="sm" min_chars={2} options={autocomplete_demo_cities} error="Tell us a city."/>    </div>}

Source

ui/autocomplete.qwe
// Autocomplete: a text box that offers matches from a list as one types (a city,// a tag, a product), where any text may stay: picking a suggestion is a shortcut,// not a must. The box is a real <input> named `name`, so a plain <form> sends// what is in it. The arrow keys move through the suggestions, Enter takes one,// Escape closes them.////   <Autocomplete name="city" label="City" options={["Lisbon", "London", "Lyon"]}/>//   <Autocomplete name="tag" label="Tag" options={tags} min_chars={2} limit={5} on_change={(t) => { tag = t }}/>//// options: the words offered, strings. min_chars: letters typed before anything// is offered (1). limit: at most this many suggestions (8). mode: "start" (the// suggestion begins with the text; the default) or "any" (it has the text anywhere).// value, placeholder, autocomplete (the browser's own, "off"), name, label, hint,// error, required, disabled, field (the box's id, "autocomplete-" + name by// default) and size ("sm" or "md") as for every field.// on_change(text): inside one of your components, given the box's text as it is// typed, and the suggestion when one is taken. on_select(text): given only the// suggestion taken.import ui/theme.qwe use ui_clslet autocomplete_css = css`.ui-autocomplete { position: relative; display: grid; gap: 6px; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-autocomplete-label { font-size: 13px; font-weight: 500; }.ui-autocomplete-req { margin-left: 2px; color: var(--ui-danger); }.ui-autocomplete-box { position: relative; }.ui-autocomplete-box input {  box-sizing: border-box; width: 100%; height: 38px; margin: 0; padding: 0 12px; border: 1px solid var(--ui-border);  border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px var(--ui-font);  transition: border-color .15s, box-shadow .15s;}.ui-autocomplete-box input::placeholder { color: var(--ui-muted); opacity: 1; }.ui-autocomplete-box input:hover { border-color: var(--ui-strong); }.ui-autocomplete-box input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-autocomplete-box input:disabled { opacity: .55; cursor: not-allowed; }.ui-autocomplete-pop {  position: absolute; z-index: 20; top: 100%; left: 0; right: 0; margin: 4px 0 0; padding: 4px; list-style: none; max-height: 240px; overflow: auto;  border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); box-shadow: var(--ui-shadow);}.ui-autocomplete-pop[hidden] { display: none; }.ui-autocomplete-opt { padding: 7px 10px; border-radius: 4px; cursor: pointer; }.ui-autocomplete-opt[aria-selected="true"] { background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-autocomplete-opt mark { background: none; color: inherit; font-weight: 600; }.ui-autocomplete-sm .ui-autocomplete-box input { height: 30px; padding: 0 10px; font-size: 13px; }.ui-autocomplete-sm .ui-autocomplete-opt { padding: 5px 8px; font-size: 13px; }.ui-autocomplete-invalid .ui-autocomplete-box input { border-color: var(--ui-danger); }.ui-autocomplete-hint, .ui-autocomplete-error { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-autocomplete-error { color: var(--ui-danger); }@media (prefers-reduced-motion: reduce) { .ui-autocomplete-box input { transition: none; } }`// The options matching `text`, at most `limit`, those that start with it first.fn autocomplete_matches(options: list, text: str, mode: str, limit: int) -> list {    let needle = lower(trim(text))    if needle == "" { return [] }    let starts = options | filter((o) => starts_with(lower(o), needle))    let within = mode == "any" ? options | filter((o) => !starts_with(lower(o), needle) && contains(lower(o), needle)) : []    take(starts + within, limit)}component Autocomplete(name: str? = null, label: str? = null, value: str = "", options: list = [], min_chars: int = 1,                       limit: int = 8, mode: str = "start", placeholder: str? = null, autocomplete: str = "off",                       hint: str? = null, error: str? = null, required: bool = false, disabled: bool = false,                       field: str? = null, size: str = "md", on_change = null, on_select = null, class: str? = null) {    style global autocomplete_css    state text = value    state open = false    state active = 0    let id = field ?? `autocomplete-${name ?? re.replace(lower(label ?? "field"), "[^a-z0-9]+", "-")}`    let shown = len(trim(text)) >= min_chars ? autocomplete_matches(options, text, mode, limit) : []    let listed = open && len(shown) > 0    let act = active < len(shown) ? active : 0    let described = ui_cls([hint != null && `${id}-hint`, error != null && `${id}-error`])    let needle = lower(trim(text))    let pick = (o: str) => {        text = o        open = false        on_change && on_change(o)        on_select && on_select(o)    }    <div class={ui_cls(["ui-autocomplete", size == "sm" && "ui-autocomplete-sm", error != null && "ui-autocomplete-invalid", class])}>        {label != null && <label.ui-autocomplete-label for={id}>            {label}{required && <span.ui-autocomplete-req aria-hidden="true">*</span>}        </label>}        <div.ui-autocomplete-box>            <input type="text" id={id} name={name} value={text} placeholder={placeholder} autocomplete={autocomplete}                   required={required} disabled={disabled} role="combobox" aria-autocomplete="list"                   aria-expanded={listed ? "true" : "false"} aria-controls={`${id}-list`}                   aria-activedescendant={listed ? `${id}-opt-${act}` : null}                   aria-invalid={error != null ? "true" : null} aria-describedby={described != "" ? described : null}                   @input={(e) => {                       text = e.value                       open = true                       active = 0                       on_change && on_change(e.value)                   }}                   @focus={() => { open = true }}                   @blur={() => { open = false }}                   @keydown={(e) => {                       let n = len(shown)                       if (e.key == "ArrowDown" || e.key == "ArrowUp") && n > 0 {                           e.preventDefault()                           if !open { open = true } else { active = e.key == "ArrowDown" ? (act + 1) % n : (act + n - 1) % n }                       }                       if e.key == "Enter" && listed {                           e.preventDefault()                           pick(shown[act])                       }                       if e.key == "Escape" && open {                           e.preventDefault()                           open = false                       }                   }}/>            <ul.ui-autocomplete-pop id={`${id}-list`} role="listbox" aria-label={label ?? "Suggestions"} hidden={!listed}>                {for [i, o] in enumerate(shown) => <li.ui-autocomplete-opt key={o} id={`${id}-opt-${i}`} role="option"                        aria-selected={i == act ? "true" : "false"}                        @mousedown={(e) => { e.preventDefault() }} @click={() => { pick(o) }}>                    {starts_with(lower(o), needle)                        ? [<mark>{slice(o, 0, len(needle))}</mark>, slice(o, len(needle))]                        : o}                </li>}            </ul>        </div>        {hint != null && <p.ui-autocomplete-hint id={`${id}-hint`}>{hint}</p>}        {error != null && <p.ui-autocomplete-error id={`${id}-error`} role="alert">{error}</p>}    </div>}
Thread
57Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.