E-commerceecommerceshopfilterstoolbarcatalogsort
Filter Bar
Barra horizontal de catálogo con chips de categoría, panel de refinado (precio, color, stock), orden y contador.
Vista Previa en Vivo
0 products
Uso
Copia y pega este código directamente en tu componente React:
tsx
import { FilterBar } from 'react-component-kit-ui/ecommerce';
<FilterBar target="grid" total={12} />
<ul id="grid">{products.map((p, __i: number) => <li key={__i}><ProductCardMinimal product={p} /></li>)}</ul>Instalación Manual
Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: src/components/FilterBar.tsx + FilterBar.css:
FilterBar.tsx
'use client';
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import { formatMoney } from './cart-store';
import { applyCatalogFilter, syncCatalogUrl } from './catalog-filter';
import './FilterBar.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;
}
/**
* FilterBar — Horizontal catalogue toolbar: category chips, a "Refine" panel (price, colour,
* availability), sort select and a live count. Filters any grid of product cards instantly.
*
* @param {string} target - id of the product grid. Default 'product-grid'.
* @param {Array<{id: string, name: string, count?: number}>} categories - Category chips.
* @param {string[]} selected - Pre-selected category ids (also read from `?category=`).
* @param {number} maxPrice - Upper bound of the price slider. Default 400.
* @param {Array<{name: string, hex: string}>} colors - Colour options.
* @param {number} total - Initial product count. Default 0.
* @param {string} allLabel - Label of the "all" chip. Default 'All'.
* @param {string} countLabel - Text after the count. Default 'products'.
* @param {string} currency - ISO currency. Default 'EUR'.
* @param {string} locale - Number format locale. Default 'en-GB'.
* @param {boolean} sticky - Stick under the header while scrolling. Default true.
* @param {string} variant - Style (line|filled). Default 'line'.
* @param {string} id - Base id. Default 'filter-bar'.
* @param {string} className - Extra class.
*
* Accessibility: chips are toggle buttons (aria-pressed); "Refine" is a disclosure that closes
* with Escape; the count is a polite live region; all controls are native inputs.
*/
export interface FilterBarProps {
target?: string;
categories?: Array<{ id: string; name: string; count?: number }>;
selected?: string[];
maxPrice?: number;
colors?: Array<{ name: string; hex: string }>;
total?: number;
allLabel?: string;
countLabel?: string;
currency?: string;
locale?: string;
sticky?: boolean;
variant?: 'line' | 'filled';
id?: string;
className?: string;
}
export function FilterBar({
target = 'product-grid',
categories = [
{ id: 'clothing', name: 'Clothing', count: 5 },
{ id: 'shoes', name: 'Shoes', count: 2 },
{ id: 'bags', name: 'Bags', count: 2 },
{ id: 'objects', name: 'Objects', count: 3 },
],
selected = [],
maxPrice = 400,
colors = [
{ name: 'Oat', hex: '#d9cbb3' },
{ name: 'Ink', hex: '#22252b' },
{ name: 'Rust', hex: '#9c3d1c' },
{ name: 'Sage', hex: '#8a9a7b' },
],
total = 0,
allLabel = 'All',
countLabel = 'products',
currency = 'EUR',
locale = 'en-GB',
sticky = true,
variant = 'line',
id = 'filter-bar',
className = '',
}: FilterBarProps) {
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<HTMLElement>('[data-filter-bar]:not([data-bound])').forEach((bar) => {
bar.dataset.bound = '';
const grid = document.getElementById(bar.dataset.target || '');
if (!grid) return;
const target: HTMLElement = grid;
const chips = Array.from(bar.querySelectorAll<HTMLButtonElement>('[data-chip]'));
const panel = bar.querySelector<HTMLFormElement>('[data-panel]')!;
const refine = bar.querySelector<HTMLButtonElement>('[data-refine]')!;
const sort = bar.querySelector<HTMLSelectElement>('[data-sort]')!;
const active = bar.querySelector<HTMLElement>('[data-active]')!;
const priceOut = bar.querySelector<HTMLOutputElement>('[data-price-out]');
const priceInput = panel.elements.namedItem('max') as HTMLInputElement;
const money = { currency: bar.dataset.currency, locale: bar.dataset.locale };
const params = new URLSearchParams(window.location.search);
let categories = (params.get('category') || '').split(',').filter(Boolean);
if (!categories.length) categories = chips.filter((c) => c.dataset.chip && c.getAttribute('aria-pressed') === 'true').map((c) => c.dataset.chip!);
if (params.get('sort')) sort.value = params.get('sort')!;
function run() {
const data = new FormData(panel);
const max = Number(data.get('max'));
const colors = data.getAll('color') as string[];
const inStockOnly = data.get('stock') === 'in';
chips.forEach((c) => {
const id = c.dataset.chip;
c.setAttribute('aria-pressed', String(id ? categories.includes(id) : categories.length === 0));
});
applyCatalogFilter(target, { categories, colors, maxPrice: max, minRating: 0, inStockOnly, sort: sort.value });
syncCatalogUrl({ categories, sort: sort.value });
if (priceOut) priceOut.textContent = formatMoney(max, money);
const n = colors.length + (inStockOnly ? 1 : 0) + (max < Number(priceInput.max) ? 1 : 0);
active.hidden = n === 0;
active.textContent = String(n);
}
chips.forEach((chip) => chip.addEventListener('click', () => {
const id = chip.dataset.chip;
if (!id) categories = [];
else categories = categories.includes(id) ? categories.filter((c) => c !== id) : [...categories, id];
run();
}));
const setOpen = (open: boolean) => {
panel.hidden = !open;
refine.setAttribute('aria-expanded', String(open));
};
refine.addEventListener('click', () => setOpen(panel.hidden));
bar.querySelector('[data-close]')?.addEventListener('click', () => { setOpen(false); refine.focus(); });
bar.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !panel.hidden) { setOpen(false); refine.focus(); } });
panel.addEventListener('input', run);
panel.addEventListener('reset', () => window.setTimeout(run, 0));
sort.addEventListener('change', run);
run();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div className={cx(['fbar', `fbar--${variant}`, { 'fbar--sticky': sticky }, className])} data-filter-bar data-target={target} data-currency={currency} data-locale={locale} data-rk-aeb6ec="">
<div className="fbar__row" data-rk-aeb6ec="">
<div className="fbar__chips" role="group" aria-label="Categories" data-rk-aeb6ec="">
<button type="button" className="fbar__chip" data-chip="" aria-pressed={selected.length === 0 ? 'true' : 'false'} data-rk-aeb6ec="">{allLabel}</button>
{categories.map((c, __i: number) => (
<button key={__i} type="button" className="fbar__chip" data-chip={c.id} aria-pressed={selected.includes(c.id) ? 'true' : 'false'} data-rk-aeb6ec="">
{c.name}{c.count !== undefined && <sup data-rk-aeb6ec="">{c.count}</sup>}
</button>
))}
</div>
<div className="fbar__tools" data-rk-aeb6ec="">
<p className="fbar__count" aria-live="polite" data-rk-aeb6ec=""><span data-result-count={target} data-rk-aeb6ec="">{total}</span> {countLabel}</p>
<button type="button" className="fbar__refine" aria-expanded="false" aria-controls={`${id}-panel`} data-refine data-rk-aeb6ec="">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true" data-rk-aeb6ec=""><path d="M4 7h10M18 7h2M4 17h4M12 17h8" data-rk-aeb6ec="" /><circle cx="16" cy="7" r="2" data-rk-aeb6ec="" /><circle cx="10" cy="17" r="2" data-rk-aeb6ec="" /></svg>
Refine <span className="fbar__badge" data-active hidden data-rk-aeb6ec=""></span>
</button>
<label className="fbar__sort" data-rk-aeb6ec="">
<span className="sr-only" data-rk-aeb6ec="">Sort by</span>
<select data-sort data-rk-aeb6ec="">
<option value="featured" data-rk-aeb6ec="">Featured</option>
<option value="price-asc" data-rk-aeb6ec="">Price, low to high</option>
<option value="price-desc" data-rk-aeb6ec="">Price, high to low</option>
<option value="name" data-rk-aeb6ec="">Alphabetical</option>
</select>
</label>
</div>
</div>
<form className="fbar__panel" id={`${id}-panel`} hidden data-panel aria-label="Refine products" data-rk-aeb6ec="">
<fieldset data-rk-aeb6ec="">
<legend data-rk-aeb6ec="">Price</legend>
<label className="fbar__range" data-rk-aeb6ec="">
<span data-rk-aeb6ec="">Up to <output data-price-out data-rk-aeb6ec="">{formatMoney(maxPrice, money)}</output></span>
<input type="range" name="max" min="0" max={maxPrice} step="5" defaultValue={maxPrice} aria-label="Maximum price" data-rk-aeb6ec="" />
</label>
</fieldset>
{colors.length > 0 && (
<fieldset data-rk-aeb6ec="">
<legend data-rk-aeb6ec="">Colour</legend>
<div className="fbar__colors" data-rk-aeb6ec="">
{colors.map((c, __i: number) => (
<label key={__i} className="fbar__color" style={st(`--sw: ${c.hex}`)} data-rk-aeb6ec="">
<input type="checkbox" name="color" defaultValue={c.name.toLowerCase()} data-rk-aeb6ec="" />
<span aria-hidden="true" data-rk-aeb6ec=""></span>{c.name}
</label>
))}
</div>
</fieldset>
)}
<fieldset data-rk-aeb6ec="">
<legend data-rk-aeb6ec="">Availability</legend>
<label className="fbar__check" data-rk-aeb6ec=""><input type="checkbox" name="stock" defaultValue="in" data-rk-aeb6ec="" /> <span data-rk-aeb6ec="">In stock only</span></label>
</fieldset>
<div className="fbar__panel-actions" data-rk-aeb6ec="">
<button type="reset" className="fbar__link" data-rk-aeb6ec="">Clear filters</button>
<button type="button" className="fbar__done" data-close data-rk-aeb6ec="">Show results</button>
</div>
</form>
</div>
</>
);
}
export default FilterBar;FilterBar.css
/* FilterBar — ported from astro-component-kit (ecommerce/FilterBar.astro) */
.fbar:where([data-rk-aeb6ec]) { container-type: inline-size; position: relative; z-index: 5; border-block: 1px solid var(--c-border, rgba(255, 255, 255, 0.1)); background: var(--c-bg, #050505); }
.fbar--sticky:where([data-rk-aeb6ec]) { position: sticky; top: var(--shop-header-h, 0px); }
.fbar--filled:where([data-rk-aeb6ec]) { background: var(--c-bg-1, #0a0a0a); }
.fbar__row:where([data-rk-aeb6ec]) { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4, 1rem); min-height: 60px; }
.fbar__chips:where([data-rk-aeb6ec]) { display: flex; gap: 4px; overflow-x: auto; scrollbar-width: none; padding-block: 8px; }
.fbar__chips:where([data-rk-aeb6ec])::-webkit-scrollbar { display: none; }
.fbar__chip:where([data-rk-aeb6ec]) {
display: inline-flex;
align-items: flex-start;
gap: 3px;
min-height: 40px;
padding: 0.55rem 0.9rem;
border: 1px solid transparent;
border-radius: var(--r-full, 999px);
background: transparent;
color: var(--c-text-2, #a3a3a3);
font: inherit;
font-size: 0.88rem;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.fbar__chip:where([data-rk-aeb6ec]) sup:where([data-rk-aeb6ec]) { font-size: 0.62rem; opacity: 0.8; }
.fbar__chip:hover:where([data-rk-aeb6ec]) { color: var(--c-text-1, #fff); border-color: var(--c-border, rgba(255, 255, 255, 0.15)); }
.fbar__chip[aria-pressed='true']:where([data-rk-aeb6ec]) { color: var(--c-bg, #050505); background: var(--c-text-1, #fff); }
.fbar__tools:where([data-rk-aeb6ec]) { display: flex; align-items: center; gap: var(--sp-3, 0.75rem); flex-shrink: 0; }
.fbar__count:where([data-rk-aeb6ec]) { margin: 0; font-size: 0.8rem; color: var(--c-text-2, #a3a3a3); white-space: nowrap; }
.fbar__count:where([data-rk-aeb6ec]) span:where([data-rk-aeb6ec]) { color: var(--c-text-1, #fff); font-weight: 700; }
.fbar__refine:where([data-rk-aeb6ec]), .fbar__sort:where([data-rk-aeb6ec]) select:where([data-rk-aeb6ec]) {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 40px;
padding: 0 0.9rem;
border: 1px solid var(--c-border, rgba(255, 255, 255, 0.15));
border-radius: var(--r-sm, 2px);
background: transparent;
color: var(--c-text-1, #fff);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.fbar__sort:where([data-rk-aeb6ec]) select:where([data-rk-aeb6ec]) { appearance: none; padding-right: 2rem; background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%); background-position: calc(100% - 15px) 50%, calc(100% - 10px) 50%; background-size: 5px 5px; background-repeat: no-repeat; }
.fbar__sort:where([data-rk-aeb6ec]) select:where([data-rk-aeb6ec]) option:where([data-rk-aeb6ec]) { background: var(--c-bg-1, #0a0a0a); color: var(--c-text-1, #fff); }
.fbar__refine[aria-expanded='true']:where([data-rk-aeb6ec]) { background: var(--c-text-1, #fff); color: var(--c-bg, #050505); }
.fbar__badge:where([data-rk-aeb6ec]) { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--shop-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); font-size: 0.68rem; font-weight: 800; line-height: 18px; text-align: center; }
.fbar__badge[hidden]:where([data-rk-aeb6ec]) { display: none; }
.fbar:where([data-rk-aeb6ec]) :is(button, select, input):focus-visible:where([data-rk-aeb6ec]) { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }
.fbar__panel:where([data-rk-aeb6ec]) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)) auto;
gap: var(--sp-6, 1.5rem);
padding: var(--sp-5, 1.25rem) 0 var(--sp-6, 1.5rem);
border-top: 1px solid var(--c-border, rgba(255, 255, 255, 0.1));
}
.fbar__panel[hidden]:where([data-rk-aeb6ec]) { display: none; }
.fbar__panel:where([data-rk-aeb6ec]) fieldset:where([data-rk-aeb6ec]) { margin: 0; padding: 0; border: 0; min-width: 0; }
.fbar__panel:where([data-rk-aeb6ec]) legend:where([data-rk-aeb6ec]) { margin-bottom: var(--sp-3, 0.75rem); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--c-text-2, #a3a3a3); }
.fbar__range:where([data-rk-aeb6ec]) { display: grid; gap: var(--sp-2, 0.5rem); font-size: 0.88rem; color: var(--c-text-1, #fff); }
.fbar__range:where([data-rk-aeb6ec]) input:where([data-rk-aeb6ec]) { width: 100%; accent-color: var(--shop-accent, var(--c-primary-light, #d4a017)); }
.fbar__colors:where([data-rk-aeb6ec]) { display: flex; flex-wrap: wrap; gap: var(--sp-2, 0.5rem); }
.fbar__color:where([data-rk-aeb6ec]) { position: relative; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 0.7rem 0 0.4rem; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.15)); border-radius: 999px; font-size: 0.82rem; color: var(--c-text-1, #fff); cursor: pointer; }
.fbar__color:where([data-rk-aeb6ec]) input:where([data-rk-aeb6ec]) { position: absolute; opacity: 0; width: 1px; height: 1px; }
.fbar__color:where([data-rk-aeb6ec]) span:where([data-rk-aeb6ec]) { width: 18px; height: 18px; border-radius: 50%; background: var(--sw); box-shadow: inset 0 0 0 1px rgb(var(--ink-rgb, 255 255 255) / 0.2); }
.fbar__color:has(input:checked):where([data-rk-aeb6ec]) { border-color: var(--c-text-1, #fff); background: rgb(var(--ink-rgb, 255 255 255) / 0.06); }
.fbar__color:has(input:focus-visible):where([data-rk-aeb6ec]) { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }
.fbar__check:where([data-rk-aeb6ec]) { display: inline-flex; align-items: center; gap: var(--sp-2, 0.5rem); min-height: 36px; font-size: 0.88rem; color: var(--c-text-1, #fff); cursor: pointer; }
.fbar__check:where([data-rk-aeb6ec]) input:where([data-rk-aeb6ec]) { width: 18px; height: 18px; accent-color: var(--shop-accent, var(--c-primary-light, #d4a017)); }
.fbar__panel-actions:where([data-rk-aeb6ec]) { display: flex; flex-direction: column; align-items: flex-end; justify-content: flex-end; gap: var(--sp-2, 0.5rem); }
.fbar__link:where([data-rk-aeb6ec]) { min-height: 36px; border: 0; background: none; color: var(--c-text-2, #a3a3a3); font: inherit; font-size: 0.85rem; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
.fbar__done:where([data-rk-aeb6ec]) { min-height: 44px; padding: 0 1.25rem; border: 0; border-radius: var(--r-sm, 2px); background: var(--c-text-1, #fff); color: var(--c-bg, #050505); font: inherit; font-size: 0.85rem; font-weight: 700; cursor: pointer; }
.sr-only:where([data-rk-aeb6ec]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@container (max-width: 860px) {
.fbar__row:where([data-rk-aeb6ec]) { flex-direction: column; align-items: stretch; gap: 0; }
.fbar__tools:where([data-rk-aeb6ec]) { justify-content: space-between; padding-bottom: 10px; }
.fbar__panel:where([data-rk-aeb6ec]) { grid-template-columns: 1fr; }
.fbar__panel-actions:where([data-rk-aeb6ec]) { flex-direction: row; justify-content: space-between; }
}Información Rápida
- Categoría
- E-commerce
- Nombre de archivo
FilterBar.tsx- Dependencias
- Ninguna — React + CSS puro
- Etiquetas
- ecommerceshopfilterstoolbarcatalogsort