← 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>
  );
}