Pattern

Filtering a list

Toggle filters narrow a long list; the state lives in the address, so a filtered view can be shared and survives Back.

01

Watch it

The Bookshelf: Reading, then Business, then Reading off, then Show all books. Recorded from the live code.
02

Rules

One value per facet
Status, collection, genre. Choosing the active value again clears it.
State in the address
#status=Read&collection=Life+Changing&genre=Business, written with replaceState; hashchange reapplies it.
Feedback
The count updates (“12 of 170 books”); groups that empty out hide; each group’s count updates; and an empty result offers a reset.
No jumping
The first visible group sits flush at the top; if the reader had scrolled into the list, the start of the list comes back into view (desktop).
Small screens
The filters fold into “Filter books” unless a filter is active.
Without JavaScript
The full list shows; the filter column keeps its place but stays hidden.
Access
Real buttons with aria-pressed.
03

Code

From site/assets/js/bookshelf.js, lines 1 to 80.

// Bookshelf filters. Optional: without this script the full list shows and the
// filter column stays hidden. State lives in the address, as before:
//   /bookshelf/#status=Read&collection=Life+Changing&genre=Business
// One value per key; choosing the active value again clears it.

const keys = ['status', 'collection', 'genre'];
const side = document.querySelector('.filters');
const items = [...document.querySelectorAll('.books li')];
const groups = [...document.querySelectorAll('.shelf')];
const buttons = [...document.querySelectorAll('.filters button[data-k]')];
const shown = document.querySelector('[data-shown]');
const empty = document.querySelector('.shelf-empty');

const read = () => {
    const p = new URLSearchParams(location.hash.slice(1));
    return Object.fromEntries(keys.map((k) => [k, p.get(k) || '']));
};
const write = (state) => {
    const p = new URLSearchParams();
    keys.forEach((k) => state[k] && p.set(k, state[k]));
    const hash = p.toString();
    const url = hash ? `#${hash}` : location.pathname;
    history.replaceState(null, document.title, hash ? url : location.pathname + location.search);
    apply(state);
};

const matches = (li, s) =>
    (!s.status || li.dataset.status === s.status) &&
    (!s.genre || li.dataset.genre === s.genre) &&
    (!s.collection || (li.dataset.collections || '').split(', ').includes(s.collection));

function apply(s) {
    let total = 0;
    items.forEach((li) => {
        const on = matches(li, s);
        li.hidden = !on;
        total += on;
    });
    let first = true;
    groups.forEach((g) => {
        const n = g.querySelectorAll('li:not([hidden])').length;
        g.hidden = n === 0;
        const count = g.querySelector('[data-count]');
        if (count) count.textContent = String(n).padStart(2, '0');
        // The first group still showing sits flush at the top, whichever it is,
        // so the list does not jump when an earlier group is filtered away.
        g.classList.toggle('is-first', !g.hidden && first);
        if (!g.hidden) first = false;
    });
    buttons.forEach((b) => b.setAttribute('aria-pressed', String(s[b.dataset.k] === b.dataset.v)));
    if (shown) shown.textContent = total;
    if (empty) empty.hidden = total > 0;
}

// If the reader has scrolled into the list, a filter change brings its start
// back into view instead of leaving them somewhere in the middle of nothing.
const list = document.querySelector('.shelf-side + .read') || groups[0]?.parentElement;
function keepListInView() {
    if (!list || matchMedia('(max-width: 1023px)').matches) return;
    if (list.getBoundingClientRect().top < 0) list.scrollIntoView({ block: 'start' });
}

if (side) {
    side.hidden = false;
    // The filter box folds away on small screens unless a filter is active.
    const box = side.querySelector('details');
    const s0 = read();
    if (box && (matchMedia('(min-width: 1024px)').matches || keys.some((k) => s0[k]))) box.open = true;
    buttons.forEach((b) =>
        b.addEventListener('click', () => {
            const s = read();
            s[b.dataset.k] = s[b.dataset.k] === b.dataset.v ? '' : b.dataset.v;
            write(s);
            keepListInView();
        })
    );
    document.querySelectorAll('[data-reset]').forEach((b) => b.addEventListener('click', () => write({})));
    addEventListener('hashchange', () => apply(read()));
    apply(read());
}