Pattern: advanced filter panel
The filter set that outgrew the bar. A finance or purchasing user opens the same list a dozen times a day and narrows it a different way each time — cost centre, status, date range, amount band. They do not want twelve controls permanently across the top; they want the two they use constantly visible and the rest a click away. "Done" means the list reloads and what is currently filtering it stays visible after the panel closes — a filter you cannot see is a filter you will forget you set, and then mistrust the numbers. Not for a list with one obvious narrowing dimension — a single search box or a two-option status filter is already inline in the list report's own bar; reach for the grouped, chip-backed panel here only once distinct facets pile up (cost centre, date band, status, and more), not by default for every list.
The screen
Applied filters, always visible — the answer to "why is this list short?":
Zero new CSS. This is the filter
bar for the two controls in constant use, a dropdown
menu in its multi-select variant for each group behind a trigger, and
chips for what is applied. There is no
.bo-filter-panel, and there should not be — the ask was a screen, and
the screen is four things that already ship.
The menu is a native [popover], so Escape closes it, clicking
away closes it, and focus returns to the trigger — none of that is our
code, which is why it behaves the way the rest of the operating system does.
Checking a box does not close the menu (you are picking several), and the
trigger counts what you picked: "Cost centre (2)".
Why the count on the trigger matters
A collapsed filter group hides its own state. Without the count, "Cost centre" looks identical whether it is filtering nothing or excluding 90% of the rows — and the user reads a short list as missing data rather than as filtered data. That is the single most common support ticket this pattern prevents.
Two channels, as everywhere else: the count is text in the trigger's label, not a coloured dot, so it survives greyscale, forced colours and a screen reader reading the button.
Anatomy
- The bar — filter bar;
a real
<form method="get">, so filters end up in the URL and the screen is shareable and bookmarkable. - Always-visible controls — the one or two people use every time. Search and status here; pick yours from usage, not from symmetry.
- Grouped filters — a dropdown
menu per group with
data-multiselect, real checkboxes, and anameso it submits with the form. - Applied chips — chips, each a link that removes that one filter, plus "Clear all".
- The list (not in this demo — this page is scoped to the filter bar itself; see the list report for the paired table) — data table; only it swaps when filters change.
Data contract
| Request | Returns / on failure |
|---|---|
| GET /invoices?status=…&cc=… | the list fragment only, swapped into the table region. The bar is not re-rendered — re-rendering it closes an open menu and loses focus mid-selection |
| GET /invoices (no params) | the unfiltered list; the chip row renders empty and "Clear all" is absent, not disabled |
| Filter options | render them server-side into the menu. Fetching options on open costs a round trip on every click and leaves the menu empty at exactly the moment the user is looking at it |
| 422 | an impossible combination (date-to before date-from): re-render the bar with the values kept and the message beside the offending control — never silently drop a filter |
States
| State | What ships |
|---|---|
| Nothing filtered | no chips, no "Clear all"; triggers show their bare labels |
| Filtered | a chip per applied filter and a count on each trigger — the list is never short without a visible reason |
| Loading | aria-busy on the table only; the bar stays live so a second filter can be set immediately |
| Empty because of filters | the DIFFERENT empty: name which filters are active and offer "Clear all" — not the same message as a genuinely empty list |
| Empty, unfiltered | "No invoices yet" plus the action that creates one |
| Saved view | chips render from the saved query, so a shared link explains itself on arrival |
| No permission for a filter | the group is absent, not disabled — a disabled filter tells the user about data they cannot see |
| Narrow screen | the bar wraps; groups stay behind triggers, which is why this pattern degrades better than twelve inline controls |
| No JS | the menus are native popover elements opened by popovertarget, so triggers still open and close them and every checkbox inside still submits with the form — but without initDropdowns() the menu is never anchored to its trigger (it falls back to the browser's default popover position) and the trigger label never grows a "(2)" count, so an applied filter is invisible until the menu is reopened |
Components used
complexity 2 of 4