FilterTabs
Tabs with counts that filter a list, one selected, moved between with the arrow keys; a hidden input holds the key.
Demo
- 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.qwemain.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>}Related
BottomNavA fixed bar of icon tabs along the bottom of a phone's screen, with counts and the current one marked.6 views · 7 downloadsIconTabsTabs with an icon above each label and a count on its corner, the arrow keys moving between them.5 views · 6 downloadsNavSearchA search box for a navbar that grows when focused, with / to reach it and Escape to clear; a real form sends it.5 views · 5 downloadsScrollTabsA row of tabs that scrolls sideways when there are more than fit, with arrows at the ends and the keyboard.5 views · 8 downloadsSegmentedControlOne choice of a few, as a pill with the picked one raised: real radio buttons that a form sends.4 views · 5 downloadsSubnavA second row of links under the main navigation, for one section's pages, with counts and the current one marked.5 views · 5 downloads
Thread
Thread 0 posts
On FilterTabs: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.