Search & Filter

The standard header of a list screen: a debounced search field, a slot for whatever filters the screen needs, an actions slot, and a result-count line that says how much of the data is left. FilterChips is its companion — a row of toggle chips with facet counts, for the one filter that deserves to stay visible.

Search on its own

The minimum useful bar: a controlled search term and a count. The count row only appears once there is something to count — a query or an active filter — and while it is there it holds a fixed min-h-7, so its own contents changing (the clear-filters button coming and going) never shoves the table up and down.

  • Draft the Q3 shipping contractNguyễn Đức Thành
  • Reconcile carrier invoicesTrần Mai Anh
  • Update the customs declaration templateLê Đăng Khoa
  • Archive 2024 booking recordsNguyễn Đức Thành
  • Review demurrage penaltiesPhạm Thu Hà
  • Onboard the Hai Phong depotTrần Mai Anh
  • Fix duplicated container numbersLê Đăng Khoa
  • Publish the Q2 utilisation reportPhạm Thu Hà
  • Migrate the legacy rate cardsNguyễn Đức Thành
  • Schedule the annual auditPhạm Thu Hà

The filtering here runs through matchesSearch from the same entry, over the title and the owner together, so the search is accent- and case-insensitive against Vietnamese text: type dang above and Lê Đăng Khoa's rows come back. Nothing forces you to use it — SearchFilterBar only hands you the string.

The full bar, with chips

Three filters over one dataset: free text, an owner Select in the children slot, and a row of status chips underneath. The chip counts are faceted — each is computed against the search and owner filters but not against the status filter itself, so a chip always tells you how many rows it would add. The result summary and the filter badge update as you go.

  • Draft the Q3 shipping contractNguyễn Đức Thành
  • Reconcile carrier invoicesTrần Mai Anh
  • Update the customs declaration templateLê Đăng Khoa
  • Archive 2024 booking recordsNguyễn Đức Thành
  • Review demurrage penaltiesPhạm Thu Hà
  • Onboard the Hai Phong depotTrần Mai Anh
  • Fix duplicated container numbersLê Đăng Khoa
  • Publish the Q2 utilisation reportPhạm Thu Hà
  • Migrate the legacy rate cardsNguyễn Đức Thành
  • Schedule the annual auditPhạm Thu Hà

activeFilterCount is a number you compute, not something the bar can infer — it has no idea what is in the children slot. It drives two things at once: the badge, and whether the clear-filters button is offered.

Debouncing — why the input is not your state

The field holds a local value and only pushes it up after debounceMs. Bind the input straight to page state instead and every keystroke re-renders the whole table, usually with a request attached. Type fast below and watch the call counter lag behind the text.

Committed value
—
onSearchChange calls
0
  • Outside changes still win. Press Reset from outside: the parent sets searchValue to '', and the bar notices the value is not the one it last emitted, cancels the pending timer and adopts it. Re-renders that carry the value it already emitted are ignored, so it never overwrites what you are half way through typing.
  • The × button is not debounced. Clearing the field cancels the timer and emits '' immediately — waiting 250ms to un-filter a list you just emptied feels broken.
  • debounceMs={0} for local filtering. If the rows are already in memory there is nothing to wait for.
  • onSearchChange is read through a ref, so passing a fresh inline arrow on every render does not reset the timer.

Strings: the labels contract

Every string this component can render — including the ones only a screen reader hears — follows the searchFilterBar namespace of ComitorLocaleProvider: English without one, Vietnamese under locale="vi". A Vietnamese app that forgets the provider but passes its own searchPlaceholder and unitLabel ends up with a half-translated bar — and the half that stays English includes the part nobody can see. labels is for wording one bar differently. Three rules govern the label sets across the whole package:

  1. It is a Partial<>. The component merges it over the provider's table, so overriding one key keeps the other five in the provider's language. There is no all-or-nothing dictionary to maintain.
  2. Keys that carry a number or a unit are functions, not templates. filterCount(count) and resultSummary(resultCount, total, unitLabel) receive the values and return the finished text. String concatenation on the app side is precisely how word order breaks when the language changes. The numbers arrive raw, and grouping the digits is part of that finished text: the two built-in tables print 12,345 of 1,234,567 in English and 12.345 / 1.234.567 in Vietnamese, while a function of your own receives 12345 and formats it itself, as the one below does. The grouping follows the table’s language, not the full locale: under locale="en-IN" the English table still prints 123,456. Before 3.1.0 every language printed the raw digits, so a test that matches them needs the separators now.
  3. A standalone prop beats labels. unitLabel and searchPlaceholder stay real props: labels is the language layer, those are the content layer — the same English app says “invoices” on one screen and “tasks” on the next.
task-bar.tsx
'use client'

import { SearchFilterBar } from '@comitor/ui'
import type { SearchFilterBarLabels } from '@comitor/ui'

// Language is not this object's job: every key you leave out follows ComitorLocaleProvider
// (English without one). labels rewords THIS bar — Partial<>, key by key — and it beats the
// provider in every language, so an app with more than one builds it from its own translations.
//
// Keys that interpolate a number or a unit are FUNCTIONS, not placeholder strings:
// concatenating in the app is how word order breaks in other languages. A function cannot
// cross from a Server Component into a client one, so this lives in a client module.
const TASK_LABELS: Partial<SearchFilterBarLabels> = {
  clearFilters: 'Show all tasks',
  // resultSummary returns a ReactNode so the number can carry its own element —
  // tabular-nums stops the digits from jumping column as you filter. The counts arrive
  // raw (12345): the built-in tables group them, and a function of your own does it itself.
  resultSummary: (resultCount, total, unitLabel) => (
    <>
      <strong className="font-medium text-foreground tabular-nums">{resultCount.toLocaleString('en')}</strong>
      {total !== undefined ? ` of ${total.toLocaleString('en')}` : ''} {unitLabel} match
    </>
  ),
}

<SearchFilterBar {...bar} unitLabel="tasks" labels={TASK_LABELS} />

Below, one bar three ways. The first is untouched; the second adds unitLabel="invoices", and the prop wins over labels.unitLabel; the third sits under a Vietnamese provider, and the whole bar changes language with nothing passed to it — the digit grouping included:

No provider: labels.unitLabel = "items"

12,345 of 1,234,567 items

+ unitLabel="invoices"

12,345 of 1,234,567 invoices

Under locale="vi": labels.unitLabel = "mục"

12.345 / 1.234.567 mục

DEFAULT_SEARCH_FILTER_BAR_LABELS — the English table; the Vietnamese one is comitorMessagesVi.searchFilterBar — is exported from a module with no 'use client' directive, deliberately: every export of a client module reaches a Server Component as a client-reference proxy that reads as undefined without throwing or warning. Plain data constants are exactly what server pages spread and compare against, so they live outside the client boundary.

SearchFilterBar props

PropTypeDefaultDescription
searchValuerequiredstring—The committed search term. Controlled — the input keeps its own local copy so typing never re-renders the table.
onSearchChangerequired(value: string) => void—Called with the search term after debounceMs of quiet. Read through a ref internally, so an inline arrow does not restart the timer.
searchPlaceholderstringlabels.searchPlaceholderPlaceholder text, also used verbatim as the input aria-label. Beats labels.searchPlaceholder.
debounceMsnumber250Delay before onSearchChange fires. 0 emits on every keystroke. Use 250–350 when typing triggers a request.
searchInputRefRef<HTMLInputElement>—Ref forwarded to the input — used to focus the field from a keyboard shortcut.
searchInputPropsComponentPropsWithoutRef<'input'>—Props spread onto the input (id, name, enterKeyHint, aria-describedby…). Spread last, so it wins over the internal props.
childrenReactNode—Filter slot rendered next to the search field — Select, Combobox, DatePicker, anything.
actionsReactNode—Right-hand slot, pushed to the far end with ml-auto — the create button, a column toggle.
activeFilterCountnumber0Number of filters currently on. Above 0 it shows the sliders badge and opens the result-count row.
onClearFilters() => void—Renders the clear-filters button in the count row. Only shown when activeFilterCount > 0.
resultCountnumber—Rows left after filtering. Feeds labels.resultSummary.
totalnumber—Row count before filtering. Omit it and the summary reads "3 items" instead of "3 of 57 items".
unitLabelstringlabels.unitLabelWhat the rows are called for this screen ("tasks", "invoices"). A content string, so it beats labels.unitLabel.
labelsPartial<SearchFilterBarLabels>from providerPer-key override of every visible and screen-reader string. Merged over the searchFilterBar table of ComitorLocaleProvider (English without one), so partial sets are fine.
classNamestring—Extra classes on the outer flex column.

SearchFilterBarLabels keys

Defaults are the English table, the same strings as DEFAULT_SEARCH_FILTER_BAR_LABELS; the component itself reads them from the provider.

PropTypeDefaultDescription
searchPlaceholderstring'Search…'Placeholder and aria-label of the search input. Vietnamese: “Tìm kiếm…”
unitLabelstring'items'Default noun in the result summary. The unitLabel prop overrides it. Vietnamese: “mục”
clearSearchstring'Clear search'sr-only text on the × button inside the search field. Nothing else can reach this string. Vietnamese: “Xoá từ khoá”
filterCount(count: number) => stringcount => `${count} ${count === 1 ? 'filter' : 'filters'}`Text of the active-filter badge. A function because the number is embedded in the sentence — and English has to choose between filter and filters, which Vietnamese does not: “2 bộ lọc”.
clearFiltersstring'Clear filters'Label of the clear-filters button in the count row. Vietnamese: “Xoá lọc”
resultSummary(resultCount, total, unitLabel) => ReactNode'12,345 of 1,234,567 items'The whole count sentence — the default renders the count, then " of total", then the unit, with the raw numbers it receives grouped for the language (Vietnamese: 12.345 / 1.234.567 mục). Returns a ReactNode so the number can be wrapped in its own tabular-nums element.

FilterChips: single select

Without multiple, the chips behave like a segmented control that can also be empty: clicking the active chip deselects it and emits null. A chip with no tone fills solid with the app accent when selected. Note that icon takes the component itself — icon={Flame}, never icon={<Flame />}: a Lucide icon is a forwardRef object, so a component and an element cannot be told apart at runtime and a wrong guess would fail silently.

Selected: null

Why FilterChips has no labels prop

Every other composite on this page carries a string of its own somewhere inside it — “Clear filters”, an sr-only on a button, a summary sentence. A label set exists to reach those buried strings.

FilterChips has none: the visible text of every chip arrives in items, which the app builds. The only two strings the component could own are the “all” chip and the group's aria-label, and both are already plain props — allLabel and label. Adding a dictionary would create a second place to set the same words, with no key that only a dictionary could reach.

Left unset, both follow the provider — “All” and “Quick filters” in English, “Tất cả” and “Bộ lọc nhanh” in Vietnamese. Pass them when the wording belongs to the screen, as the demos above do (“All statuses”, “Filter by status”). label is the one worth passing anyway, and the one easy to forget because it is never drawn: a group named for what it filters says more than “Quick filters”.

FilterChips props

PropTypeDefaultDescription
itemsrequiredFilterChipItem<T>[]—The chips. Each one carries its own label, count, icon and tone — which is why the component has no labels prop.
valuerequiredT[] | T | null—Current selection. Pass an array in multiple mode, a value or null in single mode.
onValueChangerequired(value: T[] | T | null) => void—Fires with the next selection. Clicking the active chip in single mode deselects it and emits null.
multiplebooleanfalseAllow several chips at once. It only changes what onValueChange emits — an array when set, a single value or null when not; whatever you pass as value is still rendered as-is.
showAllbooleanfalsePrepend an "all" chip that clears the selection. It reads as pressed exactly when nothing else is.
allLabelstring'All'Label of that chip. Unset, it follows ComitorLocaleProvider (the filterChips namespace; Tất cả in Vietnamese).
allCountnumber—Number shown before allLabel, usually the unfiltered row count.
labelstring'Quick filters'aria-label of the role="group" wrapper. Unset, it follows ComitorLocaleProvider (Bộ lọc nhanh in Vietnamese) — but name the group for what it filters.
classNamestring—Extra classes on the wrapping flex row.

FilterChipItem

PropTypeDefaultDescription
valuerequiredT—Identity of the chip and the value handed to onValueChange.
labelrequiredstring—Visible text. This is where the chip strings live — there is no label dictionary to override.
countnumber—Rendered semibold and tabular-nums before the label. Omit it entirely rather than passing 0 if the facet is unknown.
iconIconComponent—The icon COMPONENT, e.g. icon={Flame}. Rendered at size-3.5 with aria-hidden.
toneStatusTone—Colour of the chip while selected — spread one of STATUS_TONES. Without it the chip fills solid with the app accent.
disabledbooleanfalseSets the real disabled attribute: out of the tab order, pointer-events-none, handler never fires.

How the chips are coloured

A filter chip is a control with aria-pressed, so its boundary owes WCAG 1.4.11's 3:1 — and the boundary is all it has, because an unselected chip's fill (bg-card) is the page background in light mode and 1.10:1 off it in dark. That single fact rules out three tempting shortcuts:

  • Unselected. border-border is the decorative tier and sits at 1.16:1 light / 1.10:1 dark. The chip uses border-control-edge instead, the control tier: 3.83:1 and 4.64:1 with high contrast, still 3.50:1 and 4.21:1 once hover swaps the fill to bg-accent. In the default palette that tier is itself 1.16:1 light / 2.00:1 dark — a trade-off that palette accepts for control edges; high contrast is the fix, not a class here.
  • Selected, with a tone. border-transparent would drop the edge back to the tint itself (1.07–1.24:1) — leaving the selected chip fainter than the unselected ones. It uses tone.borderColor, the ink step: in the default palette 4.38–6.68:1 light and 4.06–11.90:1 dark across the coloured tones — except primary, whose ink step is the gold fill itself in light, 1.86:1 (6.51:1 with high contrast). (STATUS_TONES.neutral is the odd one out — its border is border-muted-foreground, 5.23:1 light / 5.70:1 dark: past the 3:1 this rule asks for, but not an ink step.) Because borderColor is optional on StatusTone, a tone that omits it falls back to border-control-edge — never to transparent.
  • Selected, no tone. A solid bg-app-accent fill is only 1.86:1 against white, because the default accent is gold — the classic trap of the colour contract. So it pairs the solid fill with border-app-accent-ink (6.51:1 light, 12.67:1 dark). In dark mode --app-accent-ink resolves to the accent itself, so the border vanishes into the fill — correct, since the fill already carries 12.67:1 there.

The text inside a selected untoned chip is text-app-accent-foreground on the solid fill, not accent-on-tint: gold text on a 15% gold wash measures 1.75:1.

Accessibility

  • The search field is type="search" and takes its aria-label from the placeholder, so it is still named when the placeholder is visually hidden by typing.
  • The browser's own clear button is hidden ([&::-webkit-search-cancel-button]:hidden) and replaced with a real <button> carrying labels.clearSearch as sr-only text — the native one is unlabelled and unreachable by keyboard in some browsers.
  • The result-count row is role="status", a polite live region: filtering announces “3 of 57 tasks” without stealing focus. That is the only feedback a screen-reader user gets that the list changed, which is why resultCount is worth passing even when the number is on screen.
  • Decorative icons — the magnifier, the sliders in the badge, the × glyphs — are all aria-hidden, so nothing is announced twice.
  • FilterChips renders a role="group" labelled by label, with each chip a <button aria-pressed>. It is deliberately not role="tab": chips toggle a filter condition, they do not swap a panel, and the tab role promises an aria-controls target that does not exist here.
  • A disabled chip uses the real disabled attribute — out of the tab order, pointer-events-none, handler never called — so its opacity-50 is exempt from the contrast minimum. That is the difference between it and a “locked” tile that is still focusable.
  • Whatever you put in children owes its own accessible name. A bare Select next to the search box reads as an unnamed combobox unless you give it one.