Filter Toolbar

The filter row of a list page: a search box, the most used filter inline, the rest behind "Filters" — a popover on a wide screen, a bottom sheet on a phone — and what applies as removable chips. Values in, changes out, so it works over state, a store or the URL.

src/components/ui/filter-toolbar.tsx

About

From the component's own source, where it was written.

The filter pattern of a list page, in one row.

A search box takes the free width; the filter used most sits inline beside it — a SegmentedControl, a Select; the rest go behind "Filters", in a popover on a wide screen and a bottom sheet on a phone; and whatever applies shows underneath as chips that can each be removed. Every list that grows filters one at a time ends up with a row of eight selects that wraps into three lines on a laptop. This is the shape it should have grown into.

Nothing here knows where filter state lives. Values come in and changes go out, so it works the same over component state, a store or the URL — useFilterParams is the URL case, written against a plain URLSearchParams so any router can supply one.

Install

npx astralyx-ui add filter-toolbar

Usage

import { FilterField, FilterToolbar } from '@/components/ui/filter-toolbar'

<FilterToolbar
  search={{ value: q, onChange: setQ, placeholder: 'Search tickets' }}
  quick={<SegmentedControl label="Status" options={statuses} value={status} onValueChange={setStatus} />}
  panel={
    <FilterField label="Region">
      <Select options={regions} value={region} onValueChange={setRegion} />
    </FilterField>
  }
  panelCount={region ? 1 : 0}
  chips={chips}
  onReset={reset}
/>

Examples

Over component state

In the URL

API

Generated from the source, so it cannot fall behind the code. One table per exported component, then hooks, functions and types.

FilterToolbar

Extends Omit<FilterPanelProps, 'activeCount' | 'onReset' | 'children'>, Omit<FilterChipsProps, 'chips' | 'onClearAll' | 'onEmpty' | 'className'>.

PropTypeDefaultDescription
search{ value: string onChange: (value: string) => void placeholder?: string /** Accessible name. Defaults to `searchLabel`. */ label?: string /** Monospace input, for ids and hashes. */ mono?: boolean maxLength?: number }—The search box. It takes the free width of the row and wraps above the rest on a narrow screen.
quickReactNode—The one filter used most, inline. Scrolls sideways rather than wrapping when the row is tight.
panelReactNode—The rest, behind the Filters button, as FilterField each. A popover from sm up and a bottom sheet below — on a phone a popover is a screen-wide box whose Done button sits under the keyboard.
panelCountnumber0How many of the panel's filters apply. Shown on the button, so a filter hidden in the panel is never forgotten.
chipsFilterChip[][]{ key, label, onRemove } for each filter that applies, wherever it lives. Removing one keeps focus in the row — on the next chip, then Clear all, then the search box.
onReset() => voidrequiredClears every filter. Behind Reset in the panel and Clear all under the chips.
classNamestring——
searchLabelstring'Search'Visible and accessible text, in English by default.
filtersLabelstring—Visible and accessible text, in English by default.
activeCountLabel(count: number) => string—Accessible names that carry a value: "Remove Status: open", "2 active".
resetLabelstring—Visible and accessible text, in English by default.
doneLabelstring—Visible and accessible text, in English by default.
closeLabelstring—The sheet's close button, on a phone.
clearAllLabelstring—Visible and accessible text, in English by default.
removeLabel(label: string) => string—Accessible names that carry a value: "Remove Status: open", "2 active".
activeLabelstring—Accessible name of the group.

FilterField

A caption over one control in the panel. htmlFor makes it a real label; wide spans both columns.

PropTypeDefaultDescription
labelReactNoderequired—
htmlForstring—The control's id, so the label is a real <label>.
widebooleanfalse—
classNamestring——
childrenReactNoderequired—

FilterPanel

The two halves on their own, for a layout the toolbar does not fit.

PropTypeDefaultDescription
activeCountnumberrequiredHow many of the panel's filters apply — shown as a count on the button.
onReset() => voidrequiredClears every filter. Behind Reset in the panel and Clear all under the chips.
childrenReactNoderequired—
filtersLabelstring'Filters'Visible and accessible text, in English by default.
activeCountLabel(count: number) => stringDEFAULT_ACTIVE_COUNTAccessible names that carry a value: "Remove Status: open", "2 active".
resetLabelstring'Reset'Visible and accessible text, in English by default.
doneLabelstring'Done'Visible and accessible text, in English by default.
closeLabelstring'Close'The sheet's close button, on a phone.

FilterChips

The two halves on their own, for a layout the toolbar does not fit.

PropTypeDefaultDescription
chipsFilterChip[]required{ key, label, onRemove } for each filter that applies, wherever it lives. Removing one keeps focus in the row — on the next chip, then Clear all, then the search box.
onClearAll() => voidrequired—
onEmpty() => void—Where focus goes when the last chip is removed — the search field, say.
classNamestring——
clearAllLabelstring'Clear all'Visible and accessible text, in English by default.
removeLabel(label: string) => stringDEFAULT_REMOVEAccessible names that carry a value: "Remove Status: open", "2 active".
activeLabelstring'Active filters'Accessible name of the group.

Hooks

HookSignatureDescription
useFilterParams<K extends string>(keys: readonly K[], { params, onParamsChange, checks = {}, }: { params: URLSearchParams onParamsChange: (next: URLSearchParams) => void checks?: Partial<Record<K, FilterCheck>> }) => { values: Record<K, string>; offset: number; set: (key: 'offset' | K, value: FilterValue) => void; setMany: (patch: Partial<Record<'offset' | K, FilterValue>>) => void; clear: () => void; active: boolean; }Filters in the query string, against a plain URLSearchParams so any router can supply one. Changing a filter drops offset; clear removes only its own keys.

Functions

FunctionSignatureDescription
FILTER_ANY'__any'"Any" for a Select, which cannot hold an empty value. Treated as unset.
filterChecks{ oneOf: (options: readonly string[]) => FilterCheck; matches: (pattern: RegExp) => FilterCheck; maxLength: (max: number) => FilterCheck; uuid: FilterCheck; isoDateTime: FilterCheck; flag: FilterCheck; }Ready-made checks for useFilterParams.
parseOffset(raw: string | null) => number?offset= as a safe whole number. Anything else is the first page.

Types

FilterChip

One applied filter, as a removable chip: "Status: failed ×".

FieldTypeDescription
keystring—
labelstring—
onRemove() => void—
TypeDefinitionDescription
FilterCheck
(value: string) => boolean
Checks one value read from the URL. A value that fails reads as unset.

Behaviour

TopicDetailDescription
checksPartial<Record<key, FilterCheck>>A link is input from anyone. A value that fails its check reads as unset and is removed from the URL. filterChecks has oneOf, matches, maxLength, uuid, isoDateTime and flag.