← All stories

COMPONENT · ui-search-with-filters

ui-search-with-filters

Component Tier 1 (primitive) Pairs with ui-data-table on every list view

The header above a table that lets the user narrow what they see. Search input plus filter chips, with active filters always visible (no hidden state), debounce that doesn't fire on every keystroke, and URL persistence per the SPA-state mandate.

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?: string
  • filters: FilterDef[] — schema for each filter (id, label, type: enum/multi/date/range, options).
  • filterValues: Record<filterId, value> — controlled.
  • onFiltersChange: (next) => void
  • onClearAll?: () => 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

  1. Search input with debounced change.

    Typing triggers onSearchChange after 300ms of no further keystrokes. Pressing Enter flushes the debounce immediately. Clearing (Esc or clear button) flushes immediately with empty string.

  2. 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.

  3. 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).

  4. "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.

  5. 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-testWherePurpose
ui-search-with-filtersOuter wrapperComponent identity
ui-search-inputSearch inputThe text input
ui-search-clearSearch clear button (×)Visible when search has content
ui-search-result-countResult count regionVisible when resultCount provided
ui-search-filter-chipEach filter chipdata-filter-id; both empty + active states
ui-search-filter-chip-activeActive filter chipsSubset of chips with values set
ui-search-filter-clear× on active chipsPer-filter clear
ui-search-filter-pickerOpen picker dropdownVisible when picker is open
ui-search-clear-allClear all buttonVisible 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