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
searchValueto'', 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.onSearchChangeis 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:
- 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. - Keys that carry a number or a unit are functions, not templates.
filterCount(count)andresultSummary(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. - A standalone prop beats
labels.unitLabelandsearchPlaceholderstay real props:labelsis the language layer, those are the content layer — the same English app says “invoices” on one screen and “tasks” on the next.
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"
same labels + unitLabel="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
| Prop | Type | Default | Description |
|---|---|---|---|
searchValuerequired | string | — | 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. |
searchPlaceholder | string | labels.searchPlaceholder | Placeholder text, also used verbatim as the input aria-label. Beats labels.searchPlaceholder. |
debounceMs | number | 250 | Delay before onSearchChange fires. 0 emits on every keystroke. Use 250–350 when typing triggers a request. |
searchInputRef | Ref<HTMLInputElement> | — | Ref forwarded to the input — used to focus the field from a keyboard shortcut. |
searchInputProps | ComponentPropsWithoutRef<'input'> | — | Props spread onto the input (id, name, enterKeyHint, aria-describedby…). Spread last, so it wins over the internal props. |
children | ReactNode | — | Filter slot rendered next to the search field — Select, Combobox, DatePicker, anything. |
actions | ReactNode | — | Right-hand slot, pushed to the far end with ml-auto — the create button, a column toggle. |
activeFilterCount | number | 0 | Number 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. |
resultCount | number | — | Rows left after filtering. Feeds labels.resultSummary. |
total | number | — | Row count before filtering. Omit it and the summary reads "3 items" instead of "3 of 57 items". |
unitLabel | string | labels.unitLabel | What the rows are called for this screen ("tasks", "invoices"). A content string, so it beats labels.unitLabel. |
labels | Partial<SearchFilterBarLabels> | DEFAULT_SEARCH_FILTER_BAR_LABELS | Per-key override of every visible and screen-reader string. Merged over the defaults, so partial sets are fine. |
className | string | — | Extra classes on the outer flex column. |
SearchFilterBarLabels keys
Defaults come from DEFAULT_SEARCH_FILTER_BAR_LABELS.
| Prop | Type | Default | Description |
|---|---|---|---|
searchPlaceholder | string | 'Tìm kiếm…' | Placeholder and aria-label of the search input. |
unitLabel | string | 'mục' | Default noun in the result summary. The unitLabel prop overrides it. |
clearSearch | string | 'Xóa từ khóa' | sr-only text on the × button inside the search field. Nothing else can reach this string. |
filterCount | (count: number) => string | count => `${count} bộ lọc` | Text of the active-filter badge. A function because the number is embedded in the sentence. |
clearFilters | string | '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
| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | FilterChipItem<T>[] | — | The chips. Each one carries its own label, count, icon and tone — which is why the component has no labels prop. |
valuerequired | T[] | 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. |
multiple | boolean | false | Allow 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. |
showAll | boolean | false | Prepend an "all" chip that clears the selection. It reads as pressed exactly when nothing else is. |
allLabel | string | 'Tất cả' | Label of that chip. Vietnamese by default — pass an English string. |
allCount | number | — | Number shown before allLabel, usually the unfiltered row count. |
label | string | 'Bộ lọc nhanh' | aria-label of the role="group" wrapper. Vietnamese by default — pass an English string. |
className | string | — | Extra classes on the wrapping flex row. |
FilterChipItem
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | T | — | Identity of the chip and the value handed to onValueChange. |
labelrequired | string | — | Visible text. This is where the chip strings live — there is no label dictionary to override. |
count | number | — | Rendered semibold and tabular-nums before the label. Omit it entirely rather than passing 0 if the facet is unknown. |
icon | IconComponent | — | The icon COMPONENT, e.g. icon={Flame}. Rendered at size-3.5 with aria-hidden. |
tone | StatusTone | — | Colour of the chip while selected — spread one of STATUS_TONES. Without it the chip fills solid with the app accent. |
disabled | boolean | false | Sets 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-borderis the decorative tier and sits at 1.16:1 light / 1.10:1 dark. The chip usesborder-control-edgeinstead: 3.83:1 and 4.64:1, still 3.50:1 and 4.21:1 once hover swaps the fill tobg-accent. - Selected, with a tone.
border-transparentwould drop the edge back to the tint itself (1.07–1.24:1) — leaving the selected chip fainter than the unselected ones. It usestone.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.neutralis the odd one out — its border isborder-muted-foreground, 5.23:1 light / 5.70:1 dark: past the 3:1 this rule asks for, but not an ink step.) BecauseborderColoris optional onStatusTone, a tone that omits it falls back toborder-control-edge— never to transparent. - Selected, no tone. A solid
bg-app-accentfill 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 withborder-app-accent-ink(6.51:1 light, 12.67:1 dark). In dark mode--app-accent-inkresolves 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 itsaria-labelfrom 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>carryinglabels.clearSearchassr-onlytext — 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 whyresultCountis 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. FilterChipsrenders arole="group"labelled bylabel, with each chip a<button aria-pressed>. It is deliberately notrole="tab": chips toggle a filter condition, they do not swap a panel, and the tab role promises anaria-controlstarget that does not exist here.- A disabled chip uses the real
disabledattribute — out of the tab order,pointer-events-none, handler never called — so itsopacity-50is exempt from the contrast minimum. That is the difference between it and a “locked” tile that is still focusable. - Whatever you put in
childrenowes its own accessible name. A bareSelectnext to the search box reads as an unnamed combobox unless you give it one.