v2.6.0

<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

    PropertyAttributeTypeDefaultDescription
    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

    EventDetailDescription
    bmxFilterChange BmxFilterChangeDetail Fired when the filters change (deferred: when they are applied).
    bmxViewsChange BmxFilterViewsDetail Fired when a view is saved or deleted.

    Methods

    MethodSignatureDescription
    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

    PartDescription
    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

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