FilterBar
A search box with filter menus and chips, counts, removable pills and Clear all, sent as a GET form.
Demo
8 of 8: Trail runner, Rain shell, Wool beanie, Down parka, Road racer, Sun cap, Fleece vest, Approach shoe
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/filter_bar.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/filter_bar.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/filter_bar.qwe
let filter_bar_demo_issue_facets = [ { name: "status", label: "Status", options: [ { label: "Open", value: "open", count: 128 }, { label: "In review", value: "review", count: 14 }, { label: "Closed", value: "closed", count: 1022 }] }, { name: "label", label: "Label", options: [ { label: "Bug", value: "bug", count: 61 }, { label: "Feature", value: "feature", count: 38 }, { label: "Docs", value: "docs", count: 17 }, { label: "Good first issue", value: "first", count: 9 }] }, { name: "sort", label: "Sort", multiple: false, options: ["Newest", "Oldest", "Most comments"] }, { name: "priority", label: "Priority", display: "chips", options: [ { label: "High", value: "high", count: 7 }, { label: "Low", value: "low", count: 40 }] },]let filter_bar_demo_products = [ { name: "Trail runner", kind: "Shoes", price: 120 }, { name: "Rain shell", kind: "Jackets", price: 180 }, { name: "Wool beanie", kind: "Hats", price: 25 }, { name: "Down parka", kind: "Jackets", price: 320 }, { name: "Road racer", kind: "Shoes", price: 160 }, { name: "Sun cap", kind: "Hats", price: 30 }, { name: "Fleece vest", kind: "Jackets", price: 90 }, { name: "Approach shoe", kind: "Shoes", price: 140 },]fn filter_bar_demo_keep(p: map, query: str, values: map) -> bool { let kinds = get(values, "kind") ?? [] let price = first(get(values, "price") ?? []) contains(lower(p.name), lower(trim(query))) && (len(kinds) == 0 || contains(kinds, p.kind)) && (price == null || (price == "under-100" ? p.price < 100 : p.price >= 100))}component FilterBarDemoLive() { state shown = filter_bar_demo_products <div.demo-stack> <FilterBar id="fb-shop" label="Product filters" placeholder="Search products" facets={[ { name: "kind", label: "Kind", display: "chips", options: ["Shoes", "Jackets", "Hats"] }, { name: "price", label: "Price", multiple: false, options: [{ label: "Under $100", value: "under-100" }, { label: "$100 and up", value: "100-up" }] }, ]} on_change={(f) => { shown = filter_bar_demo_products | filter((p) => filter_bar_demo_keep(p, f.query, f.values)) }}/> <p.demo-note>{len(shown)} of {len(filter_bar_demo_products)}: {join(shown | map((p) => p.name), ", ")}</p> </div>}view FilterBarDemo() = <div.demo-stack> <FilterBar id="fb-issues" label="Issue filters" placeholder="Search issues" query="crash" values={{ status: ["open", "review"], sort: "Newest" }} facets={filter_bar_demo_issue_facets}/> <FilterBarDemoLive/> </div>Source
ui/filter_bar.qwe
// FilterBar: a search box with filters beside it (each a menu of choices, or a row of// chips, with how many each finds), the filters in force shown as pills that a click// removes, and "Clear all". It is a <form method="get">: placed in a page it sends// the search and each filter as query parameters (?q=crash&status=open&status=closed),// at once when a chip is clicked or a menu closes (auto), or with its Apply button.// Inside one of your components, on_change hears every change and nothing is sent.//// route GET /issues { query: { q: str = "", status: list = [] } } =// <FilterBar query={q} values={{ status }} facets={[// { name: "status", label: "Status", options: [// { label: "Open", value: "open", count: 12 }, { label: "Closed", value: "closed", count: 40 }] },// { name: "kind", label: "Kind", display: "chips", options: ["Bug", "Feature"] },// { name: "sort", label: "Sort", multiple: false, options: ["Newest", "Oldest"] },// ]}/>//// facets: a list of { name, label, options, multiple?, display? }: name is the query// parameter; options are strings or { label, value, count? } (count shown beside it);// multiple (true) takes several values, false one (with "Any", sent empty); display "menu" (the// default) or "chips". values: what is chosen to start with, by name: { status:// ["open"], sort: "Newest" }. query: the search text to start with; name: its// parameter ("q"); placeholder ("Search…"); label: the bar's name ("Filters").// action: where the form goes (this page). auto (true): a change is sent at once; with// false, the Apply button sends them (apply: its text, "Apply").// id: the start of its parts' ids ("filters"), unique on the page.// on_change(filters): inside one of your components, called with { query, values }// (values by name, each a list of values) whenever the text or a filter changes.import ui/theme.qwe use ui_clslet filter_bar_css = css`.ui-fb { display: grid; gap: 10px; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-fb-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }.ui-fb-search { position: relative; display: flex; flex: 1 1 220px; align-items: center; min-width: 0; }.ui-fb-search svg { position: absolute; left: 11px; width: 16px; height: 16px; color: var(--ui-muted); pointer-events: none; }.ui-fb-search input { box-sizing: border-box; width: 100%; height: 36px; margin: 0; padding: 0 12px 0 34px; 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); appearance: none;}.ui-fb-search input::placeholder { color: var(--ui-muted); opacity: 1; }.ui-fb-search input:hover { border-color: var(--ui-strong); }.ui-fb-search input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-fb-btn { display: inline-flex; align-items: center; gap: 6px; height: 36px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 14px/1 var(--ui-font); white-space: nowrap; cursor: pointer;}.ui-fb-btn:hover { border-color: var(--ui-strong); background: var(--ui-subtle); }.ui-fb-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-fb-btn svg { width: 14px; height: 14px; color: var(--ui-muted); }.ui-fb-on { border-color: var(--ui-accent); color: var(--ui-accent); }.ui-fb-n { min-width: 18px; padding: 2px 5px; border-radius: 999px; background: var(--ui-accent); color: var(--ui-accent-fg); font-size: 11px; text-align: center; }.ui-fb-apply { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-fb-apply:hover { border-color: var(--ui-accent); background: color-mix(in srgb, var(--ui-accent) 88%, var(--ui-fg)); }.ui-fb-menu { min-width: 200px; max-width: min(300px, calc(100vw - 16px)); max-height: min(360px, calc(100dvh - 16px)); margin: auto; padding: 6px; overflow: auto; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow-lg); font: 14px/1.4 var(--ui-font);}.ui-fb-menu fieldset { display: grid; gap: 1px; margin: 0; padding: 0; border: 0; }.ui-fb-opt { display: flex; align-items: center; gap: 9px; padding: 7px 8px; border-radius: var(--ui-radius-sm); cursor: pointer; }.ui-fb-opt:hover { background: var(--ui-subtle); }.ui-fb-opt:has(input:focus-visible) { box-shadow: var(--ui-focus); }.ui-fb-opt input { width: 16px; height: 16px; margin: 0; accent-color: var(--ui-accent); }.ui-fb-opt span:first-of-type { flex: 1; }.ui-fb-count { color: var(--ui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }@supports (position-area: bottom) { .ui-fb-menu { inset: auto; margin: 4px 0; position-area: bottom span-right; position-try-fallbacks: flip-block, flip-inline; }}@supports not (position-area: bottom) { .ui-fb-menu::backdrop { background: var(--ui-overlay); } }.ui-fb-chips { display: inline-flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; border: 0; }.ui-fb-chips legend { float: left; margin-right: 2px; padding: 0; color: var(--ui-muted); font-size: 13px; line-height: 30px; }.ui-fb-chip { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 11px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface); color: var(--ui-fg); font-size: 13px; font-weight: 500; cursor: pointer;}.ui-fb-chip:hover { border-color: var(--ui-strong); }.ui-fb-chip:has(input:checked) { border-color: var(--ui-accent); background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-fb-chip:has(input:focus-visible) { box-shadow: var(--ui-focus); }.ui-fb-chip .ui-fb-count { color: inherit; opacity: .75; }.ui-fb-pills { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0; padding: 0; list-style: none; }.ui-fb-pill { display: inline-flex; align-items: center; gap: 4px; height: 26px; padding: 0 4px 0 10px; border-radius: 999px; background: var(--ui-subtle); color: var(--ui-fg); font-size: 13px;}.ui-fb-pill b { font-weight: 500; color: var(--ui-muted); }.ui-fb-pill button { display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--ui-muted); cursor: pointer;}.ui-fb-pill button:hover { background: var(--ui-strong); color: var(--ui-fg); }.ui-fb-pill button:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-fb-pill svg { width: 12px; height: 12px; }.ui-fb-clear { height: 26px; padding: 0 8px; border: 0; border-radius: var(--ui-radius-sm); background: none; color: var(--ui-accent); font: 500 13px/1 var(--ui-font); cursor: pointer; }.ui-fb-clear:hover { text-decoration: underline; }.ui-fb-clear:focus-visible { outline: none; box-shadow: var(--ui-focus); }`// A facet's options as maps of { label, value, count }, the values as text.fn filter_bar_options(f: map) -> list = (get(f, "options") ?? []) | map((o) => type_of(o) == "map" ? { label: o.label ?? str(o.value), value: str(o.value ?? o.label), count: get(o, "count") } : { label: str(o), value: str(o), count: null })// What is chosen of each facet, as lists of text: values may give one or a list.fn filter_bar_values(facets: list, values: map) -> map = reduce(facets, {}, (m, f) => { let v = get(values, f.name) set(m, f.name, v == null ? [] : (type_of(v) == "list" ? v | map((x) => str(x)) : [str(v)])) })// The label of a facet's value.fn filter_bar_label(f: map, v: str) -> str = (find(filter_bar_options(f), (o) => o.value == v) ?? { label: v }).labelcomponent FilterBar(facets: list = [], values: map = {}, query: str = "", name: str = "q", label: str = "Filters", placeholder: str = "Search…", action: str? = null, auto: bool = true, apply: str = "Apply", id: str = "filters", on_change = null, class: str? = null) { style global filter_bar_css state text = query state sel = filter_bar_values(facets, values) state dirty = false let chosen = flatten(for [fi, f] in enumerate(facets) => (get(sel, f.name) ?? []) | map((v) => { fi, f, v })) let multi = (f) => get(f, "multiple") != false let send = (form) => { if on_change != null { on_change({ query: text, values: sel }) } else if auto { form.requestSubmit() } else { dirty = true } } let toggle = (f, v: str, on: bool) => { let now = get(sel, f.name) ?? [] sel = set(sel, f.name, multi(f) ? (on ? unique(now + [v]) : now | filter((x) => x != v)) : (v == "" ? [] : [v])) } // The input of a facet's option, so the form sends what the state says before it renders again. let field_of = (form, fi: int, v: str) => { let opts = filter_bar_options(facets[fi]) let oi = index_of(opts | map((o) => o.value), v) oi == null ? null : form.querySelector(`#${id}-${fi}-${oi}`) } let remove = (e, c) => { let form = e.target.closest("form") let el = field_of(form, c.fi, c.v) if el != null { el.checked = false } if !multi(c.f) { let any_el = form.querySelector(`#${id}-${c.fi}-any`) if any_el != null { any_el.checked = true } } toggle(c.f, c.v, false) form.querySelector("input[type=search]").focus() send(form) } <form class={ui_cls(["ui-fb", class])} method="get" action={action} role="search" aria-label={label}> <div.ui-fb-row> <div.ui-fb-search> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><circle cx="7" cy="7" r="4.75"/><path d="M10.5 10.5L14 14"/></svg> <input type="search" id={`${id}-q`} name={name} value={text} placeholder={placeholder} aria-label={placeholder != "" ? placeholder : "Search"} autocomplete="off" @input={(e) => { text = e.value on_change && on_change({ query: text, values: sel }) }} @keydown={(e) => { if e.key == "Enter" && on_change != null { e.preventDefault() } }}/> </div> {for [fi, f] in enumerate(facets) => { let opts = filter_bar_options(f) let now = get(sel, f.name) ?? [] get(f, "display") == "chips" ? <fieldset.ui-fb-chips key={f.name}> <legend>{f.label}</legend> {for [oi, o] in enumerate(opts) => <label.ui-fb-chip key={o.value}> <input.ui-sr type={multi(f) ? "checkbox" : "radio"} id={`${id}-${fi}-${oi}`} name={f.name} value={o.value} checked={contains(now, o.value)} @click={(e) => { // A chosen radio chip, clicked again, is let go. if !multi(f) && contains(get(sel, f.name) ?? [], o.value) { e.target.checked = false toggle(f, "", true) send(e.target.form) } }} @change={(e) => { toggle(f, o.value, e.target.checked) send(e.target.form) }}/> <span>{o.label}</span> {o.count != null && <span.ui-fb-count>{o.count}</span>} </label>} </fieldset> : <span key={f.name}> <button type="button" popovertarget={`${id}-${fi}`} style={`anchor-name: --${id}-${fi}`} class={ui_cls(["ui-fb-btn", len(now) > 0 && "ui-fb-on"])}> {f.label} {len(now) > 0 && <span.ui-fb-n aria-label={`${len(now)} chosen`}>{len(now)}</span>} <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg> </button> <div.ui-fb-menu id={`${id}-${fi}`} popover="auto" style={`position-anchor: --${id}-${fi}`} @toggle={(e) => { if e.newState == "closed" && dirty && on_change == null && auto { e.target.closest("form").requestSubmit() } if e.newState == "open" { dirty = false } }}> <fieldset> <legend.ui-sr>{f.label}</legend> {!multi(f) && <label.ui-fb-opt> <input type="radio" id={`${id}-${fi}-any`} name={f.name} value="" checked={len(now) == 0} @change={(e) => { toggle(f, "", true); if on_change != null { send(e.target.form) } else { dirty = true } }}/> <span>Any</span> </label>} {for [oi, o] in enumerate(opts) => <label.ui-fb-opt key={o.value}> <input type={multi(f) ? "checkbox" : "radio"} id={`${id}-${fi}-${oi}`} name={f.name} value={o.value} checked={contains(now, o.value)} @change={(e) => { toggle(f, o.value, e.target.checked) if on_change != null { send(e.target.form) } else { dirty = true } }}/> <span>{o.label}</span> {o.count != null && <span.ui-fb-count>{o.count}</span>} </label>} </fieldset> </div> </span> }} {on_change == null && <button.ui-fb-btn.ui-fb-apply type="submit">{apply}</button>} </div> {len(chosen) > 0 ? <div.ui-fb-row> <ul.ui-fb-pills aria-label="Filters in force"> {for c in chosen => <li.ui-fb-pill key={`${c.f.name}=${c.v}`}> <span><b>{c.f.label}:</b> {filter_bar_label(c.f, c.v)}</span> <button type="button" aria-label={`Remove ${c.f.label}: ${filter_bar_label(c.f, c.v)}`} @click={(e) => { remove(e, c) }}> <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M3 3l6 6M9 3l-6 6"/></svg> </button> </li>} </ul> <button.ui-fb-clear type="button" @click={(e) => { let form = e.target.closest("form") for c in chosen => { let el = field_of(form, c.fi, c.v) if el != null { el.checked = false } let any_el = form.querySelector(`#${id}-${c.fi}-any`) if any_el != null { any_el.checked = true } } sel = filter_bar_values(facets, {}) form.querySelector("input[type=search]").focus() send(form) }}>Clear all</button> </div> : null} </form>}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 downloadsChipGroupChips to pick one or several of, like filters: real radios or checkboxes that a form sends, with counts.6 views · 4 downloadsComboboxA 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.5 views · 6 downloadsEmojiPickerPick an emoji from tabs of categories or by searching their names; each is named for screen readers and a form gets the one picked.4 views · 7 downloadsFuzzySelectA combobox that ranks options by fuzzy subsequence score with matched letters highlighted and recent picks first, ignoring case and accents, with full ARIA keyboard behaviour and a real field.2 views · 6 downloadsIconPickerPick an icon from a grid of 51, narrowed by a search over names and meanings; the arrow keys move and a form gets the name.5 views · 5 downloads
Thread
Thread 0 posts
On FilterBar: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.