Filter Panel
ProA premium faceted filter panel for product listings: collapsible groups for checkbox lists, color swatches, star ratings and a price range, with per-option counts, a live active-filter chip row and a one-click clear-all. Selection is exposed through two-way selected / price models and a consolidated change output.
Also belongs under Forms & Inputs — it is catalogued in Data Display and stays there.
Complexity tier: Application The interactive bar, plus a stated data-volume ceiling, a persistence contract, a bundle budget and an accessible parity surface.
import { SvFilterPanelComponent } from '@styloviz-pro/display';Demo & usage
Basic usage
ProUsage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Two-way selection
ProPlain variant with Apply
ProStale counts and zero-result facets
ProURL sync
ProWhen to use
- The sidebar of a product-listing or search-results page.
- A filter drawer on mobile, paired with sv-drawer.
- Any catalog view that needs faceted refinement with a live count.
Async states
- loading
- error
- retry
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| groups | FilterGroup[] | [] | Facet groups to render. |
| heading | string | 'Filters' | Panel heading. |
| showClearAll | boolean | true | Show the "Clear all" control when filters are active. |
| showCounts | boolean | true | Show per-option result counts. |
| showActiveChips | boolean | true | Show the active-filter chip row. |
| showApply | boolean | false | Batch the selection and emit only when Apply is pressed. THE BUTTON EXISTED AND CHANGED NOTHING. `filterChange` fired on every toggle regardless, so on mobile — where this mode matters most — each tap still cost a request and the Apply button was a decoration over a panel that had already applied. Turning it on now genuinely SUPPRESSES the per-toggle emission: the selection is a draft until Apply, which is the only thing that makes a transaction mode worth having. |
| countsStale | boolean | false | True while the server is recomputing the facet counts. A FACET COUNT THAT DOES NOT REFLECT THE CURRENT SELECTION IS WORSE THAN NO COUNT. It is a number, so the reader trusts it, and it is wrong — it describes the results of the PREVIOUS query. Counts are dimmed and announced as recalculating rather than left standing as fact. |
| disableEmptyFacets | boolean | true | Disable facets whose count is zero, so a dead end cannot be selected. A ZERO-RESULT COMBINATION IS REACHABLE IN ONE TAP AND ESCAPABLE ONLY BY GUESSING WHICH FACET CAUSED IT. Preventing it is far kinder than the empty state afterwards. An already-SELECTED facet is never disabled, whatever its count says: deselecting has to stay possible, and a selected facet reading zero is usually how the user got into the dead end in the first place. |
| applyLabel | string | 'Apply filters' | Apply button label. |
| clearLabel | string | 'Clear all' | Clear-all label. |
| collapsible | boolean | true | Whether groups can collapse (per-group `collapsible` overrides). |
| variant | 'card' | 'plain' | 'card' | Visual style. |
| loading | boolean | false | True while the facets are being fetched. Replaces the groups with a placeholder rather than showing an empty panel. |
| error | SvDataError | string | null | null | Failure loading the facets. Showing an empty filter panel instead would tell the user there is nothing to filter by. |
| selected | Record<string, string[]> (two-way) | {} | Selected values per group id (two-way: `[(selected)]`). |
| price | Record<string, PriceRange> (two-way) | {} | Chosen price range per price-group id (two-way: `[(price)]`). |
| urlParamPrefix | string | SV_FILTER_PANEL_PARAM_PREFIX | Namespace for the query parameters this panel owns. Override when two panels share one page; anything outside the prefix is left alone, so the host's own `?page=2&q=shoes` survives every facet change. |
| debounceMs | number | 300 | Debounce delay for `filterChange`, in ms. `0` emits immediately. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| filterChange | FilterChange | The filter selection changed — refetch. Debounced by `debounceMs`. Renamed from `change`, a native DOM event name. Not `selectedChange` either — that one is owned by the `selected` model above. |
| apply | FilterChange | Emitted when the Apply button is pressed. |
| clear | void | Emitted when everything is cleared. |
| retry | void | The host should re-run the failed facet load. |
| selectedChange | Record<string, string[]> | Emits the new `selected` whenever it changes — the output half of `[(selected)]`. |
| priceChange | Record<string, PriceRange> | Emits the new `price` whenever it changes — the output half of `[(price)]`. |
Wire it to your API
This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.
Template
ProBound to the names this component actually exposes.
Host class
ProDrive the three async inputs from your own fetch.
- This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `groups`, `loading` and `error`.
- `debounceMs` (default 300) collapses a burst of typing into one request. Set it to 0 only when the query is driven by clicks.
- Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.
Full walkthrough: Connecting to a backend.
Customization & theming
- Four facet types: checkbox, color, rating and price.
- Collapsible groups with a per-group override and remembered state.
- Toggle counts, active chips, clear-all and an Apply footer.
- countsStale dims counts during recalculation; disableEmptyFacets (default true) disables zero-count options.
- urlParamPrefix namespaces the query parameters serializeToUrl / restoreFromUrl own — override it when two panels share a page.
- Card or plain variant to match the surrounding layout.
Best practices
- Do: Drive results from the change output (or the two-way selected / price models) on your listing page.
- Do: Provide per-option counts so shoppers can see how many results each facet yields.
- Do: Use variant="plain" when embedding inside an existing card or drawer.
- Do: Set [countsStale]="true" while the server recomputes facet counts: the numbers stay visible but are dimmed and announced as "recalculating", because a count describing the previous query reads as fact.
- Do: Leave disableEmptyFacets on (the default): checkbox, colour and rating options with a count of 0 are disabled and announced ", no results" — never when already selected, so deselecting stays possible. An absent count is "unknown" and is never disabled.
- Do: In apply mode ([showApply]="true") bind (apply) — not [(selected)] — to run the query. The selected / price models still update on every toggle because they hold the DRAFT; filterChange is suppressed until Apply is pressed, then fires once alongside apply.
- Do: Sync to the URL with serializeToUrl(existing) → Router.navigate and restoreFromUrl(params) on load. Only parameters under urlParamPrefix (default "f.") are owned, so the host's own ?page / ?q survive; restoreFromUrl is all-or-nothing, does not emit filterChange, and returns a report when a shared link no longer matches the groups.
- Don't: Do not mix unrelated facet types in one group — give each facet its own FilterGroup.
Accessibility
- Group headers are buttons with aria-expanded; options use real checkboxes and pressed-state buttons.
- Color swatches and rating rows expose descriptive labels and aria-pressed.
- Zero-count options are disabled with an sr-only ", no results"; stale counts carry an sr-only ", recalculating".
- All controls have visible focus rings in light and dark mode.
Feedback on Filter Panel
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.