Composites

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 is Vietnamese by default — including the ones only a screen reader hears. An English app that skips this ends up with a half-translated bar, and the half that stays Vietnamese is the half nobody can see. Three rules govern the label sets across the whole package:

  1. It is a Partial<>. The component always spreads { ...DEFAULT_SEARCH_FILTER_BAR_LABELS, ...labels }, so overriding one key keeps the other five. 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.
  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.
en-labels.tsx
import type { SearchFilterBarLabels } from '@comitor/ui'

// Partial<> — every key you omit keeps its packaged Vietnamese default.
// 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.
export const EN_SEARCH_LABELS: Partial<SearchFilterBarLabels> = {
  searchPlaceholder: 'Search…',
  unitLabel: 'items',
  clearSearch: 'Clear search',
  filterCount: (count) => `${count} ${count === 1 ? 'filter' : 'filters'}`,
  clearFilters: 'Clear filters',
  // resultSummary returns a ReactNode so the number can carry its own element —
  // tabular-nums stops the digits from jumping column as you filter.
  resultSummary: (resultCount, total, unitLabel) => (
    <>
      <strong className="font-medium text-foreground tabular-nums">{resultCount}</strong>
      {total !== undefined ? ` of ${total}` : ''} {unitLabel}
    </>
  ),
}

The two bars below share one label set. The second one adds unitLabel="invoices" — the prop wins over labels.unitLabel:

labels.unitLabel = "items"

3 of 10 items

same labels + unitLabel="invoices"

3 of 10 invoices

DEFAULT_SEARCH_FILTER_BAR_LABELS 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
searchValuerequiredstringThe committed search term. Controlled — the input keeps its own local copy so typing never re-renders the table.
onSearchChangerequired(value: string) => voidCalled 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.
childrenReactNodeFilter slot rendered next to the search field — Select, Combobox, DatePicker, anything.
actionsReactNodeRight-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() => voidRenders the clear-filters button in the count row. Only shown when activeFilterCount > 0.
resultCountnumberRows left after filtering. Feeds labels.resultSummary.
totalnumberRow 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>DEFAULT_SEARCH_FILTER_BAR_LABELSPer-key override of every visible and screen-reader string. Merged over the defaults, so partial sets are fine.
classNamestringExtra classes on the outer flex column.

SearchFilterBarLabels keys

Defaults come from DEFAULT_SEARCH_FILTER_BAR_LABELS.

PropTypeDefaultDescription
searchPlaceholderstring'Tìm kiếm…'Placeholder and aria-label of the search input.
unitLabelstring'mục'Default noun in the result summary. The unitLabel prop overrides it.
clearSearchstring'Xóa từ khóa'sr-only text on the × button inside the search field. Nothing else can reach this string.
filterCount(count: number) => stringcount => `${count} bộ lọc`Text of the active-filter badge. A function because the number is embedded in the sentence.
clearFiltersstring'Xóa lọc'Label of the clear-filters button in the count row.
resultSummary(resultCount, total, unitLabel) => ReactNode'3 / 57 mục'The whole count sentence — the default renders the count, then " / total", then the unit. 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 Vietnamese string somewhere inside it — “Xóa lọc”, 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.

The catch worth remembering: those two props still default to Vietnamese (“Tất cả” and “Bộ lọc nhanh”). An English app must pass both, and label is easy to forget because it is never drawn on screen.

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 | nullCurrent selection. Pass an array in multiple mode, a value or null in single mode.
onValueChangerequired(value: T[] | T | null) => voidFires 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'Tất cả'Label of that chip. Vietnamese by default — pass an English string.
allCountnumberNumber shown before allLabel, usually the unfiltered row count.
labelstring'Bộ lọc nhanh'aria-label of the role="group" wrapper. Vietnamese by default — pass an English string.
classNamestringExtra classes on the wrapping flex row.

FilterChipItem

PropTypeDefaultDescription
valuerequiredTIdentity of the chip and the value handed to onValueChange.
labelrequiredstringVisible text. This is where the chip strings live — there is no label dictionary to override.
countnumberRendered semibold and tabular-nums before the label. Omit it entirely rather than passing 0 if the facet is unknown.
iconIconComponentThe icon COMPONENT, e.g. icon={Flame}. Rendered at size-3.5 with aria-hidden.
toneStatusToneColour 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: 3.83:1 and 4.64:1, still 3.50:1 and 4.21:1 once hover swaps the fill to bg-accent.
  • 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, for 6.51–7.15:1 light and 6.84–12.67:1 dark across the five coloured tones. (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.