Filter bar
StableLive search and multi-select quick filters for tasks, and the Display popover for grouping, ordering and properties.
FilterBar is a search landmark with a search field and quick filters for assignee, label and priority; each filter takes several values and shows a count. It is controlled: you keep the Filters object and test tasks with matchesFilters. DisplayOptions is the “Display” button: grouping, ordering and empty groups for the list, and which properties rows and cards show, kept in a ViewPrefs object.
Updated View source
Search and filters
import { useRef, useState } from 'react'import { sampleTasks } from '@rchursin/product/data/sample'import { FilterBar, labelIndex, matchesFilters, NO_FILTERS, PriorityIcon, StatusIcon, type Filters } from '@rchursin/product/tasks'
const NOW = new Date(2026, 9, 13, 9, 30)const tasks = sampleTasks(NOW)const labels = labelIndex([])
/** * Search and quick filters over the twelve sample tasks, starting with Urgent and High. * Esc clears the search, then hands focus to the results. */export default function FilterBarExample() { const [filters, setFilters] = useState<Filters>({ ...NO_FILTERS, priorities: [1, 2] }) const search = useRef<HTMLInputElement>(null) const results = useRef<HTMLUListElement>(null) const matches = tasks.filter((t) => matchesFilters(t, filters, labels.name))
return ( <div className="w-full overflow-hidden rounded-xl bg-surface ring-1 ring-line">Usage
import { DisplayOptions, FilterBar, matchesFilters, NO_FILTERS } from '@rchursin/product/tasks'const [filters, setFilters] = useState(NO_FILTERS)const shown = tasks.filter((t) => matchesFilters(t, filters, labelName))
<FilterBar filters={filters} onChange={setFilters} labels={labels} shown={shown.length} total={tasks.length} searchRef={searchRef} onClear={() => setFilters(NO_FILTERS)} onSearchExit={focusList}/><DisplayOptions prefs={prefs} onChange={setPrefs} />Examples
Display options
The summary below follows every change. Switch to Board to see what the board keeps.
import { useState } from 'react'import { DEFAULT_PREFS, DisplayOptions, groupByNames, orderingNames, PROP_KEYS, propNames, type ViewMode, type ViewPrefs } from '@rchursin/product/tasks'import { Segmented } from '@rchursin/product/ui'
/** Display options change how the list is drawn, never the tasks. The list view offers grouping and ordering; both views choose properties. */export default function DisplayOptionsExample() { const [prefs, setPrefs] = useState<ViewPrefs>(DEFAULT_PREFS) const shown = PROP_KEYS.filter((k) => prefs.props[k]).map((k) => propNames[k])
return ( <div className="flex w-full max-w-sm flex-col gap-5"> <div className="flex items-center gap-2"> <Segmented<ViewMode> size="sm" label="View" value={prefs.view} onChange={(view) => setPrefs({ ...prefs, view })} options={[ { value: 'list', label: 'List', icon: 'list' }, { value: 'board', label: 'Board', icon: 'board' }, ]} /> <DisplayOptions prefs={prefs} onChange={setPrefs} /> </div> <dl className="grid grid-cols-[7rem_minmax(0,1fr)] gap-x-3 gap-y-2 text-[13px]"> <dt className="text-fg-3">Grouping</dt> <dd>{prefs.view === 'list' ? groupByNames[prefs.groupBy] : 'Columns by status'}</dd> <dt className="text-fg-3">Ordering</dt> <dd>{prefs.view === 'list' ? orderingNames[prefs.ordering] : 'As you leave cards'}</dd> <dt className="text-fg-3">Empty groups</dt> <dd>{prefs.showEmpty ? 'Shown' : 'Hidden'}</dd> <dt className="text-fg-3">Properties</dt> <dd className="text-pretty">{shown.length ? shown.join(', ') : 'None'}</dd> </dl> </div> )}API
FilterBar props
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | 'Filter tasks' | Name of the search landmark. Keep it unique on the page. |
filtersrequired | Filters | – | The current filters. |
onChangerequired | (f: Filters) => void | – | Called with the whole new Filters object on every change. |
labelsrequired | Label[] | – | The labels the Label filter offers. |
shownrequired | number | – | How many tasks match, for “4 of 12”. |
totalrequired | number | – | How many tasks there are. |
searchRefrequired | Ref<HTMLInputElement> | – | The search field, so a shortcut can focus it (TasksApp binds “/”). |
onClearrequired | () => void | – | The Clear button, shown while any filter is on. It disappears once used, so move focus to the search. |
onSearchExitrequired | () => void | – | Esc in an empty search field: hand focus back to the list. |
Filters
| Prop | Type | Default | Description |
|---|---|---|---|
queryrequired | string | – | Matches the id, title, subject and label names, ignoring case. |
assigneesrequired | string[] | – | Person ids, or “none” for unassigned. |
labelsrequired | string[] | – | Label ids; a task matches if it has any of them. |
prioritiesrequired | PriorityId[] | – | 0 none, 1 urgent, 2 high, 3 medium, 4 low. |
DisplayOptions props
| Prop | Type | Default | Description |
|---|---|---|---|
prefsrequired | ViewPrefs | – | The current display settings. |
onChangerequired | (next: ViewPrefs) => void | – | Called with the whole new ViewPrefs on every change. Reset display sends DEFAULT_PREFS with the current view kept. |
ViewPrefs
| Prop | Type | Default | Description |
|---|---|---|---|
viewrequired | 'list' | 'board' | – | Which view is showing. The popover offers grouping and ordering only for the list. |
groupByrequired | 'status' | 'assignee' | 'priority' | – | How the list is grouped. |
orderingrequired | 'manual' | 'priority' | 'due' | 'created' | – | Order inside each group. |
showEmptyrequired | boolean | – | Show groups with no tasks. |
propsrequired | Record<PropKey, boolean> | – | ID, priority, status, labels, due date, estimate and assignee, each on or off. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Esc | In the search: clear it. When it is already empty, hand focus back (onSearchExit). |
| EnterSpace | On a filter or Display: open its popover. |
| Up arrowDown arrow | In a filter: move through the options while focus stays in its search field. |
| Enter | In a filter: turn the highlighted option on or off. |
| Esc | In a popover: close it; focus goes back to its button. |
- A role="search" landmark named by label. The field is a type="search" input named “Search tasks”.
- Each filter is a button named with its count, like “Filter by priority, 2 selected”, with aria-haspopup="dialog" and aria-expanded. The visible count chip is hidden from screen readers because the name says it.
- Filter options are a combobox and listbox (aria-multiselectable); the highlight moves with aria-activedescendant.
- Display options: the button is named “Display options” (its word hides on phones). Grouping and Ordering are native selects tied to their labels by ids from useId, so every copy on a page keeps its own pairs. Property toggles are buttons with aria-pressed inside a fieldset with a legend, drawn solid when on and dashed when off.
- FilterBar binds no page keys itself. TasksApp adds “/” for the search and announces the result count once typing settles.
Guidelines
Do
- Keep the filters in state you own (or the URL); FilterBar is fully controlled.
- Show the “4 of 12” count, so people know a filter is hiding something.
Don’t
- Don’t give two FilterBars the same label; each is a landmark.
- Don’t add a grouping control for the board: it always has a column per status, so DisplayOptions offers grouping and ordering for the list only.
In use
Related
- TasksThe whole task tracker: a keyboard-first list and board, a detail view, bulk edits, and undo for every change.
- Task listThe task list as a data grid: one Tab stop, arrows to move, pickers in the cells, and groups that collapse.
- PickerA filterable list of options, on its own or in a popover: type to filter, arrows and Enter to choose, or 1–9 to pick at once.
- Text inputA one-line field in the inset lux-input finish, in two sizes, with an optional icon inside and a slot on the right.
- PopoverA panel beside its trigger, on the native popover API: never clipped, closed by Esc or a click outside, and it hands focus back.
Source
The component’s own files, exactly as they run on this site.
/** * The bar above the list: live search ("/" focuses it), quick filters by assignee, label and * priority, and the display options (grouping, ordering, empty groups, which properties show). */import { useId, type KeyboardEvent, type ReactNode, type Ref } from "react"import { cx } from "../../components/cx"import { people, priorities, type PriorityId } from "../data/sample"import { Icon, type IconName } from "../icons/Icon"import { Button, Kbd } from "../ui/button"import { Select, Switch, TextInput } from "../ui/fields"import { Avatar } from "../ui/misc"import { PickerButton, type PickerOption } from "../ui/picker"import { Popover, usePopover } from "../ui/popover"import { DEFAULT_PREFS, groupByNames, hasFilters, orderingNames, PROP_KEYS, propNames,