Mobile

FilterSheet

Facets in a sheet, with result counts updating live, clear, apply, the count on the button, and a state kept for next time.

#mobile#interactive#commerce#sheet#filter#tablet

Demo

Preview

Filters

Brand
Size
Colour
Price

8 shown: Trail runner, Road racer, Canvas low, Canvas high, Court classic, Court pro, Slip-on, Hiker mid

Get it

It needs theme.qwe, as every component does, and cart_sheet.qwe; 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/cart_sheet.qwe$ curl -O https://quillolang.org/components/ui/filter_sheet.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/filter_sheet.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/filter_sheet.qwe
let filter_sheet_demo_items = [    { id: 1, name: "Trail runner", brand: "Northpeak", sizes: ["8", "9", "10"], colour: "Black", price: "120.00", in_stock: true },    { id: 2, name: "Road racer", brand: "Northpeak", sizes: ["9", "10"], colour: "Red", price: "160.00", in_stock: true },    { id: 3, name: "Canvas low", brand: "Hollis", sizes: ["7", "8"], colour: "White", price: "65.00", in_stock: true },    { id: 4, name: "Canvas high", brand: "Hollis", sizes: ["8", "9"], colour: "Black", price: "75.00", in_stock: false },    { id: 5, name: "Court classic", brand: "Ace & Co", sizes: ["9"], colour: "White", price: "90.00", in_stock: true },    { id: 6, name: "Court pro", brand: "Ace & Co", sizes: ["10", "11"], colour: "Red", price: "140.00", in_stock: false },    { id: 7, name: "Slip-on", brand: "Hollis", sizes: ["7", "8", "9"], colour: "Black", price: "55.00", in_stock: true },    { id: 8, name: "Hiker mid", brand: "Northpeak", sizes: ["10", "11"], colour: "Brown", price: "180.00", in_stock: true },]let filter_sheet_demo_facets = [    { key: "brand", label: "Brand" },    { key: "sizes", label: "Size", options: ["7", "8", "9", "10", "11", "12"] },    { key: "colour", label: "Colour" },    { key: "price", label: "Price", kind: "range" },    { key: "in_stock", label: "In stock only", kind: "flag" },]component FilterSheetDemoLive() {    state shown = filter_sheet_demo_items    state round = 0    state kept = null    <div.demo-stack>        <div.demo-row>            <FilterSheet key={round} items={filter_sheet_demo_items} facets={filter_sheet_demo_facets} noun="shoes" save_key="demo-shoes" saved={kept}                         id={`filter-demo-${round}`} on_apply={(_c, found) => { shown = found }}/>            <Button id="filter-demo-again" variant="ghost" size="lg" on_click={() => { round += 1 }}>Come back later</Button>            <Button id="filter-demo-app" variant="ghost" size="lg" on_click={() => { kept = { colour: ["Red"] }; round += 1 }}>Come back, as an app</Button>        </div>        <p.demo-note id="filter-demo-list">{len(shown)} shown: {shown | map((x) => x.name) | join(", ")}</p>    </div>}view FilterSheetDemo() = <FilterSheetDemoLive/>

Source

ui/filter_sheet.qwe
// FilterSheet: facets in a sheet, on a phone from the bottom and on a tablet in a dialog// with the facets side by side. Each option shows how many results it would give, and// these and the Show button change as options are picked, without touching the list// behind. Clear empties the choices, Show applies them, closing without Show leaves them// as they were, and the button that opens it counts what is on. What was applied can be// kept on the device for the next visit.////   import ui/filter_sheet.qwe use FilterSheet////   <FilterSheet items={products} save_key="shoes"//                facets={[{ key: "brand", label: "Brand" },//                         { key: "sizes", label: "Size", options: ["S", "M", "L"] },//                         { key: "price", label: "Price", kind: "range" },//                         { key: "in_stock", label: "In stock only", kind: "flag" }]}//                on_apply={(chosen, found) => { shown = found }}/>//// items: what is being filtered, each a map. facets: one for each thing to filter by:// key (the field of an item), label, and kind: "list" (the default: checkboxes, an item// passing with any of those ticked; its field may be one value or a list of them),// "range" (a lowest and a highest number) or "flag" (one switch, for a field that is true).// options: a list's values in the order to show them (else as they first appear in// items). trigger: the text of the button that opens it ("Filters"; null for none, then// open it by open={...} and on_close()). title: the sheet's heading ("Filters"). noun: what// the results are, in the plural ("results"). saved: what to start from, the chosen that// on_apply last gave. save_key: the name to keep it under in this browser's localStorage// instead (null keeps nothing). A phone app's localStorage comes back empty at each launch,// so there keep chosen yourself, through a server fn, and pass it as saved. on_apply(chosen,// found): after Show, and at the start when something saved came back: chosen is what// filter_sheet_on counts, found the items that pass. id: a prefix for its ids ("ui-filtsh"),// unique on the page.//// Facets work as a shop's do: ticks in one facet widen it (either brand), facets narrow// each other (this brand and that size), and the number beside an option is what ticking// it would give, so a facet's own ticks do not shrink it. An option giving none is off,// unless it is ticked already.//// filter_sheet_found(items, facets, chosen) and filter_sheet_counts(items, facets, chosen,// facet) are the same work, for a server's list of the same data.import ui/theme.qwe use ui_clsimport ui/button.qwe use Buttonimport ui/cart_sheet.qwe use CartSheetFramelet filter_sheet_css = css`.ui-filtsh-host { display: contents; }.ui-filtsh-open {  display: inline-flex; align-items: center; gap: 8px; min-width: 44px; min-height: 46px; padding: 0 16px; border: 1px solid var(--ui-border);  border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 600 15px var(--ui-font); cursor: pointer;  touch-action: manipulation;}.ui-filtsh-open:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-filtsh-on { border-color: var(--ui-accent); }.ui-filtsh-count {  display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 6px; box-sizing: border-box; border-radius: 999px;  background: var(--ui-accent); color: var(--ui-accent-fg); font-size: 13px; font-variant-numeric: tabular-nums;}.ui-filtsh-facets { display: grid; gap: 18px; }.ui-filtsh-facet { min-width: 0; margin: 0; padding: 0; border: 0; }.ui-filtsh-facet legend { padding: 0 0 4px; font-weight: 600; }.ui-filtsh-opt {  position: relative; display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 4px; border-radius: var(--ui-radius-sm); cursor: pointer;  touch-action: manipulation;}.ui-filtsh-opt input { flex: none; width: 20px; height: 20px; margin: 0; accent-color: var(--ui-accent); }.ui-filtsh-opt span:first-of-type { flex: 1; min-width: 0; overflow-wrap: anywhere; }.ui-filtsh-n { color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-filtsh-off { color: var(--ui-muted); cursor: default; }.ui-filtsh-opt:focus-within { box-shadow: var(--ui-focus); }.ui-filtsh-range { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }.ui-filtsh-range label { display: grid; gap: 2px; min-width: 0; color: var(--ui-muted); font-size: 13px; }/* 16 px: anything smaller and iOS zooms the page when the field is touched. */.ui-filtsh-range input {  box-sizing: border-box; width: 100%; min-width: 0; height: 46px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm);  background: var(--ui-surface); color: var(--ui-fg); font: 16px var(--ui-font);}.ui-filtsh-range input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-filtsh-bad { grid-column: 1 / -1; margin: 0; color: var(--ui-danger); font-size: 13px; }.ui-filtsh-acts { display: grid; grid-template-columns: auto 1fr; gap: 10px; }.ui-filtsh-saved { margin: 0; color: var(--ui-muted); font-size: 13px; }@container (min-width: 520px) { .ui-filtsh-facets { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 18px 28px; } }`// The facets that can be used: each needs a key.fn filter_sheet_clean(facets) -> list =    type_of(facets) != "list" ? [] : facets | filter((f) => type_of(f) == "map" && get(f, "key") != null)        | map((f) => set(set(f, "key", str(f.key)), "label", str(get(f, "label", f.key))))// The options of a list facet: those it names, else as they first appear in the items.fn filter_sheet_values(x, key: str) -> list {    let v = get(x, key)    if v == null { return [] }    type_of(v) == "list" ? v | map((o) => str(o)) : [str(v)]}fn filter_sheet_options(items: list, facet: map) -> list {    let named = get(facet, "options")    type_of(named) == "list" ? named | map((o) => str(o)) : items | map((x) => filter_sheet_values(x, facet.key)) | flatten() | unique()}// A bound as a decimal, or null when it is empty or not a number.fn filter_sheet_number(v) {    let t = trim(str(v ?? ""))    if t == "" || len(t) > 18 || !re.test(t, "^-?[0-9]{1,14}([.][0-9]{1,6})?$") { return null }    dec(t)}// Whether an item passes one facet.fn filter_sheet_passes(x, facet: map, chosen: map) -> bool {    let kind = get(facet, "kind", "list")    let on = get(chosen, facet.key)    if on == null { return true }    if kind == "flag" { return on != true || get(x, facet.key) == true }    if kind == "range" {        let low = filter_sheet_number(get(on, "low"))        let high = filter_sheet_number(get(on, "high"))        if low == null && high == null { return true }        let n = filter_sheet_number(get(x, facet.key))        return n != null && (low == null || n >= low) && (high == null || n <= high)    }    if len(on) == 0 { return true }    filter_sheet_values(x, facet.key) | any((v) => contains(on, v))}// The items that pass every facet.fn filter_sheet_found(items: list, facets: list, chosen: map) -> list =    items | filter((x) => facets | all((f) => filter_sheet_passes(x, f, chosen)))// What each option of a list facet would give: [{ value, n }], in the facets' order.// Each facet counts over what the others leave, so ticking one brand leaves the other// brands' numbers as they were.fn filter_sheet_counts(items: list, facets: list, chosen: map, facet: map) -> list {    let others = facets | filter((f) => f.key != facet.key)    let left = filter_sheet_found(items, others, chosen) | map((x) => filter_sheet_values(x, facet.key))    filter_sheet_options(items, facet) | map((o) => { value: o, n: left | count((vs) => contains(vs, o)) })}// How many filters are on, for the button: each tick, each range and each flag is one.fn filter_sheet_on(facets: list, chosen: map) -> int =    facets | reduce(0, (n, f) => {        let on = get(chosen, f.key)        let kind = get(f, "kind", "list")        let adds = if on == null { 0 }            else if kind == "flag" { on == true ? 1 : 0 }            else if kind == "range" { filter_sheet_number(get(on, "low")) != null || filter_sheet_number(get(on, "high")) != null ? 1 : 0 }            else { len(on) }        n + adds    })// A range's message: what is wrong with it, or null.fn filter_sheet_range_note(on) {    if on == null { return null }    let lo = trim(str(get(on, "low") ?? ""))    let hi = trim(str(get(on, "high") ?? ""))    let low = filter_sheet_number(lo)    let high = filter_sheet_number(hi)    if (lo != "" && low == null) || (hi != "" && high == null) { return "Enter a number." }    low != null && high != null && low > high ? "The lowest is above the highest." : null}// What was kept, without anything that no longer fits the facets: a saved text may be// old or changed by hand, and options come and go.fn filter_sheet_restore(text, items: list, facets: list) -> map {    if type_of(text) != "str" || len(text) > 20000 { return {} }    let data = json.parse(text)    is_err(data) ? {} : filter_sheet_fit(data, items, facets)}fn filter_sheet_fit(data, items: list, facets: list) -> map {    if type_of(data) != "map" { return {} }    facets | reduce({}, (m, f) => {        let on = get(data, f.key)        let kind = get(f, "kind", "list")        if kind == "flag" { return on == true ? set(m, f.key, true) : m }        if kind == "range" {            if type_of(on) != "map" { return m }            let low = filter_sheet_number(get(on, "low"))            let high = filter_sheet_number(get(on, "high"))            return low == null && high == null ? m : set(m, f.key, { low: str(get(on, "low") ?? ""), high: str(get(on, "high") ?? "") })        }        if type_of(on) != "list" { return m }        let known = filter_sheet_options(items, f)        let kept = on | map((o) => str(o)) | filter((o) => contains(known, o))        len(kept) > 0 ? set(m, f.key, kept) : m    })}// A tick on or off.fn filter_sheet_toggle(chosen: map, key: str, value: str) -> map {    let on = get(chosen, key, [])    set(chosen, key, contains(on, value) ? on | filter((o) => o != value) : on | push(value))}fn filter_sheet_noun(n: int, noun: str) -> str = n == 1 && ends_with(noun, "s") ? slice(noun, 0, len(noun) - 1) : nounview FilterSheetOption(id: str, label: str, n: int, on: bool, on_toggle) {    let off = n == 0 && !on    <label class={ui_cls(["ui-filtsh-opt", off && "ui-filtsh-off"])} for={id}>        <input type="checkbox" id={id} checked={on} disabled={off} @change={() => { on_toggle() }}/>        <span>{label}</span>        <span.ui-filtsh-n>{n}</span>    </label>}view FilterSheetRange(id: str, label: str, on, on_low, on_high) {    let note = filter_sheet_range_note(on)    <fieldset.ui-filtsh-facet>        <legend>{label}</legend>        <div.ui-filtsh-range>            <label for={`${id}-low`}>Lowest                <input id={`${id}-low`} inputmode="decimal" autocomplete="off" enterkeyhint="done" value={str(get(on, "low") ?? "")}                       aria-invalid={note != null ? "true" : null} @input={(e) => { on_low(e.value) }}/>            </label>            <label for={`${id}-high`}>Highest                <input id={`${id}-high`} inputmode="decimal" autocomplete="off" enterkeyhint="done" value={str(get(on, "high") ?? "")}                       aria-invalid={note != null ? "true" : null} @input={(e) => { on_high(e.value) }}/>            </label>            {note != null && <p.ui-filtsh-bad role="alert">{note}</p>}        </div>    </fieldset>}component FilterSheet(items = [], facets = [], trigger: str? = "Filters", open: bool = false, title: str = "Filters",                      noun: str = "results", saved = null, save_key: str? = null, id: str = "ui-filtsh", on_apply = null, on_close = null) {    style global filter_sheet_css    let groups = filter_sheet_clean(facets)    let rows = type_of(items) == "list" ? items | filter((x) => type_of(x) == "map") : []    state own = false    state applied = filter_sheet_fit(saved, rows, groups)     // what the page behind is filtered by    state draft = filter_sheet_fit(saved, rows, groups)       // what is ticked in the sheet, until Show    state back = len(keys(filter_sheet_fit(saved, rows, groups))) > 0       // what is applied came from the last visit    let found = filter_sheet_found(rows, groups, draft)    let on_now = filter_sheet_on(groups, applied)    let on_draft = filter_sheet_on(groups, draft)    let close = () => {        own = false        draft = applied        on_close && on_close()    }    let apply = () => {        applied = draft        back = false        on_apply && on_apply(applied, found)        own = false        on_close && on_close()        // Private windows and full storage throw: the wrapper makes that an error value, and the filter still applies.        if save_key != null {            let text = json.stringify(applied)            let kept = js("Promise.resolve", js_new("Promise", (done) => { done(js("localStorage.setItem", `ui-filtsh-${save_key}`, text)) }))            is_err(kept)        }    }    let bound = (key, part, text) => {        let was = get(draft, key, { low: "", high: "" })        draft = set(draft, key, set(was, part, text))    }    <div.ui-filtsh-host @mount={(_el) => {        let given = len(keys(applied)) > 0        let text = save_key != null && !given ? js("Promise.resolve", js_new("Promise", (done) => { done(js("localStorage.getItem", `ui-filtsh-${save_key}`)) })) : null        let kept = given ? applied : (is_err(text) ? {} : filter_sheet_restore(text, rows, groups))        if len(keys(kept)) > 0 {            applied = kept            draft = kept            back = true            on_apply && on_apply(kept, filter_sheet_found(rows, groups, kept))        }    }}>        {trigger != null && <button type="button" aria-haspopup="dialog" class={ui_cls(["ui-filtsh-open", on_now > 0 && "ui-filtsh-on"])}                                    @click={() => { own = true }}>            {trigger}            {on_now > 0 && <span.ui-filtsh-count aria-label={`${on_now} on`}>{on_now}</span>}        </button>}        <CartSheetFrame title={title} open={open || own} wide="center" on_close={close}                        footer={<div.ui-filtsh-acts>                            <Button variant="outline" size="lg" disabled={on_draft == 0} on_click={() => { draft = {} }}>Clear</Button>                            <Button size="lg" full={true} disabled={filter_sheet_any_bad(groups, draft)} on_click={apply}>                                <span aria-live="polite">Show {len(found)} {filter_sheet_noun(len(found), noun)}</span>                            </Button>                        </div>}>            {back && <p.ui-filtsh-saved>Your filters from last time.</p>}            <div.ui-filtsh-facets>                {for f in groups {                    let kind = get(f, "kind", "list")                    <div key={f.key}>                        {kind == "list" && <fieldset.ui-filtsh-facet>                            <legend>{f.label}</legend>                            {for o in filter_sheet_counts(rows, groups, draft, f) => <FilterSheetOption key={o.value} id={`${id}-${f.key}-${o.value}`}                                label={o.value} n={o.n} on={contains(get(draft, f.key, []), o.value)}                                on_toggle={() => { draft = filter_sheet_toggle(draft, f.key, o.value) }}/>}                        </fieldset>}                        {kind == "range" && <FilterSheetRange id={`${id}-${f.key}`} label={f.label} on={get(draft, f.key)}                            on_low={(t) => { bound(f.key, "low", t) }} on_high={(t) => { bound(f.key, "high", t) }}/>}                        {kind == "flag" && <label.ui-filtsh-opt for={`${id}-${f.key}`}>                            <input type="checkbox" id={`${id}-${f.key}`} checked={get(draft, f.key) == true}                                   @change={(e) => { draft = set(draft, f.key, e.checked) }}/>                            <span>{f.label}</span>                        </label>}                    </div>                }}            </div>        </CartSheetFrame>    </div>}// Whether a range cannot be read, so Show waits for it to be put right.fn filter_sheet_any_bad(facets: list, chosen: map) -> bool =    facets | any((f) => get(f, "kind", "list") == "range" && filter_sheet_range_note(get(chosen, f.key)) != null)
Thread
35Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.