Mobile

FilterChipsBar

A sideways scroller of filter chips staying under the header, the active chip kept in view, a count of results, and clear.

#mobile#interactive#tablet#filter#chips#search

Demo

Preview
Places near you

8 places

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.qwe
main.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>}
Thread
26Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.