@use 'sprucecss/scss/spruce' as *;
.product-filters {
@include layout-stack('s');
&__header {
align-items: center;
display: flex;
justify-content: space-between;
}
&__title {
font-size: font-size('h4');
margin: 0;
}
&__clear {
font-size: config('font-size-sm', $typography);
}
&__active {
list-style: none;
padding: 0;
@include layout-cluster('xxs');
li {
margin: 0;
}
> li {
margin-block: 0;
padding-block: 0;
}
}
&__chip {
@include clear-btn;
align-items: center;
background-color: color-alpha('primary', 0.1);
border-radius: radius('pill');
color: color('heading');
display: inline-flex;
font-size: config('font-size-sm', $typography);
gap: spacer('xxs');
padding: 0.25em 0.5em 0.25em 0.75em;
@include transition;
svg {
block-size: 0.875rem;
inline-size: 0.875rem;
}
&:hover {
background-color: color-alpha('primary', 0.2);
}
&:focus-visible {
outline: 2px solid color('focus-ring');
outline-offset: 2px;
}
}
&__group {
border: 0;
border-block-start: config('border-width-base', $display) solid color('border');
border-radius: 0;
padding-block-start: spacer('xxs');
> summary {
padding-inline: 0;
}
> :not(summary) {
padding-inline: 0;
}
fieldset {
border: 0;
margin: 0;
padding: 0;
}
&--plain {
padding-block: spacer('s') 0;
}
}
&__checks {
@include layout-stack('xs');
}
&__check {
align-items: center;
display: flex;
.form-check__label {
flex-grow: 1;
}
}
&__count {
color: color('muted');
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
&__price {
--from: 0%;
--to: 100%;
@include layout-stack('s');
}
&__range {
block-size: 1.5rem;
position: relative;
&::before,
&::after {
block-size: 0.25rem;
border-radius: radius('pill');
content: '';
inset-block-start: 50%;
position: absolute;
translate: 0 -50%;
}
&::before {
background-color: color('range-track-background', 'form');
inset-inline: 0;
}
&::after {
background-color: color('primary');
inset-inline: var(--from) calc(100% - var(--to));
}
input {
appearance: none;
background: none;
block-size: 100%;
inline-size: 100%;
inset: 0;
margin: 0;
pointer-events: none;
position: absolute;
z-index: 1;
&::-webkit-slider-runnable-track {
background: none;
}
&::-moz-range-track {
background: none;
}
&::-webkit-slider-thumb {
appearance: none;
background-color: color('surface');
block-size: 1.25rem;
border: 2px solid color('primary');
border-radius: 50%;
cursor: grab;
inline-size: 1.25rem;
pointer-events: auto;
}
&::-moz-range-thumb {
background-color: color('surface');
block-size: 1rem;
border: 2px solid color('primary');
border-radius: 50%;
cursor: grab;
inline-size: 1rem;
pointer-events: auto;
}
}
}
&__price-inputs {
display: grid;
gap: spacer('s');
grid-template-columns: 1fr 1fr;
.form-group {
@include layout-stack('xxs');
}
}
&__colors,
&__sizes {
display: flex;
flex-wrap: wrap;
gap: spacer('xs');
}
&__color,
&__size {
cursor: pointer;
position: relative;
input {
appearance: none;
cursor: pointer;
inset: 0;
margin: 0;
opacity: 0;
position: absolute;
}
}
&__color {
aspect-ratio: 1;
background-color: var(--swatch);
border: 2px solid color('surface');
border-radius: radius('pill');
box-shadow: 0 0 0 1px color('border');
inline-size: 2rem;
input {
border-radius: inherit;
}
&:has(:checked) {
box-shadow: 0 0 0 2px color('heading');
&::after {
block-size: 0.6rem;
border-bottom: 2px solid hsl(0deg 0% 100%);
border-right: 2px solid hsl(0deg 0% 100%);
content: '';
inline-size: 0.35rem;
inset: 0;
margin: auto;
pointer-events: none;
position: absolute;
rotate: 45deg;
translate: 0 -10%;
}
}
&:has(:focus-visible) {
outline: 2px solid color('focus-ring');
outline-offset: 3px;
}
}
&__size {
> span {
border: config('border-width-base', $display) solid color('border-strong');
border-radius: radius('md');
color: color('heading');
display: block;
font-size: config('font-size-sm', $typography);
font-weight: 600;
min-inline-size: 2.75rem;
padding: spacer('xxs') spacer('xs');
text-align: center;
@include transition;
}
&:has(:checked) > span {
background-color: color('heading');
border-color: color('heading');
color: color('surface');
}
&:has(:focus-visible) > span {
outline: 2px solid color('focus-ring');
outline-offset: 2px;
}
}
&__footer {
background-color: color('background');
border-block-start: config('border-width-base', $display) solid color('border');
inset-block-end: 0;
padding-block: spacer('s');
position: sticky;
}
}
[dir='rtl'] .product-filters__chip {
padding: 0.25em 0.75em 0.25em 0.5em;
}
<form class="product-filters" action="#" method="get" data-product-filters>
<header class="product-filters__header">
<h2 class="product-filters__title">Filters</h2>
<a class="product-filters__clear" href="?" data-filter-clear>Clear all</a>
</header>
<ul class="product-filters__active" role="list" aria-label="Active filters" data-filter-active hidden></ul>
<details class="product-filters__group" open>
<summary>Category</summary>
<fieldset>
<legend class="sr-only">Category</legend>
<div class="product-filters__checks">
<label class="form-check product-filters__check">
<input class="form-check__control" type="checkbox" name="category" value="backpacks" data-filter-label="Backpacks" data-count="32" checked>
<span class="form-label form-check__label">Backpacks</span>
<span class="product-filters__count">32<span class="sr-only"> products</span></span>
</label>
<label class="form-check product-filters__check">
<input class="form-check__control" type="checkbox" name="category" value="travel-bags" data-filter-label="Travel bags" data-count="18">
<span class="form-label form-check__label">Travel bags</span>
<span class="product-filters__count">18<span class="sr-only"> products</span></span>
</label>
<label class="form-check product-filters__check">
<input class="form-check__control" type="checkbox" name="category" value="bottles" data-filter-label="Bottles" data-count="14">
<span class="form-label form-check__label">Bottles</span>
<span class="product-filters__count">14<span class="sr-only"> products</span></span>
</label>
<label class="form-check product-filters__check">
<input class="form-check__control" type="checkbox" name="category" value="accessories" data-filter-label="Accessories" data-count="22">
<span class="form-label form-check__label">Accessories</span>
<span class="product-filters__count">22<span class="sr-only"> products</span></span>
</label>
</div>
</fieldset>
</details>
<details class="product-filters__group" open>
<summary>Price</summary>
<fieldset class="product-filters__price" data-price-range style="--min: 0; --max: 400;">
<legend class="sr-only">Price range, in US dollars</legend>
<div class="product-filters__range" aria-hidden="true" hidden>
<input type="range" min="0" max="400" step="10" value="20" tabindex="-1" data-range-min>
<input type="range" min="0" max="400" step="10" value="250" tabindex="-1" data-range-max>
</div>
<div class="product-filters__price-inputs">
<div class="form-group">
<label class="form-label" for="filter-price-min">Min</label>
<div class="form-group--stacked">
<span class="form-group-label" aria-hidden="true">$</span>
<input class="form-control" id="filter-price-min" name="price_min" type="number" min="0" max="400" step="10" value="20" inputmode="numeric" data-input-min>
</div>
</div>
<div class="form-group">
<label class="form-label" for="filter-price-max">Max</label>
<div class="form-group--stacked">
<span class="form-group-label" aria-hidden="true">$</span>
<input class="form-control" id="filter-price-max" name="price_max" type="number" min="0" max="400" step="10" value="250" inputmode="numeric" data-input-max>
</div>
</div>
</div>
</fieldset>
</details>
<details class="product-filters__group" open>
<summary>Color</summary>
<fieldset>
<legend class="sr-only">Color</legend>
<div class="product-filters__colors">
<label class="product-filters__color" style="--swatch: hsl(0deg 0% 12%);">
<input type="checkbox" name="color" value="black" data-filter-label="Black">
<span class="sr-only">Black</span>
</label>
<label class="product-filters__color" style="--swatch: hsl(150deg 30% 32%);">
<input type="checkbox" name="color" value="green" data-filter-label="Green" checked>
<span class="sr-only">Green</span>
</label>
<label class="product-filters__color" style="--swatch: hsl(25deg 55% 38%);">
<input type="checkbox" name="color" value="brown" data-filter-label="Brown">
<span class="sr-only">Brown</span>
</label>
<label class="product-filters__color" style="--swatch: hsl(215deg 50% 40%);">
<input type="checkbox" name="color" value="blue" data-filter-label="Blue">
<span class="sr-only">Blue</span>
</label>
<label class="product-filters__color" style="--swatch: hsl(210deg 8% 62%);">
<input type="checkbox" name="color" value="grey" data-filter-label="Grey">
<span class="sr-only">Grey</span>
</label>
</div>
</fieldset>
</details>
<details class="product-filters__group">
<summary>Size</summary>
<fieldset>
<legend class="sr-only">Size</legend>
<div class="product-filters__sizes">
<label class="product-filters__size">
<input type="checkbox" name="size" value="s" data-filter-label="Size S">
<span>S</span>
</label>
<label class="product-filters__size">
<input type="checkbox" name="size" value="m" data-filter-label="Size M">
<span>M</span>
</label>
<label class="product-filters__size">
<input type="checkbox" name="size" value="l" data-filter-label="Size L">
<span>L</span>
</label>
<label class="product-filters__size">
<input type="checkbox" name="size" value="xl" data-filter-label="Size XL">
<span>XL</span>
</label>
</div>
</fieldset>
</details>
<div class="product-filters__group product-filters__group--plain">
<label class="form-switch">
<input class="form-switch__control" type="checkbox" role="switch" name="in_stock" value="1" data-filter-label="In stock only">
<span class="form-label form-switch__label">In stock only</span>
</label>
</div>
<footer class="product-filters__footer">
<button class="btn btn--primary btn--block" type="submit" data-filter-submit>Show results</button>
</footer>
<p class="sr-only" aria-live="polite" data-filter-live></p>
</form>
(() => {
document.querySelectorAll('[data-product-filters]').forEach((form) => {
const active = form.querySelector('[data-filter-active]');
const submit = form.querySelector('[data-filter-submit]');
const live = form.querySelector('[data-filter-live]');
const price = form.querySelector('[data-price-range]');
const rangeMin = price.querySelector('[data-range-min]');
const rangeMax = price.querySelector('[data-range-max]');
const inputMin = price.querySelector('[data-input-min]');
const inputMax = price.querySelector('[data-input-max]');
const limit = { min: Number(rangeMin.min), max: Number(rangeMin.max), step: Number(rangeMin.step) };
const money = (value) => `$${value}`;
let timer = null;
const setPrice = (min, max) => {
min = Math.max(limit.min, Math.min(Number(min), limit.max - limit.step));
max = Math.min(limit.max, Math.max(Number(max), min + limit.step));
rangeMin.value = inputMin.value = min;
rangeMax.value = inputMax.value = max;
price.style.setProperty('--from', `${((min - limit.min) / (limit.max - limit.min)) * 100}%`);
price.style.setProperty('--to', `${((max - limit.min) / (limit.max - limit.min)) * 100}%`);
};
const filters = () => [...form.querySelectorAll('[data-filter-label]:checked')];
const estimate = () => {
const checked = (name) => [...form.querySelectorAll(`[name="${name}"]:checked`)];
const categories = checked('category');
const base = categories.length
? categories.reduce((sum, input) => sum + Number(input.dataset.count), 0)
: [...form.querySelectorAll('[name="category"]')].reduce((sum, input) => sum + Number(input.dataset.count), 0);
const share = (name) => checked(name).length ? checked(name).length / form.querySelectorAll(`[name="${name}"]`).length : 1;
const priceShare = (inputMax.value - inputMin.value) / (limit.max - limit.min);
return Math.round(base * share('color') * share('size') * priceShare * (form.elements.in_stock.checked ? 0.8 : 1));
};
const chip = (label, onRemove) => {
const item = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.className = 'product-filters__chip';
button.innerHTML = '<span></span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg>';
button.firstChild.textContent = label;
button.setAttribute('aria-label', `Remove filter: ${label}`);
button.addEventListener('click', () => {
const index = [...active.children].indexOf(item);
onRemove();
update();
const target = active.children[Math.min(index, active.children.length - 1)];
(target?.querySelector('button') || form.querySelector('[data-filter-clear]')).focus();
});
item.append(button);
return item;
};
const update = () => {
const chips = filters().map((input) => chip(input.dataset.filterLabel, () => { input.checked = false; }));
if (Number(inputMin.value) > limit.min || Number(inputMax.value) < limit.max) {
chips.push(chip(`${money(inputMin.value)} – ${money(inputMax.value)}`, () => setPrice(limit.min, limit.max)));
}
active.replaceChildren(...chips);
active.hidden = chips.length === 0;
const count = estimate();
submit.textContent = `Show ${count} ${count === 1 ? 'result' : 'results'}`;
clearTimeout(timer);
timer = setTimeout(() => { live.textContent = `${count} products match the filters.`; }, 500);
};
price.querySelector('.product-filters__range').hidden = false;
rangeMin.addEventListener('input', () => setPrice(rangeMin.value, Math.max(rangeMax.value, Number(rangeMin.value) + limit.step)));
rangeMax.addEventListener('input', () => setPrice(Math.min(rangeMin.value, Number(rangeMax.value) - limit.step), rangeMax.value));
[inputMin, inputMax].forEach((input) => input.addEventListener('change', () => setPrice(inputMin.value, inputMax.value)));
form.addEventListener('input', update);
form.addEventListener('change', update);
form.querySelector('[data-filter-clear]').addEventListener('click', (event) => {
event.preventDefault();
filters().forEach((input) => { input.checked = false; });
setPrice(limit.min, limit.max);
update();
});
setPrice(inputMin.value, inputMax.value);
update();
});
})();