← Files WixARCHIVED FILE
skills/wix-headless-templates/storefront/app/components/storefront/FilterPanel.tsx
14.5 KB · Oct 8, 2026 · 12:02 UTC
// The gallery's filter layout — wire as-is, your grid goes inside:
//
// <FilterPanel shop={shop}>
// …your loading / empty / error states and your grid of tiles…
// </FilterPanel>
//
// Renders: a toolbar (result count, sort, and under `md` a Filters button); active-filter chips;
// then on `md`+ a two-column layout — a 16rem sidebar of collapsible groups (Price as a two-handle
// slider with numeric fields, Availability, one group per facet — an option or a choice modifier —
// with swatches or pills) beside your results, so expanded facets never push the products below
// the fold. Under `md` the same groups live in a bottom sheet: changes are staged and commit on
// Apply; dismissing keeps the current results. Sidebar changes commit immediately (a price pair on
// release/blur, valid only). Picks inside one facet OR together, facets AND together — the store
// owns that. Styled from the @theme tokens.
import { useEffect, useRef, useState, type ReactNode } from "react";
import { SORTS, type ShopFilters, type UseShop } from "../../hooks/storefront/useShop";
import type { CatalogSort } from "../../wix/storefront/catalog";
interface Draft {
min: string;
max: string;
inStockOnly: boolean;
choiceIds: string[];
}
const draftFrom = (shop: UseShop): Draft => ({
min: String(shop.filters.minPrice ?? ""),
max: String(shop.filters.maxPrice ?? ""),
inStockOnly: !!shop.filters.inStockOnly,
choiceIds: shop.selectedChoiceIds,
});
function validPricePair(min: string, max: string): boolean {
if (min && !Number.isFinite(Number(min))) return false;
if (max && !Number.isFinite(Number(max))) return false;
if (min && max && Number(min) > Number(max)) return false;
return true;
}
export default function FilterPanel({ shop, children }: { shop: UseShop; children?: ReactNode }) {
const [sheetOpen, setSheetOpen] = useState(false);
const [staged, setStaged] = useState<Draft>(() => draftFrom(shop));
const panelRef = useRef<HTMLElement>(null);
// The sheet is an overlay: root-level, scrim, scroll lock, Escape, focus in and back.
useEffect(() => {
if (!sheetOpen) return;
setStaged(draftFrom(shop));
const opener = document.activeElement;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
panelRef.current?.focus();
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setSheetOpen(false);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = previousOverflow;
if (opener instanceof HTMLElement) opener.focus();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sheetOpen]);
const commit = (d: Draft) => {
if (!validPricePair(d.min, d.max)) return;
const next: ShopFilters = {
...shop.filters,
minPrice: d.min || undefined,
maxPrice: d.max || undefined,
inStockOnly: d.inStockOnly || undefined,
};
const same =
next.minPrice === shop.filters.minPrice &&
next.maxPrice === shop.filters.maxPrice &&
!!next.inStockOnly === !!shop.filters.inStockOnly;
if (!same) shop.setFilters(next);
for (const id of d.choiceIds) if (!shop.selectedChoiceIds.includes(id)) shop.toggleChoice(id);
for (const id of shop.selectedChoiceIds) if (!d.choiceIds.includes(id)) shop.toggleChoice(id);
};
const activeCount =
shop.selectedChoiceIds.length + (shop.filters.inStockOnly ? 1 : 0) + (shop.filters.minPrice || shop.filters.maxPrice ? 1 : 0);
const choiceName = (id: string) => shop.facets.flatMap((f) => f.choices).find((c) => c.id === id)?.name ?? id;
return (
<div className="flex flex-col gap-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground" aria-live="polite">
{shop.total != null ? `${shop.total} ${shop.total === 1 ? "product" : "products"}` : ""}
</p>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setSheetOpen(true)} className="rounded-md border border-border px-3 py-1.5 text-sm md:hidden">
Filters{activeCount ? ` (${activeCount})` : ""}
</button>
<label className="sr-only" htmlFor="shop-sort">Sort by</label>
<select id="shop-sort" value={shop.sort} onChange={(e) => shop.setSort(e.target.value as CatalogSort)}
className="rounded-md border border-border bg-background px-2 py-1.5 text-sm">
{Object.entries(SORTS).map(([key, { label }]) => <option key={key} value={key}>{label}</option>)}
</select>
</div>
</div>
{/* Active chips */}
{shop.hasActiveFilters && (
<div className="flex flex-wrap items-center gap-1.5" aria-label="Active filters">
{shop.selectedChoiceIds.map((id) => (
<button key={id} type="button" onClick={() => shop.toggleChoice(id)} className="rounded-control bg-secondary px-2.5 py-1 text-xs text-foreground">
{choiceName(id)} ×
</button>
))}
{shop.filters.inStockOnly && (
<button type="button" onClick={() => shop.setFilters({ ...shop.filters, inStockOnly: undefined })} className="rounded-control bg-secondary px-2.5 py-1 text-xs">In stock ×</button>
)}
{(shop.filters.minPrice || shop.filters.maxPrice) && (
<button type="button" onClick={() => shop.setFilters({ ...shop.filters, minPrice: undefined, maxPrice: undefined })} className="rounded-control bg-secondary px-2.5 py-1 text-xs">
Price {shop.filters.minPrice ?? shop.priceRange?.min ?? "…"}–{shop.filters.maxPrice ?? shop.priceRange?.max ?? "…"} ×
</button>
)}
<button type="button" onClick={shop.clearFilters} className="text-xs text-muted-foreground underline">Clear all</button>
</div>
)}
{/* Sidebar + results */}
<div className="md:grid md:grid-cols-[16rem_minmax(0,1fr)] md:gap-8">
<aside className="hidden md:block" aria-label="Filters">
<Groups shop={shop} draft={draftFrom(shop)} onChange={commit} immediate />
</aside>
<div className="min-w-0">{children}</div>
</div>
{/* Sheet — small screens; staged until Apply */}
{sheetOpen && (
<div className="fixed inset-0 z-50 flex items-end bg-foreground/40 md:hidden" onClick={() => setSheetOpen(false)}>
<section ref={panelRef} tabIndex={-1} role="dialog" aria-modal="true" aria-label="Filters" onClick={(e) => e.stopPropagation()}
className="flex max-h-[85vh] w-full flex-col rounded-t-2xl bg-background text-foreground outline-none">
<div className="flex items-center justify-between px-5 pt-5">
<h2 className="text-base font-semibold">Filters</h2>
<button type="button" aria-label="Close filters" onClick={() => setSheetOpen(false)} className="text-xl leading-none">×</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<Groups shop={shop} draft={staged} onChange={setStaged} />
</div>
<div className="flex gap-2 border-t border-border bg-background px-5 py-3">
<button type="button" onClick={() => setStaged({ min: "", max: "", inStockOnly: false, choiceIds: [] })}
className="flex-1 rounded-control border border-border py-2.5 text-sm">Clear</button>
<button type="button" onClick={() => { commit(staged); setSheetOpen(false); }}
className="flex-1 rounded-control bg-primary py-2.5 text-sm font-semibold text-primary-foreground">Apply</button>
</div>
</section>
</div>
)}
</div>
);
}
// The filter groups — one markup for the sidebar (immediate commit) and the sheet (staged).
function Groups({ shop, draft, onChange, immediate = false }: { shop: UseShop; draft: Draft; onChange: (d: Draft) => void; immediate?: boolean }) {
const toggle = (id: string) =>
onChange({ ...draft, choiceIds: draft.choiceIds.includes(id) ? draft.choiceIds.filter((x) => x !== id) : [...draft.choiceIds, id] });
return (
<div className="flex flex-col divide-y divide-border">
<Group title="Price">
<PriceControl
range={shop.priceRange}
min={draft.min}
max={draft.max}
onCommit={(min, max) => onChange({ ...draft, min, max })}
immediate={immediate}
/>
</Group>
<Group title="Availability">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={draft.inStockOnly} onChange={(e) => onChange({ ...draft, inStockOnly: e.target.checked })} />
In stock only
</label>
</Group>
{shop.facets.map((facet) => (
<Group key={facet.id} title={facet.name} count={facet.choices.filter((c) => draft.choiceIds.includes(c.id)).length}>
<div className="flex flex-wrap gap-1.5">
{facet.choices.map((c) => {
const on = draft.choiceIds.includes(c.id);
return facet.isColor && c.colorCode ? (
<button key={c.id} type="button" aria-pressed={on} aria-label={c.name} title={c.name} onClick={() => toggle(c.id)}
className={`h-7 w-7 rounded-full border-2 ${on ? "border-foreground" : "border-border"}`} style={{ backgroundColor: c.colorCode }} />
) : (
<button key={c.id} type="button" aria-pressed={on} onClick={() => toggle(c.id)}
className={`rounded-control border px-3 py-1 text-sm ${on ? "border-foreground bg-foreground text-background" : "border-border bg-background text-foreground"}`}>
{c.name}
</button>
);
})}
</div>
</Group>
))}
</div>
);
}
// A collapsible group — native <details>, open by default, the selected count on the summary.
function Group({ title, count = 0, children }: { title: string; count?: number; children: ReactNode }) {
return (
<details open className="group py-3">
<summary className="flex cursor-pointer list-none items-center justify-between text-sm font-medium">
<span>{title}{count ? <span className="ml-1.5 text-xs text-muted-foreground">({count})</span> : null}</span>
<span aria-hidden="true" className="text-muted-foreground transition-transform group-open:rotate-180">⌄</span>
</summary>
<div className="pt-3">{children}</div>
</details>
);
}
// Two-handle range slider bounded by the scope's real prices, with the numeric pair under it.
// Local drafts while dragging/typing; a VALID pair commits on release / blur / Enter (immediate mode)
// or on every change (staged mode, where Apply commits later).
function PriceControl({ range, min, max, onCommit, immediate }: {
range: UseShop["priceRange"]; min: string; max: string; onCommit: (min: string, max: string) => void; immediate: boolean;
}) {
const [lo, setLo] = useState(min);
const [hi, setHi] = useState(max);
useEffect(() => { setLo(min); setHi(max); }, [min, max]);
const bounds = range ?? null;
const loN = lo === "" ? (bounds?.min ?? 0) : Number(lo);
const hiN = hi === "" ? (bounds?.max ?? 0) : Number(hi);
const step = bounds ? Math.max(1, Math.round((bounds.max - bounds.min) / 100)) : 1;
const pct = (v: number) => (bounds ? ((v - bounds.min) / (bounds.max - bounds.min)) * 100 : 0);
const publish = (a: string, b: string) => {
// at the bounds the filter is "no bound" — omit it rather than sending the catalog's own extremes
const A = bounds && a !== "" && Number(a) <= bounds.min ? "" : a;
const B = bounds && b !== "" && Number(b) >= bounds.max ? "" : b;
if (validPricePair(A, B)) onCommit(A, B);
};
const setFromSlider = (which: "lo" | "hi", v: number) => {
const a = which === "lo" ? String(Math.min(v, hiN)) : lo;
const b = which === "hi" ? String(Math.max(v, loN)) : hi;
setLo(a); setHi(b);
if (!immediate) publish(a, b);
};
const release = () => immediate && publish(lo, hi);
const thumb = "pointer-events-none absolute inset-x-0 top-0 h-6 w-full appearance-none bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-foreground [&::-webkit-slider-thumb]:bg-background [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-foreground [&::-moz-range-thumb]:bg-background";
return (
<div className="flex flex-col gap-3">
{bounds && (
<div className="relative h-6" aria-hidden={false}>
<div className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded bg-secondary" />
<div className="absolute top-1/2 h-1 -translate-y-1/2 rounded bg-foreground" style={{ left: `${pct(loN)}%`, right: `${100 - pct(hiN)}%` }} />
<input type="range" aria-label="Minimum price" min={bounds.min} max={bounds.max} step={step} value={loN}
onChange={(e) => setFromSlider("lo", Number(e.target.value))} onPointerUp={release} onKeyUp={release} className={thumb} />
<input type="range" aria-label="Maximum price" min={bounds.min} max={bounds.max} step={step} value={hiN}
onChange={(e) => setFromSlider("hi", Number(e.target.value))} onPointerUp={release} onKeyUp={release} className={thumb} />
</div>
)}
<div className="flex items-center gap-2">
<label className="sr-only" htmlFor="filter-min">Minimum price</label>
<input id="filter-min" inputMode="decimal" placeholder={bounds ? String(bounds.min) : "Min"} value={lo}
onChange={(e) => { setLo(e.target.value); if (!immediate) publish(e.target.value, hi); }}
onBlur={release} onKeyDown={(e) => e.key === "Enter" && release()}
className="w-full min-w-0 rounded-md border border-border bg-background px-2 py-1.5 text-sm" />
<span className="text-muted-foreground">–</span>
<label className="sr-only" htmlFor="filter-max">Maximum price</label>
<input id="filter-max" inputMode="decimal" placeholder={bounds ? String(bounds.max) : "Max"} value={hi}
onChange={(e) => { setHi(e.target.value); if (!immediate) publish(lo, e.target.value); }}
onBlur={release} onKeyDown={(e) => e.key === "Enter" && release()}
className="w-full min-w-0 rounded-md border border-border bg-background px-2 py-1.5 text-sm" />
</div>
</div>
);
}
SHA-256: 6d38eac6f4d87594b79e5435cd2d1cbcd1d74d7861772bddd5a8ebe233753eae