Filter Row
The chips under a ListToolbar — + Filter, dashed pinned facets, the other chips and Clear, plus a line saying what an AI filter run added.
- Status
- Since
0.25.100- Accessibility pattern
- named group; AI chips marked; polite AI note
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Filter Row before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify filter-rowInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/filter-rowRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/button, @vegastack/calendar, @vegastack/chip, @vegastack/date-time, @vegastack/list-toolbar, @vegastack/people-picker, @vegastack/popover, @vegastack/searchable-select.
Usage
import { AddFilterMenu } from "@/components/ui/add-filter-menu";
import { FilterRow, ListFacet } from "@/components/ui/filter-row";
<ListToolbar
filterRow={
<FilterRow
addFilter={<AddFilterMenu fields={fields} onPick={addFilter} ai={ai} />}
facets={
<ListFacet
label="Status"
multiple
items={statuses}
value={status}
onValueChange={setStatus}
/>
}
chips={chips}
aiNote={note}
onClear={clearAll}
/>
}
/>;The order never changes, so chips never jump:
+ Filter(addFilter), pinned first — also on a phone, where the row scrolls.- Pinned facets (
facets), in the page's order. Unset, a facet is a dashed outline that reads as an offer (+ Due); set, it is a solid chip. A facet with a custom editor is anunsetFilterChip(below). - Other chips (
chips), in the order they were added — from + Filter, a preset or the AI. A preset resolves to ordinary chips; there is no separate preset chip. - Clear, at the far end, only while something is set.
Chips made by an AI filter (source: "ai") lead with ✦ until the filter is edited. After an AI run
the host passes aiNote: one line under the row — "✦ 2 filters added by AI · Undo · Couldn't match
“warehouse”" — until the next manual change or its ×. Undo removes exactly that run's chips.
Unknown words are listed, never guessed.
From an @3xl row the chips wrap; below it they are one sideways-scrolling line with an edge fade,
+ Filter and Clear pinned at its two ends. The row counts what is applied (its chips plus its set
facets) and tells the enclosing ListToolbar, whose Filter tool
shows the count.
Anatomy
Filter Row is a compound component: the row, and the facet chips that sit in it. Every exported
part, with the data-slot it renders (generated from the canonical source):
Examples
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
activeCount | number | — | The number of filters applied. Omitted, the row counts its chips and its set facets (and
tells the enclosing ListToolbar, whose Filter tool shows it). |
addFilter | React.ReactNode | — | + Filter — an AddFilterMenu. Always first. |
aiNote | FilterRowAiNote | — | The line under the row after an AI filter run. |
chips | readonly FilterRowChip[] | [] | The other applied filters, in the order they were added. |
facets | React.ReactNode | — | The page's pinned facets, in its order: ListFacet, PeopleFacet,
DateRangeFilter, or an unset FilterChip that opens its own editor. |
labels | Partial<FilterRowLabels> | English | The component's words. |
onClear | (() => void) | — | Clear every filter; Clear shows while anything is set. |
Data attributes and CSS variables on FilterRow
| Attribute | Values |
|---|---|
data-filter-id | mirrors a prop or state value |
data-slot | "filter-row" | "filter-row-add" | "filter-row-ai-dismiss" | "filter-row-ai-note" | "filter-row-ai-undo" | "filter-row-ai-unmatched" | "filter-row-chips" | "filter-row-clear" | "filter-row-scroll" |
Chip
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | Stable id. |
label* | React.ReactNode | — | The filter's name ("Priority"). |
onRemove* | () => void | — | Remove the filter. |
defaultEditorOpen | boolean | false | Open the editor as the chip mounts — a filter just picked from + Filter. |
editor | React.ReactNode | — | The editor that opens from the chip (a value picker). |
icon | React.ReactNode | — | A leading icon. |
source | "ai" | "user" | "user" | ai when an AI filter made it (a ✦ until it is edited). |
value | React.ReactNode | — | The value summary ("High"). |
AI note
| Prop | Type | Default | Description |
|---|---|---|---|
count* | number | — | How many filters it added. |
onUndo* | () => void | — | Remove exactly the filters that run added. |
onDismiss | (() => void) | — | Hide the line (the next manual change does it too — the host decides). |
unmatched | readonly string[] | [] | The words it could not turn into a filter — listed, never guessed. |
ListFacet
One filter on SearchableSelect, in the row's one chip
shape: compact (h-7), rounded-md, quiet type. Unset it reads just its label (Status ▾); set it is
tinted and reads Status: Open, then Status: 2 from two values (the values in the chip's tooltip
and ticked in the popover), with a × named "Clear Status" in the chevron's fixed slot. Use short,
sentence-case labels; never "Any" or "All statuses". The search box shows only when the list is
worth searching (more than 7 options, or a remote list); searchable forces it on or off.
It passes SearchableSelect's props through (minus the trigger face it owns: renderItem,
renderValue, renderTriggerValue, placeholder, variant, size, aria-label, groupBy
and groupOrder) — multiple, remote with useAsyncSearch, leadingItems,
itemToDescription — and adds pinSelected (the chosen options first, under "Selected") and
removable with onRemove. The tick matches by itemToKey, so options from a server search tick
as static ones do. For people, pass itemToSecondaryLabel={(p) => p.email} (and itemToAvatar):
each option is one PersonOption (the person contract).
| Prop | Type | Default | Description |
|---|---|---|---|
label* | string | — | The facet's name, in sentence case ("Status", "Due date"). An unset facet reads just this; a set one reads "{label}: {value}". |
anyLabel | string | — | Deprecated. |
moreGroupLabel | string | "More" | The heading of the group under the pinned one. |
onRemove | (() => void) | — | Called when a removable facet is removed or cleared. |
pinSelected | boolean | false | List the selected options first, under "Selected", above the rest under "More". |
removable | boolean | false | A secondary facet added from "More": clearing it also calls onRemove, and while it is unset
a remove control sits beside it. |
removeLabel | string | `Remove ${label} filter` | Accessible name of the remove control beside an unset removable facet. |
renderItem | ((item: Item) => React.ReactNode) | itemToStringLabel | Renders one option. Not used with the person contract (itemToSecondaryLabel /
itemToAvatar), which draws avatar, name and email once; passing both warns in development. |
searchable | boolean | — | Show the search box in the panel. Unset, it shows only when the list is worth searching: more
than 7 options, or a remote (server-searched) list. Pass true or false to override. |
selectedGroupLabel | string | "Selected" | The heading of the pinned group. |
PeopleFacet
A people filter: the facet chip ("Assignee", "Assignee: Asha Rao", "Assignee: 2") over the one
people picker (PeoplePickerContent), so a filter lists people
exactly as every other picker does. leadingOptions adds rows such as "Unassigned".
| Prop | Type | Default | Description |
|---|---|---|---|
label* | string | — | The facet's name, in sentence case ("Assignee", "Recorded by"). |
onValueChange* | (value: PeoplePickerValue<Multiple>) => void | — | Called with the new choice (null, or [] with multiple, when cleared). |
value* | PeoplePickerValue<Multiple> | — | The chosen person — or people, with multiple (controlled). |
actions | readonly PeoplePickerAction[] | [] | Static actions in the footer under the results. |
clearLabel | string | `Clear ${label}` | The clear control's accessible name. |
countLabel | ((n: number) => string) | (n) => String(n) | The chip's count for several people. |
defaultSearch | string | — | The search text the list starts from instead of "" — the character that opened a cell
editor (type-to-edit). PeoplePicker also opens with it, once, on mount. |
disabled | boolean | false | Disable the facet. |
emptyText | string | "No people found" | Shown when nothing matches. |
exclude | readonly string[] | [] | Ids never offered (a member cannot manage themselves). |
heading | string | — | A label over the rows ("Your agents"). |
inactiveLabel | string | "Inactive" | The badge on an inactive person (with includeInactive). |
includeInactive | boolean | false | List people with active: false, badged "Inactive". |
leadingOptions | readonly PeoplePickerOption[] | [] | Options always listed first and never filtered out — "Unassigned" in a filter. Give each an
icon. |
multiple | Multiple | false | Filter by several people. |
onOpenChange | ((open: boolean) => void) | — | Called when the popover opens or closes — start a remote search on the first open. |
onRemove | (() => void) | — | Called when a removable facet is removed or cleared. |
options | readonly PeoplePickerOption[] | — | A fixed list instead of search, filtered here by name, email and description. |
removable | boolean | false | A secondary facet added from "More": clearing it also calls onRemove. |
removeLabel | string | `Remove ${label} filter` | The remove control's accessible name. |
search | PeoplePickerSearch | — | Finds people and teams for the typed query ("" on open), debounced and race-safe; return
nextCursor to page on scroll. Leave out anyone who cannot be chosen. |
searchPlaceholder | string | "Search people" | The search field's placeholder and accessible name. |
selected | readonly string[] | [] | Ids shown with a check (the current choice). |
teamLabel | ((memberCount: number) => string) | (n) => `Team · ${n} ${n === 1 ? "person" : "people"}` | A team's second line. |
viewerId | string | null | — | The viewer's id: their row reads "(you)" and is listed first. |
youLabel | string | "(you)" | The viewer's marker after their name. |
Data attributes and CSS variables on PeopleFacet
| Attribute | Values |
|---|---|
data-placeholder | "" |
data-slot | "list-facet" | "list-facet-clear" | "list-facet-remove" | "list-facet-select-trigger" |
data-state | "set" | "unset" |
DateRangeFilter
A date-range chip in the same shape as a facet. Presets: Today · Yesterday · Last 7 days · Last 30
days · This month · Last month · Custom range (a two-month calendar with Apply). Set, it reads the
preset ("Due: Last 7 days") or the dates from the date-time helpers ("Due: Sep 1–25"). Pass now
in the same calendar-date convention as the range endpoints; it drives both the presets and the
calendar's Today highlight. dateRangeForPreset(preset, now) returns the { from, to } a preset
covers, for the query. The calendar loads lazily — fetched when the chip is hovered, focused or
opened — so a filter row never puts the calendar engine in the page's first bundle.
| Prop | Type | Default | Description |
|---|---|---|---|
label* | string | — | The filter's name, in sentence case ("Due date", "Created"). |
onValueChange* | (value: DateRangeFilterValue | null) => void | — | Called with the new range, or null when cleared. |
value* | DateRangeFilterValue | null | — | The applied range, or null when unset (controlled). |
clearLabel | string | `Clear ${label}` | Accessible name of the clear control. |
disabled | boolean | false | Disable the filter. |
locale | string | "en-US" | The locale of the range label. |
now | Date | new Date() | "Today", for the presets, the custom calendar highlight and the label's year rule. |
presetLabels | Partial<Record<DateRangeFilterPreset, string>> | English ("Today", "Yesterday", "Last 7 days", …, "Custom range") | Relabel presets — for another language. |
presets | DateRangeFilterPreset[] | ["today", "yesterday", "last7", "last30", "thisMonth", "lastMonth", "custom"] | Which presets to offer, in order. "custom" opens the calendar. |
timeZone | string | the runtime's zone | The zone the range label's days are written in (the date-time helpers' timeZone). |
Data attributes and CSS variables on DateRangeFilter
| Attribute | Values |
|---|---|
data-slot | "date-range-filter" | "date-range-filter-clear" | "date-range-filter-content" | "date-range-filter-presets" | "date-range-filter-trigger" |
data-state | "set" | "unset" |
FilterChip
The removable "Label: value" pill FilterRow draws for each of its chips — the
Chip primitive in the row's chip shape — exported for a custom row.
Give it an editor and its label and value become a button (filter-chip-trigger) that opens the
editor in a popover; Escape closes it and returns focus to the chip, and the × stays its
own 24px target. unset draws a pinned facet with no value yet as a dashed + Label offer.
| Prop | Type | Default | Description |
|---|---|---|---|
label* | React.ReactNode | — | The filter's name (muted leading text). |
active | boolean | true | Whether the chip reads as an active selection. An active chip takes
accent; an inactive chip keeps the rest fill (muted). |
ai | boolean | false | Made by an AI filter: a ✦ leads the chip until the filter is edited (the host clears it). |
defaultEditorOpen | boolean | false | Open the editor when the chip mounts — a filter just added from "More". |
editor | React.ReactNode | — | Content opened from the chip's label and value, which become a button (FilterChipTrigger). |
icon | React.ReactNode | — | Optional leading icon. |
onEditorOpenChange | ((open: boolean) => void) | — | Called when the editor opens or closes. |
onRemove | (() => void) | — | Invoked when the remove (×) control is activated. An unset chip has none. |
removeLabel | string | — | Accessible name for the remove control. Defaults to Clear <label> when label is a
string. |
unset | boolean | false | A pinned facet with no value yet: a dashed + Label offer instead of a value, with no ×.
Its editor opens from it. |
value | React.ReactNode | — | Optional value summary, rendered after label separated by a colon. |
Data attributes and CSS variables on FilterChip
| Attribute | Values |
|---|---|
data-slot | "filter-chip" | "filter-chip-ai" | "filter-chip-trigger" |
data-source | "ai" |
data-state | "unset" |
ListFilter
A descriptive applied-filter model (id, label, value, icon, editor, onRemove) for hosts
that keep their filters as data and map them to chips.
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | Stable identity for the chip (used as the React key and data-filter-id). |
label* | React.ReactNode | — | The filter's name in sentence case (e.g. "Status"). Rendered as the muted leading text
of the chip. |
onRemove* | () => void | — | Invoked when the chip's remove (×) control is activated. |
active | boolean | true | Whether the chip reads as an active selection (the accent selected fill).
An applied filter is a selection, so this defaults to true; set false
for a presence-only chip on the rest fill. |
editor | React.ReactNode | — | Edit the filter in place: the chip's label and value become a button that opens this content in a popover (a facet list, a date range, a number range). The remove control stays its own target. |
icon | React.ReactNode | — | Optional leading icon — a single lucide-react / @vegastack/design/icons element. |
value | React.ReactNode | — | Optional human-readable value summary (e.g. "In Progress" or
"2 selected"). Rendered after label, separated by a colon. Omit for a
presence-only filter. |
Accessibility
- The chips are a
groupnamed "Filters". Each chip's × is named "Clear" and the filter's label. - An AI chip's ✦ is labelled "Added by AI", so the mark is read with the chip.
- The AI note is a polite
status: it is announced once after a run. Undo and the dismiss × are buttons. - Clear hands focus back to the row's first control once it unmounts.
- A
ListFacet,PeopleFacetorDateRangeFilteris a button named by what it reads ("Status", "Status: Open"); its × is named "Clear" and the label, and Escape closes its popover and returns focus to it.
| Key | Action |
|---|---|
| Tab | Move through + Filter, chips, Clear |
| Enter / Space | Open a chip's editor, remove, clear |
| Contract | States tested |
|---|---|
| Behaviour | default, set, ai, active, disabled, facet, editing |
| Accessibility | labeled, browser-accessibility-test |
| Visual | default, hover |
Do / Don't
List Toolbar
The one header row of every list and table — tabs left; search, icon tools and the main button right — collapsing in a fixed, measured order.
Add Filter Menu
+ Filter — one input that narrows the fields or sends a plain-language request to the page's AI filter; fields open their pickers in place.