MultiSelect
Several choices from a searchable drop-down of checkboxes, shown as chips that come off with ×.
Demo
EnglishPortuguese
- Select all
- Arabic
- Chinese
- Dutch
- English
- French
- German
- Hindi
- Italian
- Japanese
- Korean
- Polish
- Portuguese
- Spanish
- Swahili
- Turkish
Type to filter; the arrow keys and Enter tick.
Grace
- Ada
- Alan
- Barbara
- Edsger
- Grace
- Ken
- Linus
- Margaret
Review by Grace.
- Mon
- Tue
- Wed
- Thu
- Fri
Choose at least one day.
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.qwemain.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>}Related
BirthdayInputDay, month and year drop-downs that give one date in a hidden input, the days following the month and the years the age allowed.5 views · 7 downloadsCountryPickerA drop-down of every country with its flag and ISO code, favourites first, sending the two-letter code with a form.5 views · 7 downloadsFontPickerA drop-down of fonts, each option drawn in itself, with a preview line in the one chosen.5 views · 7 downloadsLanguagePickerA drop-down of languages each written in its own name, sending the ISO 639-1 code with a form.5 views · 5 downloadsTimezonePickerA drop-down of time zones grouped by region, each with its UTC offset for today, sending the IANA name with a form.5 views · 5 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 downloads
Thread
Thread 0 posts
On MultiSelect: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.