<bmx-filter-panel>
Faceted search for any list: checkbox facets with live counts, a single choice, numeric ranges with a histogram behind a two-handled slider, date ranges with quick choices, a minimum rating and on/off switches - plus keywords, removable chips for everything chosen, "Clear all", saved views and the choice written into the address bar.
20 properties · 2 events · 8 methods · 14 parts
Example
Products
Show markup
<div class="ex-fp" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(min(17rem, 100%), 1fr)); gap: 16px; align-items: start">
<bmx-filter-panel id="ex-fp" searchable save-views label="Filter products" style="max-inline-size: 22rem"></bmx-filter-panel>
<div>
<p id="ex-fp-count" style="margin: 0 0 8px; font-weight: 600">Products</p>
<ul id="ex-fp-list" style="display: grid; gap: 6px; margin: 0; padding: 0; list-style: none"></ul>
</div>
</div>
<script type="module">
await customElements.whenDefined('bmx-filter-panel');
const panel = document.getElementById('ex-fp');
const list = document.getElementById('ex-fp-list');
const count = document.getElementById('ex-fp-count');
// A small office-equipment catalogue (fictional brands).
const brands = ['Ashby', 'Corvel', 'Halden', 'Mireo', 'Tarrow'];
const kinds = [
['Laptop', 649, 1899],
['Monitor', 129, 899],
['Desk', 199, 1199],
['Chair', 89, 949],
['Headset', 29, 349],
['Printer', 119, 799],
];
const colours = ['Black', 'White', 'Graphite', 'Oak', 'Blue'];
const products = [];
let seed = 7;
const next = () => ((seed = (seed * 9301 + 49297) % 233280) / 233280);
for (let i = 0; i < 60; i++) {
const [kind, lo, hi] = kinds[i % kinds.length];
const brand = brands[Math.floor(next() * brands.length)];
const day = new Date();
day.setDate(day.getDate() - Math.floor(next() * 120));
products.push({
id: i + 1,
name: `${brand} ${kind} ${100 + i * 7}`,
kind,
brand,
colour: colours[Math.floor(next() * colours.length)],
price: Math.round((lo + next() * (hi - lo)) / 5) * 5,
rating: Math.round((3 + next() * 2) * 10) / 10,
stock: next() > 0.25,
added: day.toISOString().slice(0, 10),
features: ['Next-day delivery', 'Eco label', 'Three-year warranty'].filter(() => next() > 0.55),
});
}
panel.facets = [
{ id: 'kind', label: 'Product type', sort: 'label' },
{ id: 'brand', label: 'Brand', limit: 4 },
{ id: 'price', label: 'Price', type: 'range', step: 25, format: { style: 'currency', currency: 'GBP', maximumFractionDigits: 0 } },
{ id: 'rating', label: 'Customer rating', type: 'rating' },
{ id: 'stock', label: 'Availability', type: 'toggle', toggleLabel: 'In stock only' },
{ id: 'features', label: 'Features', operator: 'and', description: 'Products with every feature ticked.' },
{ id: 'colour', label: 'Colour', collapsed: true },
{ id: 'added', label: 'Date added', type: 'date', collapsed: true },
];
panel.searchFields = ['name', 'brand', 'kind'];
panel.views = [{ id: 'cheap-chairs', label: 'Chairs under £300', value: { kind: ['Chair'], price: { max: 300 } } }];
panel.items = products;
const money = new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', maximumFractionDigits: 0 });
// The list is drawn once now, then again at every change.
const render = shown => {
count.textContent = `${shown.length} of ${products.length} products`;
list.replaceChildren(
...shown.slice(0, 8).map(p => {
const li = document.createElement('li');
li.style.cssText = 'display:flex;justify-content:space-between;gap:12px;padding:8px 12px;border:1px solid var(--bmx-border);border-radius:8px;background:var(--bmx-surface)';
li.innerHTML = `<span><strong></strong><br><small style="color:var(--bmx-text-muted)"></small></span><span></span>`;
li.querySelector('strong').textContent = p.name;
li.querySelector('small').textContent = `${p.colour} · ★ ${p.rating} · ${p.stock ? 'In stock' : 'Out of stock'}`;
li.lastElementChild.textContent = money.format(p.price);
return li;
}),
);
if (shown.length > 8) {
const more = document.createElement('li');
more.style.color = 'var(--bmx-text-muted)';
more.textContent = `…and ${shown.length - 8} more`;
list.append(more);
}
};
render(products);
panel.addEventListener('bmxFilterChange', e => render(e.detail.filtered));
</script>
<bmx-filter-panel id="filters" searchable url-sync></bmx-filter-panel>
<script>
filters.facets = [
{ id: 'brand', label: 'Brand' },
{ id: 'price', label: 'Price', type: 'range', step: 50, format: { style: 'currency', currency: 'GBP' } },
{ id: 'rating', label: 'Customer rating', type: 'rating' },
{ id: 'stock', label: 'Availability', type: 'toggle', toggleLabel: 'In stock only' },
];
filters.items = products;
filters.addEventListener('bmxFilterChange', e => grid.rows = e.detail.filtered);
</script>
With items it does the work itself: options are found in the records,
every count is worked out the way good shops do (a facet's counts with
every other filter applied, so choosing one brand does not zero the
others), and bmxFilterChange hands over the filtered records. Without
them, a page whose data is on a server listens for the same event - its
queryString is ready to send - and hands back counts and
result-count.
layout="bar" lays the facets out in a row of buttons, each opening its
facet in a popup, for the top of a table. deferred waits for "Show 24
results" instead of filtering at every tick. On a narrow screen the panel
folds behind a "Filters (3)" button.
Everything is native form controls - checkboxes, radios, range sliders, date boxes - inside headed, collapsible sections, so the keyboard, screen readers and high-contrast themes behave as they do everywhere else; each option's name carries its count, and the number of results is announced after a change.
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
collapseBelow |
collapse-below |
number |
640 |
Below this window width (px), the panel layout folds behind a button. 0 never folds. |
counts |
counts |
BmxFilterCounts | string |
— | A server's option counts, by facet id and option value. |
deferred |
deferred |
boolean |
false |
Wait for "Show results" instead of filtering at every change. |
facets |
facets |
BmxFacet[] | string |
[] |
The facets, as objects or JSON. |
headingLevel |
heading-level |
number |
2 |
The level of the panel's heading (2 to 6); facet headings are one below. |
items |
items |
unknown[] | string |
— | The records to filter (from script, or JSON). Without them the panel takes counts and result-count from the page. |
label |
label |
string |
— | The panel's heading. Default: "Filters". |
layout |
layout |
'panel' | 'bar' |
'panel' |
panel: a column of collapsible facets (a sidebar). bar: a row of buttons opening each facet in a popup. |
locale |
locale |
string |
— | The locale for numbers and dates. Default: the nearest lang attribute. |
now |
now |
number | string |
— | Today, for the date presets (milliseconds or ISO). Default: the clock. |
query |
query |
string |
'' |
The keywords. Mutable. |
resultCount |
result-count |
number |
— | How many results there are, when the page filters on a server. |
saveViews |
save-views |
boolean |
false |
Offer "Save view" and the list of saved views. |
searchFields |
search-fields |
string[] | string |
— | The record fields the keywords search (dot paths). Default: every top-level text and number field. |
searchable |
searchable |
boolean |
false |
Show a keyword box. |
strings |
strings |
Partial<BmxFilterPanelStrings> | string |
— | Words to show instead of the English ones, as an object or JSON. |
urlPrefix |
url-prefix |
string |
'' |
A prefix for the names written to the address bar, to keep them apart from the page's own. |
urlSync |
url-sync |
boolean |
false |
Keep the address bar's query string in step (history.replaceState), and read it on load. |
value |
value |
BmxFilterValue | string |
{} |
What is chosen, as an object, JSON or a query string. Mutable: the reader's choices are written back as an object. |
views |
views |
BmxFilterView[] | string |
[] |
Saved views. Mutable: saving and deleting are written back. |
Events
| Event | Detail | Description |
|---|---|---|
bmxFilterChange |
BmxFilterChangeDetail |
Fired when the filters change (deferred: when they are applied). |
bmxViewsChange |
BmxFilterViewsDetail |
Fired when a view is saved or deleted. |
Methods
| Method | Signature | Description |
|---|---|---|
apply |
apply() => Promise<void> |
Applies the filters in deferred mode. |
clear |
clear(facet?: string) => Promise<void> |
Clears one facet, or everything (keywords too) when no facet is named. |
expand |
expand(facet: string, open?: boolean) => Promise<void> |
Opens (or closes) a facet's section. |
getFiltered |
getFiltered() => Promise<unknown[]> |
The records that pass (with items). |
getValue |
getValue() => Promise<BmxFilterValue> |
What is chosen (applied), as an object. |
setFocus |
setFocus() => Promise<void> |
Focuses the first control. |
setValue |
setValue(value: BmxFilterValue | string) => Promise<void> |
Chooses filters: an object, JSON or a query string. Fires bmxFilterChange. |
toQueryString |
toQueryString() => Promise<string> |
The value as query-string pairs (brand=Contoso&price=100..500). |
CSS shadow parts
| Part | Description |
|---|---|
bar |
The row of facet buttons (bar layout). |
chip |
One chosen filter. |
chips |
The row of chosen filters. |
count |
The number of results. |
facet |
One facet's section. |
facet-body |
A facet's controls. |
facet-header |
A facet's heading button. |
footer |
The Apply and Reset buttons (deferred). |
header |
The heading row. |
histogram |
A range's histogram. |
option |
One option. |
panel |
The panel (panel layout). |
popup |
A facet's popup (bar layout). |
search |
The keyword box. |
CSS custom properties
| Property | Description |
|---|---|
--bmx-filter-panel-accent |
The colour of chosen options, the slider and the histogram. |
--bmx-filter-panel-background |
The panel's background. |
--bmx-filter-panel-histogram-color |
The histogram's bars outside the chosen range. |
--bmx-filter-panel-option-height |
The height of one option row. |
--bmx-filter-panel-popup-width |
A facet's popup width (bar layout). |
--bmx-filter-panel-radius |
Corner radius of the panel, the popups and the controls. |