Facet Filter
The one surface behind every faceted filter — a trigger that counts, a listbox of values, and a way out.
Usage
import { FacetFilter } from "@kanzo-tech/ui";<FacetFilter
items={[{ value: "Thornmarch", label: "Thornmarch", count: 8 }]}
label="Region"
onValueChange={setValue}
value={value}
/>A facet filter is the control at the top of a table or a dashboard that narrows what you are
looking at: pick some values, see fewer rows. FacetFilter is only the surface. Where the values
come from and where the choice goes are yours — this library has two adapters over it,
DataTableFacetFilter reading TanStack's faceted counts and
ChartFilter publishing a Mosaic clause.
role="menu" is a list of commands and a filter is a value that lives in state. Column visibility
really is a view command that is nobody's data, so DataTableViewOptions stays a menu. Mis-roling
costs more than purity buys: a menuitemcheckbox list cannot announce "two of five selected", and a
menu has nowhere to grow a search field the day a column has hundreds of values.
Two behaviours differ from the menus this replaced, both deliberate. The popover is not modal:
ours defaults to modal, which marks the rest of the page aria-hidden, so the table being filtered
went unreadable to a screen reader at the moment it was being filtered. And deselectable is not
set on the listbox — the machine only unticks an already-selected value when it is true, and setting
it also binds Escape to a clear with stopPropagation(), which made the key everyone presses to back
out of a popover silently discard the filter.
FacetFilter sits on the root barrel because its two consumers are on subpaths that must not see
each other — /table would drag in Mosaic, /analytics would drag in TanStack — and being
presentational it needs neither.
What would reverse it: nothing on the roles. The unification of the two filters into one surface is reversed by two consumers whose surfaces genuinely diverge; on the day it was written they had independently rediscovered the same two list rules and commented them in nearly the same words.
Held by packages/ui/src/index.test.ts, "exposes the one facet-filter surface, on the root barrel";
packages/ui/src/simples/FacetFilter.test.tsx, "is a listbox and not a menu of checkbox items" and
"keeps the popover non-modal, so the table it filters stays reachable".
What it owns for you
Two rules that are invisible until they bite, which is why they live here and not in each adapter:
- A ticked value that has been faceted away stays listed. Filter the board to night work, and a region that posts none drops out of the data — but if it is still ticked it must stay on screen, or the filter is stuck with nothing to untick.
- Rows are ordered by label, never by count. Ordering by frequency reshuffles the list under the cursor every time another filter moves, so you tick the row that took the place of the one you read.
Both were independently rediscovered in the table layer and the charts layer before this component existed. That is the tell for behaviour that belongs to a shared surface rather than to its callers.
Searching a long list
A column with two hundred values needs a filter field. searchable puts one above the list — a
ListboxInput inside the same listbox, not a Combobox nested in
the popover, which would be two machines sharing one open-state.
It is a prop, and never a count threshold. A control that grows a search field once it passes n
values reshapes itself under the cursor: tick something, the crossfilter shrinks the facet, the field
disappears and every row moves up. The caller knows whether the column is region or member; the
component only knows how many rows survived the last filter, which is not the same question.
Both rules above survive the query:
- A ticked value is never hidden by it. Unticking is the only way back out, and a query that hides the row takes the untick with it.
- The surviving rows are still in label order.
Matching is case- and accent-insensitive — useFilter({ sensitivity: "base" }), the same call
Combobox makes — so vrana finds Ludmila Vrána. It matches the row's label, falling back to its
value when the label is not a string. The query is forgotten when the popover closes.
The popover opens with the field focused, so you can open it and start typing. That takes an explicit
initialFocusEl: zag aims a popover's opening focus at the content, and late enough that a keystroke
can arrive first — the key then reaches the list as typeahead and focus moves out from under the
caret. Without the field, focus lands on the list as before, where typeahead is the right behaviour.
Two ways to be empty
They are not the same news, so they do not read the same:
| Means | Prop | |
|---|---|---|
empty | the facet offers nothing at all — still loading, or no values under the current filters | "No values." |
searchEmpty | your query excluded everything the facet did offer | "No matching values." |
The second clears itself when you retype; the first does not, which is why an adapter that is still
fetching passes empty="Loading…".
Counts, notes and a cap
count on an item draws at the row's end. note is a footnote under the list — its reason for
existing is truncation: a GROUP BY over a high-cardinality column answers with thousands of rows,
so an adapter caps what it offers and says so, rather than pretending the tail is absent.
Truncation is also what makes searchable mean different things in the two adapters, and each one
says which:
DataTableFacetFilter— honest and total. The table already holds its rows, so the field narrows every value the column has.ChartFilter— the field narrows the fetched page, thelimitmost frequent values it asked for, and runs no query of its own. When the page is truncated the note and the no-match message name that scope, because a field that answers "no matching values" about a database it never asked is worse than no field. Searching the column isChartSearch, which publishes a match clause.
API Reference
FacetFilter
| Prop | Type | Default |
|---|---|---|
items | readonly FacetFilterItem[] | — |
value | readonly string[] | — |
onValueChange | (next: string[]) => void | — |
label | React.ReactNode | — |
multiple | boolean | true |
searchable | boolean | false |
searchPlaceholder | string | "Filter values…" |
searchLabel | string | "Filter values" |
searchEmpty | React.ReactNode | "No matching values." |
empty | React.ReactNode | "No values." |
note | React.ReactNode | — |
contentClassName | string | — |
Everything else passes through to the trigger Button — size, variant, disabled,
className. FacetFilterItem is { value, label?, icon?, count? }.
It is controlled by design: the value belongs to the thing being filtered — a table's column state,
a crossfilter's clause — not to the control. multiple={false} keeps at most one value ticked.
FacetFilterProps is exported, so a wrapper can take the same props without restating them.