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