E-commerceecommerceshopfilterscatalogsearchform
Filter Sidebar
Фильтры по категории, цене, цвету, рейтингу и наличию, мгновенно фильтрующие и сортирующие ProductGrid с синхронизацией URL.
Живое превью
Использование
Скопируйте и вставьте этот код в ваш React-компонент:
tsx
import { FilterSidebar } from 'react-component-kit-ui/ecommerce';
<FilterSidebar target="shop-grid" id="shop-filters" maxPrice={300} />
<ProductToolbar target="shop-grid" filtersId="shop-filters" total={products.length} />
<ProductGrid id="shop-grid" products={products} />Ручная установка
Если вы не используете пакет npm, создайте файл и вставьте следующий код: src/components/FilterSidebar.tsx + FilterSidebar.css:
FilterSidebar.tsx
'use client';
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import { formatMoney } from './cart-store';
import './FilterSidebar.css';
/** class:list → className */
function cx(...args: unknown[]): string {
const out: string[] = [];
const walk = (v: unknown) => {
if (!v) return;
if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
else if (Array.isArray(v)) v.forEach(walk);
else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
};
args.forEach(walk);
return out.join(' ');
}
/** "a: b; --c: d" → style object */
function st(css: string | undefined): CSSProperties {
const o: Record<string, string> = {};
for (const decl of String(css ?? '').split(';')) {
const i = decl.indexOf(':');
if (i < 0) continue;
const k = decl.slice(0, i).trim();
if (k) o[k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = decl.slice(i + 1).trim();
}
return o as CSSProperties;
}
/**
* FilterSidebar — Catalog filters (category, max price, colour, rating, stock) that filter
* and sort a ProductGrid instantly in the browser. Collapses behind a "Filters" button on mobile.
*
* @param {string} target - id of the ProductGrid to filter. Default 'product-grid'.
* @param {string} id - Form id; ProductToolbar's sort select uses `${id}-sort`. Default 'catalog-filters'.
* @param {Array<{id: string, name: string, count?: number}>} categories - Category checkboxes.
* @param {string[]} selected - Pre-checked category ids (also read from `?category=` in the URL).
* @param {number} maxPrice - Upper bound of the price slider. Default 300.
* @param {Array<{name: string, hex: string}>} colors - Colour swatches.
* @param {string} currency - ISO currency. Default 'USD'.
* @param {string} locale - Number format locale. Default 'en-US'.
* @param {string} variant - Style (glass|plain). Default 'glass'.
* @param {string} size - Density (sm|md). Default 'md'.
* @param {string} className - Extra class.
*
* Accessibility: native form controls in fieldsets with legends; result count is announced
* by ProductToolbar's live region; the mobile toggle is a disclosure button.
*/
export interface FilterSidebarProps {
target?: string;
id?: string;
categories?: Array<{ id: string; name: string; count?: number }>;
selected?: string[];
maxPrice?: number;
colors?: Array<{ name: string; hex: string }>;
currency?: string;
locale?: string;
variant?: 'glass' | 'plain';
size?: 'sm' | 'md';
className?: string;
}
export function FilterSidebar({
target = 'product-grid',
id = 'catalog-filters',
categories = [
{ id: 'audio', name: 'Audio', count: 3 },
{ id: 'wearables', name: 'Wearables', count: 2 },
{ id: 'bags', name: 'Bags', count: 2 },
{ id: 'home', name: 'Home', count: 3 },
],
selected = [],
maxPrice = 300,
colors = [
{ name: 'Graphite', hex: '#2b2b2f' },
{ name: 'Sand', hex: '#d8c3a5' },
{ name: 'Ocean', hex: '#3d6f9e' },
{ name: 'Moss', hex: '#5f7a4e' },
{ name: 'Clay', hex: '#b5654a' },
],
currency = 'USD',
locale = 'en-US',
variant = 'glass',
size = 'md',
className = '',
}: FilterSidebarProps) {
const money = { currency, locale };
// Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
const mounted = useRef(false);
useEffect(() => {
if (mounted.current) return;
mounted.current = true;
document.querySelectorAll<HTMLFormElement>('form[data-filters]:not([data-bound])').forEach((form) => {
form.dataset.bound = '';
const root = form.closest<HTMLElement>('[data-filters-root]')!;
const target = document.getElementById(form.dataset.filters || '');
if (!target) return;
const grid: HTMLElement = target;
const wrap = grid.parentElement;
const empty = wrap?.querySelector<HTMLElement>('[data-product-empty]');
const cards = Array.from(grid.querySelectorAll<HTMLElement>('[data-product]'));
cards.forEach((c, i) => { c.dataset.index = String(i); });
const sort = document.getElementById(`${form.id}-sort`) as HTMLSelectElement | null;
const priceOut = form.querySelector<HTMLOutputElement>('[data-price-out]');
const toggle = root.querySelector<HTMLButtonElement>('[data-filters-toggle]');
const activeBadge = root.querySelector<HTMLElement>('[data-active-count]');
const money = { currency: form.dataset.currency, locale: form.dataset.locale };
const priceInput = form.elements.namedItem('max') as HTMLInputElement;
const ceiling = Number(priceInput.max);
// Pre-select categories from ?category=a,b
const params = new URLSearchParams(window.location.search);
const fromUrl = (params.get('category') || '').split(',').filter(Boolean);
if (fromUrl.length) {
form.querySelectorAll<HTMLInputElement>('input[name="category"]').forEach((i) => { i.checked = fromUrl.includes(i.value); });
}
if (sort && params.get('sort')) sort.value = params.get('sort')!;
function apply() {
const data = new FormData(form);
const cats = data.getAll('category') as string[];
const colors = data.getAll('color') as string[];
const max = Number(data.get('max'));
const minRating = Number(data.get('rating') || 0);
const stockOnly = data.get('stock') === 'in';
let visible = 0;
cards.forEach((card) => {
const d = card.dataset;
const cardColors = (d.colors || '').split(',').filter(Boolean);
const ok =
(!cats.length || cats.includes(d.category || '')) &&
Number(d.price) <= max &&
Number(d.rating) >= minRating &&
(!stockOnly || d.stock === 'in') &&
(!colors.length || colors.some((c) => cardColors.includes(c)));
card.hidden = !ok;
if (ok) visible++;
});
const mode = sort?.value || 'featured';
const sorted = [...cards].sort((a, b) => {
const A = a.dataset, B = b.dataset;
if (mode === 'price-asc') return Number(A.price) - Number(B.price);
if (mode === 'price-desc') return Number(B.price) - Number(A.price);
if (mode === 'rating') return Number(B.rating) - Number(A.rating);
if (mode === 'name') return (A.name || '').localeCompare(B.name || '');
return Number(A.index) - Number(B.index);
});
grid.append(...sorted);
if (empty) empty.hidden = visible > 0;
if (priceOut) priceOut.textContent = formatMoney(max, money);
document.querySelectorAll<HTMLElement>(`[data-result-count="${grid.id}"]`).forEach((n) => { n.textContent = String(visible); });
const active = cats.length + colors.length + (max < ceiling ? 1 : 0) + (minRating > 0 ? 1 : 0) + (stockOnly ? 1 : 0);
if (activeBadge) { activeBadge.hidden = active === 0; activeBadge.textContent = String(active); }
const url = new URL(window.location.href);
if (cats.length) url.searchParams.set('category', cats.join(','));
else url.searchParams.delete('category');
if (mode !== 'featured') url.searchParams.set('sort', mode);
else url.searchParams.delete('sort');
window.history.replaceState(null, '', url);
}
form.addEventListener('input', apply);
form.addEventListener('change', apply);
form.addEventListener('submit', (e) => { e.preventDefault(); apply(); });
form.addEventListener('reset', () => window.setTimeout(apply, 0));
sort?.addEventListener('change', apply);
toggle?.addEventListener('click', () => {
const open = toggle.getAttribute('aria-expanded') !== 'true';
toggle.setAttribute('aria-expanded', String(open));
root.classList.toggle('is-open', open);
});
apply();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div className={cx(['filters', `filters--${variant}`, `filters--${size}`, className])} data-filters-root data-rk-a56ca5="">
<button className="filters__toggle" type="button" aria-expanded="false" aria-controls={id} data-filters-toggle data-rk-a56ca5="">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true" data-rk-a56ca5=""><path d="M4 6h16M7 12h10M10 18h4" data-rk-a56ca5="" /></svg>
Filters
<span className="filters__active" data-active-count hidden data-rk-a56ca5=""></span>
</button>
<form className="filters__form" id={id} data-filters={target} data-currency={currency} data-locale={locale} aria-label="Product filters" data-rk-a56ca5="">
<fieldset className="filters__group" data-rk-a56ca5="">
<legend data-rk-a56ca5="">Category</legend>
{categories.map((c, __i: number) => (
<label key={__i} className="filters__check" data-rk-a56ca5="">
<input type="checkbox" name="category" defaultValue={c.id} defaultChecked={selected.includes(c.id)} data-rk-a56ca5="" />
<span className="filters__box" aria-hidden="true" data-rk-a56ca5=""></span>
<span className="filters__label" data-rk-a56ca5="">{c.name}</span>
{c.count !== undefined && <span className="filters__count" data-rk-a56ca5="">{c.count}</span>}
</label>
))}
</fieldset>
<fieldset className="filters__group" data-rk-a56ca5="">
<legend data-rk-a56ca5="">Price</legend>
<label className="filters__range" data-rk-a56ca5="">
<span className="filters__range-head" data-rk-a56ca5="">
<span data-rk-a56ca5="">Up to</span>
<output data-price-out data-rk-a56ca5="">{formatMoney(maxPrice, money)}</output>
</span>
<input type="range" name="max" min="0" max={maxPrice} step="5" defaultValue={maxPrice} aria-label="Maximum price" data-rk-a56ca5="" />
<span className="filters__range-scale" aria-hidden="true" data-rk-a56ca5=""><span data-rk-a56ca5="">{formatMoney(0, money)}</span><span data-rk-a56ca5="">{formatMoney(maxPrice, money)}</span></span>
</label>
</fieldset>
{colors.length > 0 && (
<fieldset className="filters__group" data-rk-a56ca5="">
<legend data-rk-a56ca5="">Colour</legend>
<div className="filters__swatches" data-rk-a56ca5="">
{colors.map((c, __i: number) => (
<label key={__i} className="filters__swatch" style={st(`--sw: ${c.hex}`)} data-rk-a56ca5="">
<input type="checkbox" name="color" defaultValue={c.name.toLowerCase()} data-rk-a56ca5="" />
<span className="filters__swatch-dot" aria-hidden="true" data-rk-a56ca5=""></span>
<span className="sr-only" data-rk-a56ca5="">{c.name}</span>
</label>
))}
</div>
</fieldset>
)}
<fieldset className="filters__group" data-rk-a56ca5="">
<legend data-rk-a56ca5="">Rating</legend>
{[['0', 'Any rating'], ['4.5', '4.5 ★ & up'], ['4', '4 ★ & up']].map(([v, l], i) => (
<label key={i} className="filters__check filters__check--radio" data-rk-a56ca5="">
<input type="radio" name="rating" defaultValue={v} defaultChecked={i === 0} data-rk-a56ca5="" />
<span className="filters__box" aria-hidden="true" data-rk-a56ca5=""></span>
<span className="filters__label" data-rk-a56ca5="">{l}</span>
</label>
))}
</fieldset>
<fieldset className="filters__group" data-rk-a56ca5="">
<legend className="sr-only" data-rk-a56ca5="">Availability</legend>
<label className="filters__switch" data-rk-a56ca5="">
<span data-rk-a56ca5="">In stock only</span>
<input type="checkbox" name="stock" defaultValue="in" role="switch" data-rk-a56ca5="" />
<span className="filters__track" aria-hidden="true" data-rk-a56ca5=""></span>
</label>
</fieldset>
<button className="filters__reset" type="reset" data-rk-a56ca5="">Clear all filters</button>
</form>
</div>
</>
);
}
export default FilterSidebar;FilterSidebar.css
/* FilterSidebar — ported from astro-component-kit (ecommerce/FilterSidebar.astro) */
.filters:where([data-rk-a56ca5]) { container-type: inline-size; position: relative; }
.filters__form:where([data-rk-a56ca5]) {
display: grid;
gap: var(--sp-2, 0.5rem);
}
.filters--glass:where([data-rk-a56ca5]) .filters__form:where([data-rk-a56ca5]) {
padding: var(--sp-5, 1.25rem);
border-radius: var(--r-lg, 20px);
border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05));
background: var(--c-surface, rgb(var(--ink-rgb, 255 255 255) / 0.02));
}
.filters__group:where([data-rk-a56ca5]) {
display: grid;
gap: 2px;
margin: 0;
padding: 0 0 var(--sp-4, 1rem);
border: 0;
border-bottom: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05));
}
.filters__group:last-of-type:where([data-rk-a56ca5]) { border-bottom: 0; }
.filters__group:where([data-rk-a56ca5]) legend:where([data-rk-a56ca5]) {
padding: var(--sp-2, 0.5rem) 0;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--c-text-1, #fff);
}
.filters__check:where([data-rk-a56ca5]) {
position: relative;
display: flex;
align-items: center;
gap: var(--sp-3, 0.75rem);
min-height: 40px;
padding: 0 var(--sp-2, 0.5rem);
margin: 0 calc(var(--sp-2, 0.5rem) * -1);
border-radius: var(--r-sm, 8px);
cursor: pointer;
color: var(--c-text-2, #a3a3a3);
font-size: 0.9rem;
}
.filters--sm:where([data-rk-a56ca5]) .filters__check:where([data-rk-a56ca5]) { min-height: 34px; font-size: 0.85rem; }
.filters__check:hover:where([data-rk-a56ca5]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.03); color: var(--c-text-1, #fff); }
.filters__check:where([data-rk-a56ca5]) input:where([data-rk-a56ca5]),
.filters__swatch:where([data-rk-a56ca5]) input:where([data-rk-a56ca5]) { position: absolute; opacity: 0; width: 1px; height: 1px; }
.filters__box:where([data-rk-a56ca5]) {
display: grid;
place-items: center;
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 5px;
border: 1.5px solid rgb(var(--ink-rgb, 255 255 255) / 0.25);
transition: background 0.15s ease, border-color 0.15s ease;
}
.filters__check--radio:where([data-rk-a56ca5]) .filters__box:where([data-rk-a56ca5]) { border-radius: 50%; }
.filters__check:where([data-rk-a56ca5]) input:checked:where([data-rk-a56ca5]) + .filters__box:where([data-rk-a56ca5]) {
border-color: var(--c-primary-light, #d4a017);
background: var(--c-primary-light, #d4a017);
box-shadow: inset 0 0 0 3px var(--c-bg-1, #0a0a0a);
}
.filters__check:where([data-rk-a56ca5]) input:checked:where([data-rk-a56ca5]) ~ .filters__label:where([data-rk-a56ca5]) { color: var(--c-text-1, #fff); font-weight: 600; }
.filters__check:where([data-rk-a56ca5]) input:focus-visible:where([data-rk-a56ca5]) + .filters__box:where([data-rk-a56ca5]),
.filters__swatch:where([data-rk-a56ca5]) input:focus-visible:where([data-rk-a56ca5]) + .filters__swatch-dot:where([data-rk-a56ca5]),
.filters__switch:where([data-rk-a56ca5]) input:focus-visible:where([data-rk-a56ca5]) + .filters__track:where([data-rk-a56ca5]) { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 2px; }
.filters__label:where([data-rk-a56ca5]) { flex: 1; }
.filters__count:where([data-rk-a56ca5]) { font-size: 0.75rem; color: var(--c-text-2, #a3a3a3); }
.filters__range:where([data-rk-a56ca5]) { display: grid; gap: var(--sp-2, 0.5rem); }
.filters__range-head:where([data-rk-a56ca5]) { display: flex; justify-content: space-between; font-size: 0.85rem; }
.filters__range-head:where([data-rk-a56ca5]) output:where([data-rk-a56ca5]) { color: var(--c-text-1, #fff); font-weight: 700; }
.filters__range:where([data-rk-a56ca5]) input:where([data-rk-a56ca5]) { width: 100%; accent-color: var(--c-primary-light, #d4a017); height: 24px; cursor: pointer; }
.filters__range-scale:where([data-rk-a56ca5]) { display: flex; justify-content: space-between; font-size: 0.72rem; color: var(--c-text-2, #a3a3a3); }
.filters__swatches:where([data-rk-a56ca5]) { display: flex; flex-wrap: wrap; gap: var(--sp-2, 0.5rem); padding-top: 2px; }
.filters__swatch:where([data-rk-a56ca5]) { position: relative; display: grid; place-items: center; width: 40px; height: 40px; cursor: pointer; }
.filters__swatch-dot:where([data-rk-a56ca5]) {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--sw);
box-shadow: 0 0 0 1px rgb(var(--ink-rgb, 255 255 255) / 0.2);
transition: box-shadow 0.15s ease, transform 0.15s ease;
}
.filters__swatch:hover:where([data-rk-a56ca5]) .filters__swatch-dot:where([data-rk-a56ca5]) { transform: scale(1.08); }
.filters__swatch:where([data-rk-a56ca5]) input:checked:where([data-rk-a56ca5]) + .filters__swatch-dot:where([data-rk-a56ca5]) { box-shadow: 0 0 0 2px var(--c-bg-1, #0a0a0a), 0 0 0 4px var(--c-primary-light, #d4a017); }
.filters__switch:where([data-rk-a56ca5]) {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 44px;
font-size: 0.9rem;
color: var(--c-text-1, #fff);
cursor: pointer;
}
.filters__switch:where([data-rk-a56ca5]) input:where([data-rk-a56ca5]) { position: absolute; opacity: 0; width: 1px; height: 1px; }
.filters__track:where([data-rk-a56ca5]) {
position: relative;
width: 42px;
height: 24px;
border-radius: 999px;
background: rgb(var(--ink-rgb, 255 255 255) / 0.12);
transition: background 0.2s ease;
}
.filters__track:where([data-rk-a56ca5])::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--c-text-1, #fff);
transition: transform 0.2s ease;
}
.filters__switch:where([data-rk-a56ca5]) input:checked:where([data-rk-a56ca5]) + .filters__track:where([data-rk-a56ca5]) { background: var(--c-primary-light, #d4a017); }
.filters__switch:where([data-rk-a56ca5]) input:checked:where([data-rk-a56ca5]) + .filters__track:where([data-rk-a56ca5])::after { transform: translateX(18px); }
.filters__reset:where([data-rk-a56ca5]) {
min-height: 44px;
margin-top: var(--sp-2, 0.5rem);
border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.12);
border-radius: var(--r-full, 9999px);
background: transparent;
color: var(--c-text-1, #fff);
font: inherit;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.filters__reset:hover:where([data-rk-a56ca5]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.05); }
.filters__reset:focus-visible:where([data-rk-a56ca5]), .filters__toggle:focus-visible:where([data-rk-a56ca5]) { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 2px; }
.filters__toggle:where([data-rk-a56ca5]) {
display: none;
align-items: center;
gap: var(--sp-2, 0.5rem);
min-height: 44px;
padding: 0 var(--sp-4, 1rem);
border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.12);
border-radius: var(--r-full, 9999px);
background: rgb(var(--ink-rgb, 255 255 255) / 0.03);
color: var(--c-text-1, #fff);
font: inherit;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.filters__active:where([data-rk-a56ca5]) {
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: var(--c-primary-light, #d4a017);
color: var(--c-on-primary, #050505);
font-size: 0.72rem;
font-weight: 800;
line-height: 20px;
}
.filters__active[hidden]:where([data-rk-a56ca5]) { display: none; }
.sr-only:where([data-rk-a56ca5]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@container (max-width: 900px) {
.filters__toggle:where([data-rk-a56ca5]) { display: inline-flex; }
.filters__form:where([data-rk-a56ca5]) { display: none; margin-top: var(--sp-3, 0.75rem); }
.filters.is-open:where([data-rk-a56ca5]) .filters__form:where([data-rk-a56ca5]) { display: grid; }
}
@media (prefers-reduced-motion: reduce) {
.filters__track:where([data-rk-a56ca5]), .filters__track:where([data-rk-a56ca5])::after, .filters__swatch-dot:where([data-rk-a56ca5]) { transition: none; }
}Краткая информация
- Категория
- E-commerce
- Имя файла
FilterSidebar.tsx- Зависимости
- Нет — чистый React + CSS
- Теги
- ecommerceshopfilterscatalogsearchform