Component contract
Renders the search + filters header above a list view. Stateless from the parent's perspective — the parent owns the state and re-fetches when it changes. URL sync is the parent's job; the component only emits change events.
search: string— controlled.onSearchChange: (next) => void— debounced internally; default 300ms.searchPlaceholder?: stringfilters: FilterDef[]— schema for each filter (id, label, type: enum/multi/date/range, options).filterValues: Record<filterId, value>— controlled.onFiltersChange: (next) => voidonClearAll?: () => void— optional explicit handler; default behavior calls both onSearchChange("") and onFiltersChange({}).resultCount?: number— when provided, shows "N results" inline next to search.isLoading?: boolean— debounce-aware loading hint.
Interaction surface
-
Search input with debounced change.
Typing triggers
onSearchChangeafter 300ms of no further keystrokes. Pressing Enter flushes the debounce immediately. Clearing (Esc or clear button) flushes immediately with empty string. -
Filter chips render each filter with current value.
A filter with no value renders as "Add Status filter" (label-only chip with + indicator). A filter with a value renders as "Status: Active" with a × to clear that filter only. Multi-value filters render as "Status: Active +2" with hover/focus showing the full list.
-
Clicking a filter chip opens its picker.
Type=enum: opens a dropdown with the filter's options. Type=multi: dropdown with checkboxes. Type=date: opens a date-range picker. Type=range: numeric range slider. Picker closes on selection (single) or via apply button (multi).
-
"Clear all" appears when any filter or search is active.
Visible button to reset everything in one click. Hidden when no filter and no search are set.
-
Result count updates when state changes.
When provided, "N results" renders next to search. Updates after debounce + parent re-fetch. During in-flight fetch, shows a subtle skeleton on the count text only (not the full search input).
Failure modes
Active filters hidden in a "More" panel
Trigger: with 4+ active filters, only 2 visible chips render and the rest hide behind a "+2 more" toggle.
Active filters are ALWAYS visible. Hiding them is a Ratchet violation — the user must see at a glance what's narrowing their list. Wrap to multiple lines if needed; never collapse. Harness: render with 6 active filters, assert 6 ui-search-filter-chip-active visible.
Debounce fires on every keystroke
Trigger: missing debounce, every character triggers a re-fetch.
Search calls onSearchChange after 300ms of no input. Harness: type "abcde" rapidly within 300ms, assert onSearchChange called once with "abcde", not 5 times.
Enter doesn't flush debounce
Trigger: Enter in the search box is consumed by debounce timer; user has to wait 300ms after pressing it.
Enter flushes the debounce. Harness: type "abc", press Enter at 100ms, assert onSearchChange called immediately with "abc".
Filter cleared from chip × doesn't clear corresponding picker state
Trigger: user clears Status filter from chip, then opens the Status picker, the previous selections are still highlighted.
The picker is a render of filterValues. Clearing via chip × calls onFiltersChange({...filterValues, status: undefined}). The picker reads the new state on next render. Harness: set status=["active"], click chip ×, open picker, assert no options checked.
Clear-all calls only one handler
Trigger: clear-all calls onFiltersChange({}) but not onSearchChange("") — search box still has stale text.
Default clear-all calls both handlers. If onClearAll override is provided, the parent owns the behavior. Harness default: search="abc" + filters non-empty, click clear-all, both handlers called.
Result count is stale during in-flight fetch
Trigger: search="ab" returns 50 results; user types "abc"; component continues to show "50 results" for 300ms before refetching.
While in-flight (parent passes isLoading=true), the result count renders as a subtle skeleton, not stale text. Harness: set isLoading=true, assert ui-search-result-count has loading skeleton, no stale numeric text.
Filter chip click opens the picker but click-outside doesn't close it
Trigger: picker is a fixed-position panel, click outside it has no handler.
Picker closes on: outside click, Esc key, focus moving to a non-picker element. Harness: open picker, click outside, assert closed.
Multi-filter chip "+2" tooltip not keyboard-accessible
Trigger: hover-only tooltip; keyboard users can't see what the +2 represents.
+2 indicator opens a tooltip on focus AND hover. Tooltip content is also available as the chip's aria-label. Harness: focus chip via keyboard, assert tooltip visible OR aria-label includes the hidden values.
Accessibility
- Search input is
<input role="searchbox">with associated label. - Filter chips are
<button>with descriptive aria-label including current value. - Active filter clear buttons (×) are nested buttons with their own
aria-label="Clear [filter name]". - Result count region has
aria-live="polite". - Picker dropdowns use the same a11y as the underlying ui-modal/listbox: focus trap, ESC closes, return focus to trigger.
- Color contrast: chip text ≥ 4.5:1, search placeholder ≥ 4.5:1.
- axe-clean at severity ≥ serious.
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
ui-search-with-filters | Outer wrapper | Component identity |
ui-search-input | Search input | The text input |
ui-search-clear | Search clear button (×) | Visible when search has content |
ui-search-result-count | Result count region | Visible when resultCount provided |
ui-search-filter-chip | Each filter chip | data-filter-id; both empty + active states |
ui-search-filter-chip-active | Active filter chips | Subset of chips with values set |
ui-search-filter-clear | × on active chips | Per-filter clear |
ui-search-filter-picker | Open picker dropdown | Visible when picker is open |
ui-search-clear-all | Clear all button | Visible when any filter or search is set |
Agent test plan
Standalone probes against /admin-test/ui-search-with-filters-fixture with variants: empty, search-only, filter-only, both, 6-filters, multi-filter, in-flight loading, with-result-count.
Probe list
- search-debounces-300ms: type rapidly, onSearchChange called once after 300ms quiet
- enter-flushes-debounce: type + Enter, onSearchChange immediate with full text
- esc-clears-search: type, focus search, Esc, search cleared
- clear-button-visible-when-text: search="abc" → ui-search-clear visible
- clear-button-hidden-when-empty: search="" → ui-search-clear hidden
- filter-chip-empty-state: filter with no value → "Add Status filter" + indicator
- filter-chip-active-state: filter with value → "Status: Active" + × button
- filter-chip-multi-value: status=[a,b,c] → "Status: Active +2"
- multi-value-tooltip-keyboard-accessible: focus chip, tooltip visible
- chip-click-opens-picker: click chip, ui-search-filter-picker visible
- picker-outside-click-closes: open picker, click outside, picker hidden
- picker-esc-closes: open picker, Esc, picker hidden
- chip-clear-clears-only-this-filter: 2 active filters, click × on one, other still active
- clear-all-clears-search-and-filters: clear-all called, both handlers fire
- clear-all-hidden-when-empty: no search no filters → ui-search-clear-all hidden
- clear-all-visible-when-active: any active state → ui-search-clear-all visible
- active-filters-always-visible: 6 active filters, all 6 chips visible (no "+2 more" collapse)
- result-count-skeleton-when-loading: isLoading=true → count region shows skeleton, no stale text
- result-count-aria-live: count region has aria-live=polite
- color-contrast-chip-text: ≥ 4.5:1
- color-contrast-search-placeholder: ≥ 4.5:1
- axe-clean-serious: no serious violations