Navigation

FilterTabs

Tabs with counts that filter a list, one selected, moved between with the arrow keys; a hidden input holds the key.

#navigation#tabs#filter#forms#interactive

Demo

Preview
  • Login fails on Safari (open)
  • Dark mode for charts (open)
  • Typo in the install guide (closed)
  • Export to CSV (closed)
  • Slow search on large lists (open)
  • Keyboard shortcuts page (closed)

Showing 6 of 6; the hidden input says "all".

Kind: bug

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_tabs.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/filter_tabs.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/filter_tabs.qwe
let filter_tabs_demo_issues = [    { title: "Login fails on Safari", status: "open", kind: "bug" }, { title: "Dark mode for charts", status: "open", kind: "feature" },    { title: "Typo in the install guide", status: "closed", kind: "bug" }, { title: "Export to CSV", status: "closed", kind: "feature" },    { title: "Slow search on large lists", status: "open", kind: "bug" }, { title: "Keyboard shortcuts page", status: "closed", kind: "feature" }]component FilterTabsDemoLive() {    state status = "all"    let shown = filter_tabs_demo_issues | filter((i) => status == "all" || i.status == status)    let n = (s: str) => count(filter_tabs_demo_issues, (i) => s == "all" || i.status == s)    <div.demo-stack>        <FilterTabs name="status" controls="filter-tabs-demo-list" selected={status} on_change={(k) => { status = k }} tabs={[            { key: "all", label: "All", count: n("all") }, { key: "open", label: "Open", count: n("open") },            { key: "closed", label: "Closed", count: n("closed") }, { key: "archived", label: "Archived", count: 0, disabled: true }        ]}/>        <ul id="filter-tabs-demo-list" style="margin: 0; padding-left: 20px">            {for i in shown => <li key={i.title}>{i.title} <span.demo-note style="display: inline">({i.status})</span></li>}        </ul>        <p.demo-note>Showing {len(shown)} of {len(filter_tabs_demo_issues)}; the hidden input says "{status}".</p>    </div>}component FilterTabsDemoLine() {    state kind = "bug"    <div.demo-stack>        <FilterTabs variant="line" size="sm" label="Kind" selected={kind} on_change={(k) => { kind = k }} tabs={[            { key: "bug", label: "Bugs", count: 3 }, { key: "feature", label: "Features", count: 3 }, { key: "question", label: "Questions" }        ]}/>        <p.demo-note>Kind: {kind}</p>    </div>}view FilterTabsDemo() =    <div.demo-stack style="gap: 28px">        <FilterTabsDemoLive/>        <FilterTabsDemoLine/>    </div>

Source

ui/filter_tabs.qwe
// FilterTabs: a row of tabs that filter a list, each with how many it would show// (All 48 · Open 12 · Closed 36). One is selected; the arrow keys move between them.// The key picked goes in a hidden input named `name`, so a plain <form> sends it,// and inside one of your components on_change(key) filters the list.////   <FilterTabs name="status" selected={status} on_change={(k) => { status = k }} tabs={[//       { key: "all", label: "All", count: 48 },//       { key: "open", label: "Open", count: 12 },//       { key: "closed", label: "Closed", count: 36 }//   ]}/>//// tabs: { key, label, count?, disabled? }. selected: the key picked (the first if// none). name: the hidden input's name. controls: the id of the list they filter// (aria-controls). label: what screen readers call the row ("Filter").// size: "sm" or "md" (default). variant: "pill" (default) or "line".// on_change(key): called with the key picked.import ui/theme.qwe use ui_clslet filter_tabs_css = css`.ui-filtertabs { display: flex; flex-wrap: wrap; gap: 4px; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-filtertabs-tab {  display: inline-flex; align-items: center; gap: 7px; height: 34px; padding: 0 12px; border: 1px solid transparent; border-radius: 999px;  background: transparent; color: var(--ui-muted); font: 500 14px/1 var(--ui-font); white-space: nowrap; cursor: pointer;  transition: color .12s, background .12s, border-color .12s;}.ui-filtertabs-tab:hover { color: var(--ui-fg); background: var(--ui-subtle); }.ui-filtertabs-tab:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-filtertabs-tab[aria-selected="true"] { color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-filtertabs-tab:disabled { opacity: .5; cursor: not-allowed; }.ui-filtertabs-count {  min-width: 20px; padding: 1px 6px; border-radius: 999px; background: var(--ui-subtle); border: 1px solid var(--ui-border);  color: var(--ui-muted); font-size: 12px; font-weight: 600; text-align: center; font-variant-numeric: tabular-nums;}.ui-filtertabs-tab[aria-selected="true"] .ui-filtertabs-count { border-color: transparent; background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-filtertabs-line { gap: 0; border-bottom: 1px solid var(--ui-border); }.ui-filtertabs-line .ui-filtertabs-tab { height: 40px; margin-bottom: -1px; border-radius: 0; box-shadow: inset 0 -2px 0 transparent; }.ui-filtertabs-line .ui-filtertabs-tab:hover { background: transparent; }.ui-filtertabs-line .ui-filtertabs-tab[aria-selected="true"] { background: transparent; color: var(--ui-fg); box-shadow: inset 0 -2px 0 var(--ui-accent); }.ui-filtertabs-line .ui-filtertabs-tab[aria-selected="true"] .ui-filtertabs-count { background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-filtertabs-sm .ui-filtertabs-tab { height: 28px; padding: 0 10px; font-size: 13px; }.ui-filtertabs-sm .ui-filtertabs-count { font-size: 11px; }@media (prefers-reduced-motion: reduce) { .ui-filtertabs-tab { transition: none; } }`component FilterTabs(tabs: list = [], selected: str? = null, name: str? = null, controls: str? = null, label: str = "Filter",                     size: str = "md", variant: str = "pill", on_change = null, class: str? = null) {    style global filter_tabs_css    state at = selected ?? (len(tabs) > 0 ? tabs[0].key : null)    let live = tabs | filter((t) => get(t, "disabled") != true)    let pick = (k: str) => {        at = k        on_change && on_change(k)    }    <div class={ui_cls(["ui-filtertabs", variant == "line" && "ui-filtertabs-line", size == "sm" && "ui-filtertabs-sm", class])}         role="tablist" aria-label={label}         @keydown={(e) => {             // Taken first: pick may wait, as it calls on_change, and after a wait the event has no currentTarget.             let bar = e.currentTarget             let n = len(live)             let i = index_of(live | map((t) => t.key), at) ?? 0             let to = match e.key {                 "ArrowRight" => (i + 1) % n                 "ArrowLeft" => (i + n - 1) % n                 "Home" => 0                 "End" => n - 1                 _ => null             }             if to != null && n > 0 {                 e.preventDefault()                 pick(live[to].key)                 bar.querySelector(`[data-key="${live[to].key}"]`).focus()             }         }}>        {for t in tabs => <button.ui-filtertabs-tab type="button" role="tab" data-key={t.key} key={t.key}                aria-selected={t.key == at ? "true" : "false"} aria-controls={controls} tabindex={t.key == at ? "0" : "-1"}                disabled={get(t, "disabled") == true} @click={() => { pick(t.key) }}>            <span>{t.label}</span>            {get(t, "count") != null && <span.ui-filtertabs-count>{t.count}<span.ui-sr> items</span></span>}        </button>}        {name != null && <input type="hidden" name={name} value={at ?? ""}/>}    </div>}
Thread
56Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.