FilterChipsBar
A sideways scroller of filter chips staying under the header, the active chip kept in view, a count of results, and clear.
Demo
Harbour Cafe
The Long Table
Noodle Bar
Fig and Olive
Station Diner
Corner Bakery
Rooftop 12
Night Owl
On: , cleared 0 times
Period: All
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_chips_bar.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/filter_chips_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_chips_bar.qwe
let filter_chips_bar_demo_places = [ { name: "Harbour Cafe", tags: ["open", "wifi", "outdoor"] }, { name: "The Long Table", tags: ["open", "vegan", "booking"] }, { name: "Noodle Bar", tags: ["open", "cheap"] }, { name: "Fig and Olive", tags: ["vegan", "outdoor", "booking", "wifi"] }, { name: "Station Diner", tags: ["open", "cheap", "dogs"] }, { name: "Corner Bakery", tags: ["wifi", "cheap"] }, { name: "Rooftop 12", tags: ["outdoor", "booking", "late"] }, { name: "Night Owl", tags: ["open", "late", "dogs"] }]let filter_chips_bar_demo_chips = [ { id: "open", label: "Open now" }, { id: "wifi", label: "Wi-Fi" }, { id: "outdoor", label: "Outdoor seating" }, { id: "vegan", label: "Vegan" }, { id: "cheap", label: "Under 15" }, { id: "booking", label: "Takes bookings" }, { id: "dogs", label: "Dogs welcome" }, { id: "late", label: "Open late" }, { id: "kids", label: "Kids' menu", n: 0 }]fn filter_chips_bar_demo_find(wanted: list) -> list = filter_chips_bar_demo_places | filter((p) => all(wanted, (w) => contains(p.tags, w)))component FilterChipsBarDemoLive() { state wanted = [] state picked = "All" state cleared = 0 let found = filter_chips_bar_demo_find(wanted) <div.demo-stack> <div.demo-phone data-frame> <div style="height: 100%; overflow: auto" data-scroller> <header data-head style="position: sticky; top: 0; z-index: 6; height: 56px; display: flex; align-items: center; padding: 0 16px; background: var(--ui-bg); font-weight: 600">Places near you</header> <FilterChipsBar chips={filter_chips_bar_demo_chips} results={len(found)} noun="place" top="56px" on_change={(ids) => { wanted = ids }} on_clear={() => { cleared += 1 }}/> {for p in found => <p key={p.name} data-place style="margin: 0; padding: 18px 16px; border-bottom: 1px solid var(--ui-border)">{p.name}</p>} <div style="height: 900px"></div> </div> </div> <p.demo-note>On: <b data-wanted>{join(wanted, ",")}</b>, cleared <b data-cleared>{cleared}</b> times</p> <FilterChipsBar id="filter-chips-one" multi={false} active={["Week"]} label="Period" chips={["Day", "Week", "Month", "Year"]} on_change={(ids) => { picked = ids[0] ?? "All" }}/> <p.demo-note>Period: <b data-picked>{picked}</b></p> <FilterChipsBar id="filter-chips-bad" chips={[null, { label: "" }, "Plain", { id: 7, label: "Seven", n: 3 }, { id: 7, label: "Twin" }, { id: "x", label: "Off", disabled: true }]} results="many" active={["gone", 7]}/> </div>}view FilterChipsBarDemo() = <FilterChipsBarDemoLive/>Source
ui/filter_chips_bar.qwe
// FilterChipsBar: a sideways row of filter chips that stays under the header as the// list below it scrolls, as in a shopping or maps app. A chip pressed is scrolled// fully into view, and stays so after a rotation; the number of results shows under// the chips, and Clear turns them all off. The row is the browser's own scroller,// so a finger flings it, with fading edges saying there is more. Arrow keys, Home// and End go along the chips. In a wide pane there is no need to scroll: they wrap.//// <FilterChipsBar chips={[{ id: "open", label: "Open now", n: 12 }, { id: "wifi", label: "Wi-Fi" }]}// results={len(shown)} top="56px" on_change={(ids) => { wanted = ids }}/>//// chips: a list of strings or { id, label, n?, disabled? }. id: what on_change gives// (the label when left out). n: how many a chip would find, shown on it; a chip at 0// is disabled unless it is on. active: the ids on at the start; when this prop// changes, the bar follows it. multi: several may be on (true); false makes pressing// one turn off the others. results: the number of results now, as a number; left out,// no count shows. noun: what is counted ("result"; an "s" is added but for one).// top: how far below the top of the scrolling box it sticks, a CSS length, the// height of your header ("0px"). clear_text: the words on the button ("Clear").// label: what screen readers call the bar ("Filters"). id, class: on the bar.// on_change(ids): inside one of your components, the ids that are on, in the order the// chips stand in. on_clear(): after Clear, and on_change is called as well.import ui/theme.qwe use ui_clslet filter_chips_bar_css = css`.ui-chipsbar { container-type: inline-size; position: sticky; z-index: 5; box-sizing: border-box; width: 100%; padding: 6px 0 4px; border-bottom: 1px solid var(--ui-border); background: var(--ui-bg); color: var(--ui-fg); font: 14px/1.3 var(--ui-font); }.ui-chipsbar-line { display: flex; align-items: center; min-width: 0; }.ui-chipsbar-chips { position: relative; display: flex; flex: 1; gap: 8px; min-width: 0; padding: 0 12px; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; touch-action: pan-x pan-y; }.ui-chipsbar-chips::-webkit-scrollbar { display: none; }.ui-chipsbar-chips[data-fade="left"] { mask-image: linear-gradient(to right, transparent, #000 24px); -webkit-mask-image: linear-gradient(to right, transparent, #000 24px); }.ui-chipsbar-chips[data-fade="right"] { mask-image: linear-gradient(to left, transparent, #000 24px); -webkit-mask-image: linear-gradient(to left, transparent, #000 24px); }.ui-chipsbar-chips[data-fade="both"] { mask-image: linear-gradient(to right, transparent, #000 24px, #000 calc(100% - 24px), transparent); -webkit-mask-image: linear-gradient(to right, transparent, #000 24px, #000 calc(100% - 24px), transparent); }.ui-chipsbar-chip { display: inline-flex; align-items: center; gap: 6px; flex: none; box-sizing: border-box; min-height: 44px; padding: 0 14px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface); color: var(--ui-fg); font: 500 14px/1 var(--ui-font); white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: background .12s, border-color .12s, color .12s; }.ui-chipsbar-chip small { color: var(--ui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }.ui-chipsbar-chip[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-chipsbar-chip[aria-pressed="true"] small { color: inherit; }.ui-chipsbar-chip:disabled { border-style: dashed; color: var(--ui-muted); cursor: default; }.ui-chipsbar-chip:focus-visible, .ui-chipsbar-clear:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (hover: hover) { .ui-chipsbar-chip:not(:disabled):not([aria-pressed="true"]):hover { border-color: var(--ui-accent); } }.ui-chipsbar-clear { flex: none; min-width: 60px; min-height: 44px; margin-right: 6px; padding: 0 10px; border: 0; border-radius: var(--ui-radius-sm); background: none; color: var(--ui-accent); font: 600 14px/1 var(--ui-font); cursor: pointer; touch-action: manipulation; }.ui-chipsbar-count { min-height: 18px; margin: 2px 0 0; padding: 0 16px; color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }@container (min-width: 640px) { .ui-chipsbar-chips { flex-wrap: wrap; overflow: visible; } .ui-chipsbar-chips[data-fade] { mask-image: none; -webkit-mask-image: none; }}@media (prefers-reduced-motion: reduce) { .ui-chipsbar-chip { transition: none; } }`// The chips as { id, label, n, disabled }, without what cannot be shown, and without a// second chip of the same id.fn filter_chips_bar_list(chips) -> list { let given = type_of(chips) == "list" ? chips : [] let all = given | map((c) => type_of(c) == "map" ? { id: get(c, "id") ?? get(c, "label"), label: str(get(c, "label") ?? ""), n: get(c, "n"), disabled: get(c, "disabled", false) == true } : { id: c, label: c == null ? "" : str(c), n: null, disabled: false }) let good = enumerate(all) | filter((p) => p[1].id != null && p[1].label != "") let parts = group_by(good, (p) => str(p[1].id)) good | filter((p) => get(parts, str(p[1].id), [])[0][0] == p[0]) | map((p) => p[1])}// The ids given that are chips, in the chips' order; one at most unless multi.fn filter_chips_bar_start(list_of: list, active, multi: bool) -> list { let said = type_of(active) == "list" ? active : [] let on = list_of | filter((c) => contains(said, c.id)) | map((c) => c.id) multi ? on : take(on, 1)}// What is on after pressing a chip, in the chips' order.fn filter_chips_bar_toggle(list_of: list, on: list, id, multi: bool) -> list { if contains(on, id) { return remove(on, id) } if !multi { return [id] } list_of | filter((c) => c.id == id || contains(on, c.id)) | map((c) => c.id)}// Where the row should stand to show a chip: as it is if the chip is in view with// room around it, else with the chip in the middle, as far as the row goes.fn filter_chips_bar_left(chip_left: num, chip_width: num, view_width: num, scroll_width: num, left_now: num, room: num = 24) -> num { if chip_left - room >= left_now && chip_left + chip_width + room <= left_now + view_width { return left_now } let mid = chip_left - (view_width - chip_width) / 2 max([0, min([mid, scroll_width - view_width])])}// The next chip a key goes to, among the positions that can be pressed.fn filter_chips_bar_step(open: list, at: int, press: str) { if len(open) == 0 { return null } let i = index_of(open, at) ?? 0 match press { "ArrowRight" => open[min([len(open) - 1, i + 1])] "ArrowLeft" => open[max([0, i - 1])] "Home" => open[0] "End" => open[len(open) - 1] _ => null }}// "1 result", "12 results"; nothing for what is not a count.fn filter_chips_bar_count(results, noun: str) -> str { let kind = type_of(results) if (kind != "int" && kind != "float") || results < 0 { return "" } `${results} ${noun}${results == 1 ? "" : "s"}`}fn filter_chips_bar_fade(left: bool, right: bool) -> str? = left && right ? "both" : (left ? "left" : (right ? "right" : null))component FilterChipsBar(chips = [], active = [], multi: bool = true, results = null, noun: str = "result", top: str = "0px", clear_text: str = "Clear", label: str = "Filters", on_change = null, on_clear = null, id: str? = null, class: str? = null) { style global filter_chips_bar_css let shown = filter_chips_bar_list(chips) state on = filter_chips_bar_start(filter_chips_bar_list(chips), active, multi) state given = active state box = null state still = false state fade = null state last_id = null // A parent that sets `active` itself leads; the bar follows it. if active != given { given = active on = filter_chips_bar_start(shown, active, multi) } let edges = (el) => { let wide = el.scrollWidth - el.clientWidth > 1 fade = filter_chips_bar_fade(wide && el.scrollLeft > 2, wide && el.scrollLeft < el.scrollWidth - el.clientWidth - 2) } // Brings one chip into view. The row is its own positioned box, so offsetLeft counts from it. let reveal = (chip_id, quick: bool) => { let i = index_of(shown | map((c) => c.id), chip_id) if box != null && i != null && i >= 0 { let chip = box.querySelector(`[data-i="${i}"]`) if chip != null { let to = filter_chips_bar_left(chip.offsetLeft, chip.offsetWidth, box.clientWidth, box.scrollWidth, box.scrollLeft) box.scrollTo({ left: to, behavior: quick || still ? "instant" : "smooth" }) } } } let press = (c) => { on = filter_chips_bar_toggle(shown, on, c.id, multi) last_id = c.id reveal(c.id, false) on_change && on_change(on) } let clear = () => { on = [] last_id = null on_change && on_change([]) on_clear && on_clear() } <div class={ui_cls(["ui-chipsbar", class])} id={id} role="group" aria-label={label} style={`top: ${top}`}> <div.ui-chipsbar-line> <div.ui-chipsbar-chips data-fade={fade} @mount={(el) => { still = js("matchMedia", "(prefers-reduced-motion: reduce)").matches box = el edges(el) if len(on) > 0 { reveal(on[len(on) - 1], true) } // A rotation or a new width: the chip pressed last stays in view. let ro = js_new("ResizeObserver", () => { edges(el) let keep = last_id ?? (len(on) > 0 ? on[len(on) - 1] : null) if keep != null { reveal(keep, true) } }) ro.observe(el) () => ro.disconnect() }} @scroll={(e) => { edges(e.target) }} @keydown={(e) => { let from_i = e.target.dataset.i if from_i != null { let open = enumerate(shown) | filter((p) => !p[1].disabled && !(get(p[1], "n") == 0 && !contains(on, p[1].id))) | map((p) => p[0]) let to = filter_chips_bar_step(open, int(from_i), e.key) if to != null { e.preventDefault() box.querySelector(`[data-i="${to}"]`).focus() } } }}> {for [i, c] in enumerate(shown) => <button.ui-chipsbar-chip key={str(c.id)} type="button" data-i={i} aria-pressed={contains(on, c.id) ? "true" : "false"} disabled={c.disabled || (c.n == 0 && !contains(on, c.id))} @click={() => { press(c) }}> {c.label} {c.n != null && <small>{c.n}</small>} </button>} </div> {len(on) > 0 && <button.ui-chipsbar-clear type="button" @click={clear}>{clear_text}</button>} </div> {filter_chips_bar_count(results, noun) != "" && <p.ui-chipsbar-count role="status">{filter_chips_bar_count(results, noun)}</p>} </div>}Related
ContactListPeople grouped by letter with avatars from initials, fast search across names and numbers, a call and message action per row.3 views · 8 downloadsEmptyScreenEmpty states for first use, none-found and all-done, each with what to do next.3 views · 9 downloadsFilterSheetFacets in a sheet, with result counts updating live, clear, apply, the count on the button, and a state kept for next time.3 views · 5 downloadsFullscreenPickerA full-screen list to pick from, with search and a header with Done, that stays clear of the on-screen keyboard; on a tablet, a card.2 views · 6 downloadsSearchHeaderA header whose search field opens in place with Cancel, recent searches and your results; the keyboard stays up through them; a dropdown panel on a tablet.3 views · 5 downloadsSettingsListRows of toggles, values and links from data, with a search across them, and on_change(key, value).3 views · 6 downloads
Thread
Thread 0 posts
On FilterChipsBar: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.