← back to Stayclaim

src/components/ListingGrid.tsx

122 lines

'use client';

import { useEffect, useState, type ReactNode } from 'react';

const STORAGE_KEY = 'wlt_grid_cols';
const EVENT = 'wlt-grid-cols-change';
const MIN_COLS = 3;
const MAX_COLS = 12;
const DEFAULT_COLS = 3;

/**
 * Shared col count across all grids on a page. Persists to localStorage; a
 * custom DOM event keeps multiple GridSliders / ListingGrids in sync without
 * needing a context provider — pages can drop them in arbitrarily.
 */
function useSharedCols(): [number, (n: number) => void] {
  const [cols, setColsState] = useState<number>(DEFAULT_COLS);

  useEffect(() => {
    if (typeof window === 'undefined') return;
    const saved = window.localStorage.getItem(STORAGE_KEY);
    if (saved) {
      const n = parseInt(saved, 10);
      if (n >= MIN_COLS && n <= MAX_COLS) setColsState(n);
    }
    const handler = (e: Event) => {
      const n = (e as CustomEvent<number>).detail;
      if (n >= MIN_COLS && n <= MAX_COLS) setColsState(n);
    };
    window.addEventListener(EVENT, handler);
    return () => window.removeEventListener(EVENT, handler);
  }, []);

  const setCols = (n: number) => {
    const clamped = Math.max(MIN_COLS, Math.min(MAX_COLS, n));
    setColsState(clamped);
    if (typeof window !== 'undefined') {
      window.localStorage.setItem(STORAGE_KEY, String(clamped));
      window.dispatchEvent(new CustomEvent<number>(EVENT, { detail: clamped }));
    }
  };

  return [cols, setCols];
}

/** Render once near the top of a page; broadcasts to every ListingGrid below. */
export function GridSlider({ label = 'Grid density', className = '' }: { label?: string; className?: string }) {
  const [cols, setCols] = useSharedCols();
  return (
    <div className={`flex items-center gap-3 ${className}`}>
      <label
        htmlFor="grid-cols-slider"
        className="text-[10px] uppercase tracking-[0.15em] text-ink/50 whitespace-nowrap"
      >
        {label}
      </label>
      <input
        id="grid-cols-slider"
        type="range"
        min={MIN_COLS}
        max={MAX_COLS}
        step={1}
        value={cols}
        onChange={(e) => setCols(parseInt(e.target.value, 10))}
        className="flex-1 max-w-xs accent-moss h-1 cursor-pointer"
        aria-label={`Grid columns (${cols})`}
      />
      <span className="font-mono text-xs text-ink/70 tabular-nums w-12 text-right">
        {cols} {cols === 1 ? 'col' : 'cols'}
      </span>
      <div className="hidden sm:flex gap-1 ml-2">
        {[3, 4, 6, 8, 12].map((preset) => (
          <button
            key={preset}
            type="button"
            onClick={() => setCols(preset)}
            className={`text-[10px] uppercase tracking-wider px-2 py-1 border transition ${
              cols === preset
                ? 'border-moss bg-moss text-sand'
                : 'border-ink/20 text-ink/60 hover:border-ink/50'
            }`}
          >
            {preset}
          </button>
        ))}
      </div>
    </div>
  );
}

/** Wrap a list of cards. Reads the shared col count and applies it via CSS. */
export function ListingGrid({
  children,
  className = '',
  as: Tag = 'div',
}: {
  children: ReactNode;
  className?: string;
  as?: 'div' | 'ul';
}) {
  const [cols] = useSharedCols();
  // Mobile / tablet caps prevent absurd narrow cards on phones.
  const tabletCap = Math.min(cols, 2);
  const desktopCap = Math.min(cols, 4);
  const fullCap = cols;
  return (
    <>
      <style>{`
        .wlt-grid-${cols} {
          display: grid;
          gap: 1rem;
          grid-template-columns: repeat(1, minmax(0, 1fr));
        }
        @media (min-width: 640px) { .wlt-grid-${cols} { grid-template-columns: repeat(${tabletCap}, minmax(0, 1fr)); } }
        @media (min-width: 1024px) { .wlt-grid-${cols} { grid-template-columns: repeat(${desktopCap}, minmax(0, 1fr)); } }
        @media (min-width: 1280px) { .wlt-grid-${cols} { grid-template-columns: repeat(${fullCap}, minmax(0, 1fr)); } }
      `}</style>
      <Tag className={`wlt-grid-${cols} ${className}`}>{children}</Tag>
    </>
  );
}