Pruh filtrů
Strip nad seznamem: filtry obrazovky jako children, počet aktivních a cesta je zrušit, se sbalením pod md.
navigationdata
Install
pnpm dlx shadcn@4.23.0 add @ansora/filter-barExamples
Usage
FilterBar is the strip above a list. It renders LAYOUT plus exactly two controls of its own — the collapse trigger and the clear button. Everything else is yours.
You pass the controls and you keep their state. children are the screen's own Selects, Tabs and Inputs, and the screen is what binds each of them to the URL (web-ui-conventions: a filter belongs in a query parameter). There is no filters prop, no onFilterChange and no useSearchParams here, and there will not be: the moment this component learns what a filter is, it starts deciding your URL on your behalf.
activeCount cannot be quietly wrong. The props are a discriminated union — { activeCount: 0 } on its own, or { activeCount: number; onClear; clearLabel }. A count a screen COMPUTES is a number, and number is not assignable to 0, so any screen that can have an active filter has to compile with a way to clear it. That is the receptionist's real failure — four filters, one left set, the call log then reported as broken — turned into a type error rather than a code review.
Below md the controls collapse behind a control that names itself, never behind a bare icon: a real <button> carrying aria-expanded and aria-controls, printing label and the count ("Filtry (3)"). It is a disclosure over ONE subtree rather than a sheet. A sheet would have to render your controls a second time — two elements with the same id, so every <label htmlFor> on the screen names the wrong one — and would unmount them on close; here they are hidden with display, so a half-typed query and an open combobox survive the collapse.
The clear button is a SIBLING of the trigger, never a child of the collapsed panel. It is drawn at every width the moment activeCount > 0, so the state that hides the CAUSE — a filter left set behind a closed panel — can never also hide the REMEDY. It shipped inside the panel for one round, and that revoked the type union above: an affordance the compiler makes unskippable and the layout then takes away is a guarantee that holds in review and fails on the phone. Melio, Klarna and Dribbble all keep the active-filter row and its clear control in the open with the filter popover closed. apps/docs/filter-bar-runtime.test.tsx → "keeps the way out OUT of the disclosure — no hidden ancestor while closed" reads the rendered tree and fails on any hidden ancestor, so this is a gate rather than a promise.
The count renders in exactly ONE place at any width, and the two sites are exact breakpoint complements: on the trigger while the controls are hidden (md:hidden), on the clear button while they are visible (hidden md:inline). Both controls share one row on a 390 px phone, and "Filtry (3)" beside "Zrušit filtry (3)" prints the same number twice within a thumb's width — which does not say it louder, it asks the reader whether the two numbers are the same number. It goes through formatNumber from @ansora/core, never through string concatenation.
The strip is one wrapping row at every width. The panel carries w-full md:w-auto, so below md it takes a line of its own the moment it opens and takes none at all while it is closed — visual order equals DOM order in all three states, with no order-* utility and therefore no gap between the reading order and the tab order. If you pass a className that changes display or flex-direction on the root, you own that property again.
It positions nothing — sticky top-0 z-20 is your layout's decision, the same rule @ansora/session-bar states. Every visible word is a prop; the component ships no Czech string of its own.
"use client"
import { FilterBar } from "@/components/ansora/filter-bar"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
// `active` is a number, so the type demands `onClear` and `clearLabel` too.
const active = Object.values(params).filter((v) => v !== "vse").length
<FilterBar
label="Filtry"
activeCount={active}
clearLabel="Zrušit filtry"
onClear={() => router.replace(pathname)}
>
<Select value={params.stav} onValueChange={(v) => setParam("stav", v)}>
<SelectTrigger aria-label="Stav rezervace">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="vse">Všechny stavy</SelectItem>
<SelectItem value="potvrzeno">Potvrzeno</SelectItem>
</SelectContent>
</Select>
</FilterBar>
Files
registry/ansora/components/filter-bar.tsx→@components/ansora/filter-bar.tsx
Dependencies
@ansora/base
Event timeline
A read-only, ordered record of what happened to one record — a pre-formatted time, a title, who did it and an optional body, grouped by consecutive day labels, in a scroll region of its own.
Ansora KPI strip
A row of KPI cells in ONE card with internal dividers, not N cards with gaps. Scrolls horizontally rather than wrapping. Knows no domain — labels, values, icons and tones all come from props.