Forms

MultiSelect

Several choices from a searchable drop-down of checkboxes, shown as chips that come off with ×.

#forms#select#picker#interactive

Demo

Preview
EnglishPortuguese
2 chosen

Type to filter; the arrow keys and Enter tick.

Grace
1 chosen, up to 2

Review by Grace.

None chosen

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

Example

The code of the preview above.

demos/multi_select.qwe
let multi_select_demo_langs = [    { label: "Arabic", value: "ar" }, { label: "Chinese", value: "zh" }, { label: "Dutch", value: "nl" },    { label: "English", value: "en" }, { label: "French", value: "fr" }, { label: "German", value: "de" },    { label: "Hindi", value: "hi" }, { label: "Italian", value: "it" }, { label: "Japanese", value: "ja" },    { label: "Korean", value: "ko" }, { label: "Polish", value: "pl" }, { label: "Portuguese", value: "pt" },    { label: "Spanish", value: "es" }, { label: "Swahili", value: "sw" }, { label: "Turkish", value: "tr" },]component MultiSelectDemoLive() {    state who = ["Grace"]    <div.demo-stack>        <MultiSelect id="ms-demo-reviewers" name="reviewers" label="Reviewers (up to 2)" max={2} values={["Grace"]}                     options={["Ada", "Alan", "Barbara", "Edsger", "Grace", "Ken", "Linus", "Margaret"]}                     on_change={(vs) => { who = vs }}/>        <p.demo-note>{len(who) == 0 ? "No reviewers." : `Review by ${join(who, " and ")}.`}</p>    </div>}view MultiSelectDemo() =    <div.demo-stack.demo-narrow>        <MultiSelect id="ms-demo-langs" name="langs" label="Languages spoken" values={["en", "pt"]}                     options={multi_select_demo_langs} placeholder="Search languages"                     hint="Type to filter; the arrow keys and Enter tick."/>        <MultiSelectDemoLive/>        <MultiSelect id="ms-demo-days" name="days" label="Days" size="sm" select_all={false}                     options={["Mon", "Tue", "Wed", "Thu", "Fri"]} error="Choose at least one day."/>    </div>

Source

ui/multi_select.qwe
// MultiSelect: several choices from a list, picked in a drop-down of checkboxes// that a search box filters, and shown as chips that each come off with their ×.// It follows the ARIA combobox pattern with a multi-select list: the arrow keys// move, Enter ticks or unticks, Escape closes (and, closed, clears the search),// Backspace in the empty search takes the last chip off. "Select all" ticks every// option shown. A plain <form> gets one `name` per value chosen.////   <MultiSelect id="langs" name="langs" label="Languages" values={["en"]}//                options={[{ label: "English", value: "en" }, { label: "French", value: "fr" }]}/>//   <MultiSelect id="team" label="Reviewers" options={people} on_change={(vs) => { reviewers = vs }}/>//// id: the search box's id, unique on the page; the list's ids start with it.// options: strings, or maps of { label, value }. values: the values chosen to start.// placeholder: the search box's text while nothing is chosen ("Choose…").// select_all: offer "Select all" (true). empty: what the list says when nothing// matches ("No matches"). max: at most this many can be chosen.// name, label, hint, error, disabled and size ("sm" or "md") as for every field.// on_change(values): inside one of your components, given the list of values chosen.import ui/theme.qwe use ui_clslet multi_select_css = css`.ui-multi-select { position: relative; display: grid; gap: 6px; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-multi-select-label { font-size: 13px; font-weight: 500; color: var(--ui-fg); }.ui-multi-select-box {  display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-height: 38px; padding: 4px 36px 4px 6px;  border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); cursor: text;  transition: border-color .15s, box-shadow .15s;}.ui-multi-select-box:hover { border-color: var(--ui-strong); }.ui-multi-select-box:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-multi-select-invalid .ui-multi-select-box { border-color: var(--ui-danger); }.ui-multi-select-chip {  display: inline-flex; align-items: center; gap: 2px; max-width: 100%; height: 26px; padding: 0 2px 0 9px; border-radius: 4px;  background: var(--ui-accent-soft); color: var(--ui-accent); font: 500 13px/1 var(--ui-font);}.ui-multi-select-chip > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-multi-select-x {  display: inline-grid; place-items: center; flex: none; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 3px;  background: transparent; color: inherit; cursor: pointer; opacity: .75;}.ui-multi-select-x svg { width: 10px; height: 10px; }.ui-multi-select-x:hover { opacity: 1; background: color-mix(in srgb, var(--ui-accent) 18%, transparent); }.ui-multi-select-x:focus-visible { outline: none; opacity: 1; box-shadow: var(--ui-focus); }.ui-multi-select-search {  flex: 1 0 90px; min-width: 0; height: 28px; margin: 0; padding: 0 6px; border: 0; outline: none; background: transparent;  color: var(--ui-fg); font: 14px/1 var(--ui-font);}.ui-multi-select-search::placeholder { color: var(--ui-muted); opacity: 1; }.ui-multi-select-toggle {  position: absolute; right: 4px; top: 4px; display: inline-grid; place-items: center; width: 30px; height: 30px;  padding: 0; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-muted); cursor: pointer;}.ui-multi-select-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-multi-select-open .ui-multi-select-toggle span { margin-top: 3px; transform: rotate(-135deg); }.ui-multi-select-field { position: relative; }.ui-multi-select-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-multi-select-pop[hidden] { display: none; }.ui-multi-select-list { max-height: 260px; overflow: auto; margin: 0; padding: 0; list-style: none; }.ui-multi-select-opt { display: flex; align-items: center; gap: 10px; padding: 7px 10px; border-radius: 4px; cursor: pointer; }.ui-multi-select-opt:hover, .ui-multi-select-active { background: var(--ui-subtle); }.ui-multi-select-active { box-shadow: inset 0 0 0 1px var(--ui-strong); }.ui-multi-select-all { border-bottom: 1px solid var(--ui-border); border-radius: 4px 4px 0 0; margin-bottom: 2px; font-weight: 500; }.ui-multi-select-tick {  flex: none; position: relative; width: 16px; height: 16px; border: 1px solid var(--ui-strong); border-radius: 4px; background: var(--ui-surface);}.ui-multi-select-opt[aria-selected="true"] .ui-multi-select-tick { background: var(--ui-accent); border-color: var(--ui-accent); }.ui-multi-select-opt[aria-selected="true"] .ui-multi-select-tick::after {  content: ""; position: absolute; left: 4.5px; top: 1px; width: 4px; height: 9px;  border: solid var(--ui-accent-fg); border-width: 0 2px 2px 0; transform: rotate(45deg);}.ui-multi-select-some .ui-multi-select-tick { background: var(--ui-accent); border-color: var(--ui-accent); }.ui-multi-select-some .ui-multi-select-tick::after {  content: ""; position: absolute; left: 3px; top: 6.5px; width: 8px; border-top: 2px solid var(--ui-accent-fg);}.ui-multi-select-opt[aria-disabled="true"] { opacity: .5; cursor: not-allowed; }.ui-multi-select-empty { padding: 7px 10px; color: var(--ui-muted); }.ui-multi-select-hint, .ui-multi-select-error { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-multi-select-error { color: var(--ui-danger); }.ui-multi-select-sm .ui-multi-select-box { min-height: 30px; padding: 2px 30px 2px 4px; gap: 4px; }.ui-multi-select-sm .ui-multi-select-chip { height: 22px; font-size: 12px; }.ui-multi-select-sm .ui-multi-select-search { height: 24px; font-size: 13px; }.ui-multi-select-sm .ui-multi-select-toggle { top: 1px; width: 26px; height: 26px; }.ui-multi-select-sm .ui-multi-select-opt { padding: 5px 8px; font-size: 13px; }.ui-multi-select-disabled .ui-multi-select-box { opacity: .55; cursor: not-allowed; }@media (prefers-reduced-motion: reduce) { .ui-multi-select-box, .ui-multi-select-toggle span { transition: none; } }`fn multi_select_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 MultiSelect(id: str, name: str? = null, label: str? = null, options: list = [], values: list = [],                      placeholder: str = "Choose…", select_all: bool = true, empty: str = "No matches",                      max: int? = null, hint: str? = null, error: str? = null, disabled: bool = false,                      size: str = "md", on_change = null, class: str? = null) {    style global multi_select_css    let opts = multi_select_options(options)    state chosen = values    state query = ""    state open = false    state active = 0    state said = ""    let needle = lower(trim(query))    let shown = needle != "" ? opts | filter((o) => contains(lower(o.label), needle)) : opts    let with_all = select_all && max == null && len(shown) > 1    let skip = with_all ? 1 : 0    let rows = len(shown) + skip    let shown_on = shown | filter((o) => contains(chosen, o.value))    let all_on = len(shown) > 0 && len(shown_on) == len(shown)    let full = max != null && len(chosen) >= max    let picked = opts | filter((o) => contains(chosen, o.value))    let described = ui_cls([`${id}-count`, hint != null && `${id}-hint`, error != null && `${id}-error`])    let set_to = (next: list, note: str) => {        // Kept in the options' order, whatever order they were ticked in.        let ordered = opts | filter((o) => contains(next, o.value)) | map((o) => o.value)        if ordered != chosen {            chosen = ordered            said = note            on_change && on_change(ordered)        }    }    let toggle = (o) => {        if contains(chosen, o.value) {            set_to(chosen | filter((v) => v != o.value), `${o.label} removed`)        } else if !full {            set_to(chosen + [o.value], `${o.label} added`)        }    }    let toggle_all = () => {        let vs = shown | map((o) => o.value)        if all_on {            set_to(chosen | filter((v) => !contains(vs, v)), "All shown removed")        } else {            set_to(chosen + vs, `All ${len(vs)} shown chosen`)        }    }    let pick_row = (i: int) => { if with_all && i == 0 { toggle_all() } else if i - skip < len(shown) { toggle(shown[i - skip]) } }    let reveal = (e, i: int) => {        let el = e.target.closest(".ui-multi-select").querySelector(`[data-i="${i}"]`)        el && el.scrollIntoView({ block: "nearest" })    }    let search = (e) => e.target.closest(".ui-multi-select").querySelector(".ui-multi-select-search")    <div class={ui_cls(["ui-multi-select", size == "sm" && "ui-multi-select-sm", open && "ui-multi-select-open",                        error != null && "ui-multi-select-invalid", disabled && "ui-multi-select-disabled", class])}         @focusout={(e) => {             let root = e.target.closest(".ui-multi-select")             if open && root != null && !(e.relatedTarget != null && root.contains(e.relatedTarget)) { open = false }         }}>        {label != null && <label.ui-multi-select-label for={id}>{label}</label>}        <div.ui-multi-select-field>            <div.ui-multi-select-box @click={(e) => {                if e.target.classList.contains("ui-multi-select-box") { search(e).focus() }            }}>                {for o in picked => <span.ui-multi-select-chip key={o.value}>                    <span>{o.label}</span>                    <button.ui-multi-select-x type="button" aria-label={`Remove ${o.label}`} disabled={disabled}                            @click={(e) => {                                search(e).focus()                                toggle(o)                            }}>                        <svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"                             aria-hidden="true"><path d="M2 2l6 6M8 2L2 8"/></svg>                    </button>                </span>}                <input.ui-multi-select-search type="text" id={id} role="combobox" value={query} disabled={disabled}                       placeholder={len(chosen) == 0 ? placeholder : null} autocomplete="off" spellcheck="false"                       aria-autocomplete="list" aria-expanded={open ? "true" : "false"} aria-controls={`${id}-list`}                       aria-activedescendant={open && rows > 0 ? `${id}-opt-${active}` : null}                       aria-invalid={error != null ? "true" : null} aria-describedby={described}                       @input={(e) => {                           query = e.value                           open = true                           active = 0                       }}                       @click={() => { open = true }}                       @keydown={(e) => {                           if e.key == "ArrowDown" || e.key == "ArrowUp" {                               e.preventDefault()                               if !open {                                   open = true                                   active = 0                               } else if rows > 0 {                                   active = e.key == "ArrowDown" ? (active + 1) % rows : (active + rows - 1) % rows                                   reveal(e, active)                               }                           }                           if (e.key == "Home" || e.key == "End") && open && rows > 0 {                               e.preventDefault()                               active = e.key == "Home" ? 0 : rows - 1                               reveal(e, active)                           }                           if e.key == "Enter" {                               e.preventDefault()                               if open && rows > 0 { pick_row(active) } else { open = true }                           }                           if e.key == "Escape" {                               if open {                                   e.preventDefault()                                   open = false                               } else if query != "" {                                   e.preventDefault()                                   query = ""                               }                           }                           if e.key == "Backspace" && query == "" && len(chosen) > 0 {                               let gone = find(opts, (o) => o.value == last(chosen))                               set_to(take(chosen, len(chosen) - 1), `${gone.label} removed`)                           }                           if e.key == "Tab" { open = false }                       }}/>            </div>            <button.ui-multi-select-toggle type="button" tabindex="-1" aria-label={open ? "Hide options" : "Show options"}                    disabled={disabled} @mousedown={(e) => { e.preventDefault() }}                    @click={(e) => {                        search(e).focus()                        open = !open                    }}><span aria-hidden="true"></span></button>            <div.ui-multi-select-pop hidden={!open}>                <ul.ui-multi-select-list id={`${id}-list`} role="listbox" aria-multiselectable="true" aria-label={label}>                    {with_all && <li class={ui_cls(["ui-multi-select-opt", "ui-multi-select-all", active == 0 && "ui-multi-select-active",                                                    !all_on && len(shown_on) > 0 && "ui-multi-select-some"])}                                     id={`${id}-opt-0`} data-i="0" role="option" aria-selected={all_on ? "true" : "false"}                                     @mousedown={(e) => { e.preventDefault() }}                                     @click={() => {                                         active = 0                                         toggle_all()                                     }}>                        <span.ui-multi-select-tick aria-hidden="true"></span>                        <span>{needle != "" ? `Select all ${len(shown)} shown` : "Select all"}</span>                    </li>}                    {for [i, o] in enumerate(shown) => <li key={o.value} id={`${id}-opt-${i + skip}`} data-i={i + skip}                            class={ui_cls(["ui-multi-select-opt", active == i + skip && "ui-multi-select-active"])}                            role="option" aria-selected={contains(chosen, o.value) ? "true" : "false"}                            aria-disabled={full && !contains(chosen, o.value) ? "true" : null}                            @mousedown={(e) => { e.preventDefault() }}                            @click={() => {                                active = i + skip                                toggle(o)                            }}>                        <span.ui-multi-select-tick aria-hidden="true"></span>                        <span>{o.label}</span>                    </li>}                </ul>                {len(shown) == 0 && <div.ui-multi-select-empty role="status">{empty}</div>}            </div>        </div>        {for v in (name != null ? chosen : []) => <input type="hidden" name={name} value={v}/>}        <span.ui-sr id={`${id}-count`}>{len(chosen) == 0 ? "None chosen" : `${len(chosen)} chosen`}{max != null ? `, up to ${max}` : ""}</span>        <span.ui-sr aria-live="polite">{said}</span>        {hint != null && <p.ui-multi-select-hint id={`${id}-hint`}>{hint}</p>}        {error != null && <p.ui-multi-select-error id={`${id}-error`} role="alert">{error}</p>}    </div>}
Thread
57Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.