1 звезда на GitHub = 1 новый компонент.

Поставить звезду
E-commerceecommerceshopfilterstoolbarcatalogsort

Filter Bar

Горизонтальная панель каталога: чипы категорий, панель уточнения (цена, цвет, наличие), сортировка и счётчик.

Живое превью

0 products

Использование

Скопируйте и вставьте этот код в ваш 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>

Ручная установка

Если вы не используете пакет npm, создайте файл и вставьте следующий код: 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; }
}

Краткая информация

Категория
E-commerce
Имя файла
FilterBar.tsx
Зависимости
Нет — чистый React + CSS
Теги
ecommerceshopfilterstoolbarcatalogsort