← back to Dw Catalog Control Bar
src/CatalogControlBar.tsx
340 lines
'use client';
/**
* CatalogControlBar — Designer Wallcoverings catalog filter/sort/density bar.
*
* Winner of a 10-direction Figma exploration, ratified by vp-dw-commerce (officer-yolo):
* C1 Unified Pill-Rail chassis + C6 all-8-visible Sort menu + C10 editorial styling
* at shippable a11y sizes (>=40px targets, AA contrast, sentence-case).
*
* - Bar (horizontal) <-> Rail (vertical 240px) responsive forms off one state model.
* - Labeled "Bar | Rail" view switch (Steve's explicit horiz<->vert ask, no mystery glyph).
* - All 8 sort categories visible on open, no internal scroll (the requirement that killed C8).
* - 2-stop 4/6 density toggle drives --cols. No SHOW 100 (infinite scroll handles paging).
* - sort / density / view persist to localStorage.
*
* Self-contained: Tailwind + lucide-react only. Drop into any React 18 app.
*/
import React, { useEffect, useRef, useState } from 'react';
import { Check, ChevronDown, X } from 'lucide-react';
/* ---- tokens (editorial neutral/ink) ---- */
const T = {
paper: '#FBFAF8',
ink: '#1A1714',
ink70: '#55504A',
ink50: '#8B857D',
line: '#C9C4BC',
hover: 'rgba(26,23,20,0.06)',
bronze: '#6B5B45',
};
const SORTS = [
'Best selling', 'Newest', 'Color', 'Style',
'SKU A→Z', 'Title A→Z', 'Price ↑', 'Price ↓',
] as const;
type Sort = typeof SORTS[number];
const FACETS = ['Color', 'Style', 'Brand', 'Type', 'Price'] as const;
type Facet = typeof FACETS[number];
const FACET_OPTIONS: Record<Facet, string[]> = {
Color: ['Black', 'Blue', 'Green', 'Warm', 'Neutral'],
Style: ['Traditional', 'Modern', 'Floral', 'Damask', 'Geometric'],
Brand: ['Schumacher', 'Thibaut', 'Cole & Son', 'Arte', 'Koroseal'],
Type: ['Grasscloth', 'Vinyl', 'Silk', 'Cork', 'Mural'],
Price: ['Under $50', '$50–$120', '$120–$250', '$250+'],
};
type Density = 4 | 6;
type View = 'bar' | 'rail';
type Filters = Partial<Record<Facet, string[]>>;
/* ---- small column-bar density glyph ---- */
function ColBars({ n, active }: { n: number; active: boolean }) {
const stroke = active ? T.paper : T.ink50;
return (
<svg width={n * 3 + (n - 1) * 1.5} height="12" viewBox={`0 0 ${n * 3 + (n - 1) * 1.5} 12`} aria-hidden>
{Array.from({ length: n }).map((_, i) => (
<rect key={i} x={i * 4.5} y="0" width="2.4" height="12" rx="1" fill={stroke} />
))}
</svg>
);
}
/* ---- generic popover behavior (Escape + outside-click close) ---- */
function usePopover() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); };
}, [open]);
return { open, setOpen, ref };
}
const pillBase =
'inline-flex items-center gap-1.5 h-10 px-3.5 rounded-full text-[14px] tracking-[0.01em] ' +
'border transition-colors select-none';
/* ---- facet pill + checkbox popover ---- */
function FacetPill({
facet, selected, onChange,
}: { facet: Facet; selected: string[]; onChange: (v: string[]) => void }) {
const { open, setOpen, ref } = usePopover();
const count = selected.length;
const active = count > 0;
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(o => !o)}
className={pillBase}
style={{
background: active ? T.ink : 'transparent',
color: active ? T.paper : T.ink70,
borderColor: active ? T.ink : T.line,
}}
>
{facet}{active && <span className="text-[12px] opacity-80">({count})</span>}
<ChevronDown size={14} style={{ opacity: 0.55 }} />
</button>
{open && (
<div
className="absolute z-30 mt-2 min-w-[200px] rounded-lg p-2"
style={{ background: T.paper, border: `1px solid ${T.line}`, boxShadow: '0 8px 24px rgba(26,23,20,0.10)' }}
>
<div className="px-2.5 pb-1.5 pt-1 text-[12px] uppercase tracking-[0.06em]" style={{ color: T.ink50 }}>{facet}</div>
{FACET_OPTIONS[facet].map(opt => {
const on = selected.includes(opt);
return (
<button
key={opt}
onClick={() => onChange(on ? selected.filter(s => s !== opt) : [...selected, opt])}
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-[14px]"
style={{ color: T.ink }}
onMouseEnter={e => (e.currentTarget.style.background = T.hover)}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<span
className="flex h-[18px] w-[18px] items-center justify-center rounded"
style={{ border: `1px solid ${on ? T.ink : T.line}`, background: on ? T.ink : T.paper }}
>
{on && <Check size={12} color={T.paper} />}
</span>
{opt}
</button>
);
})}
</div>
)}
</div>
);
}
/* ---- always-labeled Sort trigger + 8-item no-scroll radio menu ---- */
function SortControl({ value, onChange }: { value: Sort; onChange: (s: Sort) => void }) {
const { open, setOpen, ref } = usePopover();
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(o => !o)}
className={pillBase}
style={{ background: 'transparent', color: T.ink, borderColor: T.line }}
>
<span style={{ color: T.ink50 }}>Sort:</span> {value}
<ChevronDown size={14} style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }} />
</button>
{open && (
<div
role="radiogroup"
aria-label="Sort by"
className="absolute right-0 z-30 mt-2 w-[230px] rounded-lg p-1.5"
style={{ background: T.paper, border: `1px solid ${T.line}`, boxShadow: '0 8px 24px rgba(26,23,20,0.10)' }}
>
<div className="px-2.5 pb-1 pt-1 text-[12px] uppercase tracking-[0.06em]" style={{ color: T.ink50 }}>Sort by</div>
{SORTS.map(s => {
const on = s === value;
return (
<button
key={s}
role="radio"
aria-checked={on}
onClick={() => { onChange(s); setOpen(false); }}
className="flex w-full items-center justify-between rounded-md px-2.5 py-2 text-left text-[15px]"
style={{ color: on ? T.ink : T.ink70, fontWeight: on ? 600 : 400 }}
onMouseEnter={e => (e.currentTarget.style.background = T.hover)}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
{s}
{on && <Check size={15} color={T.bronze} />}
</button>
);
})}
</div>
)}
</div>
);
}
/* ---- segmented control (density + view switch share this) ---- */
function Segmented<O extends string | number>({
options, value, onChange, render,
}: { options: O[]; value: O; onChange: (o: O) => void; render: (o: O, active: boolean) => React.ReactNode }) {
return (
<div className="inline-flex items-center gap-0.5 rounded-lg p-[3px]" style={{ background: T.hover, border: `1px solid ${T.line}` }}>
{options.map(o => {
const active = o === value;
return (
<button
key={String(o)}
onClick={() => onChange(o)}
className="inline-flex h-[34px] items-center gap-1.5 rounded-md px-3 text-[13px] font-semibold transition-colors"
style={{ background: active ? T.ink : 'transparent', color: active ? T.paper : T.ink50 }}
>
{render(o, active)}
</button>
);
})}
</div>
);
}
/* ===================== the bar ===================== */
export function CatalogControlBar({ count = 1250 }: { count?: number }) {
const [sort, setSort] = useState<Sort>('Best selling');
const [density, setDensity] = useState<Density>(4);
const [view, setView] = useState<View>('bar');
const [filters, setFilters] = useState<Filters>({ Color: ['Blue'], Style: ['Floral'] });
/* persist */
useEffect(() => {
try {
const s = localStorage.getItem('dw_catalog_sort');
const d = localStorage.getItem('dw_catalog_density');
const v = localStorage.getItem('dw_catalog_view');
if (s && (SORTS as readonly string[]).includes(s)) setSort(s as Sort);
if (d === '4' || d === '6') setDensity(Number(d) as Density);
if (v === 'bar' || v === 'rail') setView(v as View);
} catch {}
}, []);
useEffect(() => { try { localStorage.setItem('dw_catalog_sort', sort); } catch {} }, [sort]);
useEffect(() => { try { localStorage.setItem('dw_catalog_density', String(density)); } catch {} }, [density]);
useEffect(() => { try { localStorage.setItem('dw_catalog_view', view); } catch {} }, [view]);
const setFacet = (f: Facet, v: string[]) =>
setFilters(prev => { const next = { ...prev }; if (v.length) next[f] = v; else delete next[f]; return next; });
const chips = (Object.entries(filters) as [Facet, string[]][]).flatMap(([f, vs]) => vs.map(v => ({ f, v })));
const densityToggle = (
<Segmented<Density> options={[4, 6]} value={density} onChange={setDensity}
render={(n, a) => (<><ColBars n={n} active={a} />{n}</>)} />
);
const viewSwitch = (
<Segmented<View> options={['bar', 'rail']} value={view} onChange={setView}
render={(v) => (v === 'bar' ? 'Bar' : 'Rail')} />
);
const sortControl = <SortControl value={sort} onChange={setSort} />;
const countLabel = (
<span className="text-[12px] uppercase tracking-[0.06em] whitespace-nowrap" style={{ color: T.ink50 }}>
{count.toLocaleString()} wallcoverings
</span>
);
/* ----- RAIL (vertical) form ----- */
if (view === 'rail') {
return (
<div className="flex gap-6" style={{ background: T.paper }}>
<aside className="w-[240px] shrink-0 rounded-xl p-4" style={{ border: `1px solid ${T.line}` }}>
{FACETS.map(f => (
<details key={f} open className="border-b py-1.5 last:border-b-0" style={{ borderColor: T.line }}>
<summary className="flex cursor-pointer list-none items-center justify-between py-2 text-[14px]" style={{ color: T.ink }}>
{f}<ChevronDown size={14} style={{ color: T.ink50 }} />
</summary>
<div className="pb-2">
{FACET_OPTIONS[f].map(opt => {
const on = (filters[f] ?? []).includes(opt);
return (
<label key={opt} className="flex cursor-pointer items-center gap-2.5 py-1.5 text-[14px]" style={{ color: T.ink70 }}>
<input type="checkbox" checked={on}
onChange={() => setFacet(f, on ? (filters[f] ?? []).filter(x => x !== opt) : [...(filters[f] ?? []), opt])}
className="h-[18px] w-[18px] accent-[#1A1714]" />
{opt}
</label>
);
})}
</div>
</details>
))}
</aside>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3 pb-4">
{countLabel}
<div className="flex-1" />
{sortControl}{densityToggle}{viewSwitch}
</div>
</div>
</div>
);
}
/* ----- BAR (horizontal) form ----- */
return (
<div className="flex flex-wrap items-center gap-2.5 py-3"
style={{ background: T.paper, borderBottom: `1px solid ${T.line}` }}>
{countLabel}
<div className="flex flex-wrap items-center gap-2 overflow-x-auto">
{FACETS.map(f => (
<FacetPill key={f} facet={f} selected={filters[f] ?? []} onChange={v => setFacet(f, v)} />
))}
</div>
{chips.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{chips.map(({ f, v }) => (
<span key={f + v} className="inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-[13px]"
style={{ background: T.hover, color: T.ink }}>
{f}: {v}
<button onClick={() => setFacet(f, (filters[f] ?? []).filter(x => x !== v))} aria-label={`Remove ${f} ${v}`}>
<X size={13} />
</button>
</span>
))}
<button onClick={() => setFilters({})} className="px-1 text-[13px] underline" style={{ color: T.ink50 }}>
Clear all
</button>
</div>
)}
<div className="flex-1" />
<div className="flex items-center gap-2.5">
{sortControl}{densityToggle}{viewSwitch}
</div>
</div>
);
}
/* ===================== demo: bar over a grid that respects --cols ===================== */
export default function CatalogControlBarDemo() {
const [cols, setCols] = useState(4);
useEffect(() => {
const sync = () => { const d = localStorage.getItem('dw_catalog_density'); if (d === '4' || d === '6') setCols(Number(d)); };
const id = window.setInterval(sync, 250); // demo-only; production grid reads density from shared context/store
return () => window.clearInterval(id);
}, []);
return (
<div className="mx-auto max-w-[1280px] px-8 py-10" style={{ background: T.paper, fontFamily: '"Inter", system-ui, sans-serif' }}>
<CatalogControlBar count={1250} />
<div className="grid gap-4 pt-6"
style={{ ['--cols' as any]: cols, gridTemplateColumns: 'repeat(var(--cols), minmax(0,1fr))' }}>
{Array.from({ length: cols * 2 }).map((_, i) => (
<div key={i} className="aspect-[4/3] rounded-lg" style={{ background: 'rgba(26,23,20,0.05)' }} />
))}
</div>
</div>
);
}