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
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 withreplaceState;hashchangereapplies 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());
}