Tiles view: +/- control for cards per row

Adjusts the tile grid's column count as an offset from the existing
width-based auto-fill default, shared across Library, Decks, Card search,
and set card lists. Clicking the count resets to the default.
This commit is contained in:
2026-10-16 21:14:43 -05:00
parent 214a535588
commit 6072d44ebe
6 changed files with 149 additions and 4 deletions

View File

@@ -1709,6 +1709,106 @@ export function ViewToggle({
);
}
// --- Tiles per row --------------------------------------------------------
// Mirror `.tile-grid`'s `repeat(auto-fill, minmax(190px, 1fr))` and `gap` in
// styles.css — used to work out how many columns that default fills.
const TILE_MIN_WIDTH = 190;
const TILE_GAP = 16;
const TILE_MAX_COLUMNS = 12;
export interface TileColumns {
/** Columns currently shown (the width-based default plus the adjustment). */
columns: number;
/** True while the user has moved away from the width-based default. */
adjusted: boolean;
adjust: (delta: number) => void;
reset: () => void;
/** Spread onto each `.tile-grid` (`<div className="tile-grid" {...gridProps}>`). */
gridProps: { ref: (el: HTMLDivElement | null) => void; className: string; style?: React.CSSProperties };
}
/** Per-row tile count for the Tiles view, adjustable with `TileColumnsControl`.
*
* The default stays the CSS auto-fill (as many ~190px tiles as fit), so the
* persisted setting is an *offset* from that rather than a fixed count: +1
* means "one more than would fit by default" on a phone and a desktop alike,
* and it keeps tracking the width across resizes and rotation. One offset is
* shared by every tiles view. */
export function useTileColumns(): TileColumns {
const [offset, setOffset] = usePersistedState<number>("tile-columns-offset", 0);
const [auto, setAuto] = useState(0);
const observer = useRef<ResizeObserver | null>(null);
// Every grid in a view (one per group) shares the same width, so whichever
// mounted last is the one measured.
const ref = useCallback((el: HTMLDivElement | null) => {
if (!el) return;
observer.current?.disconnect();
// A grid that's been unmounted (e.g. switching to List) reports width 0;
// keep the last real measurement instead.
const measure = (w: number) => {
if (w > 0) setAuto(Math.max(1, Math.floor((w + TILE_GAP) / (TILE_MIN_WIDTH + TILE_GAP))));
};
measure(el.clientWidth);
observer.current = new ResizeObserver((entries) => measure(entries[0].contentRect.width));
observer.current.observe(el);
}, []);
useEffect(() => () => observer.current?.disconnect(), []);
const base = auto || 1;
const columns = Math.min(TILE_MAX_COLUMNS, Math.max(1, base + offset));
const adjusted = offset !== 0 && auto > 0;
return {
columns,
adjusted,
// Clamp against the visible count, so pressing − at one column doesn't
// bank an offset that has to be undone before + does anything.
adjust: (delta) => {
const next = Math.min(TILE_MAX_COLUMNS, Math.max(1, columns + delta));
setOffset(next - base);
},
reset: () => setOffset(0),
gridProps: adjusted
? {
ref,
className: columns === 1 ? "tile-grid tile-grid-single" : "tile-grid",
style: { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` },
}
: { ref, className: "tile-grid" },
};
}
/** − N + stepper for the tiles-per-row count; clicking N resets to the default. */
export function TileColumnsControl({ tiles, disabled }: { tiles: TileColumns; disabled?: boolean }) {
return (
<span className="view-toggle tile-columns" style={disabled ? { opacity: 0.5 } : undefined}>
<button
onClick={() => tiles.adjust(-1)}
disabled={disabled || tiles.columns <= 1}
title="Fewer cards per row (bigger tiles)"
>
−
</button>
<button
className="tile-columns-count"
onClick={tiles.reset}
disabled={disabled || !tiles.adjusted}
title={tiles.adjusted ? "Cards per row — click to reset to the default" : "Cards per row"}
>
{tiles.columns}
</button>
<button
onClick={() => tiles.adjust(1)}
disabled={disabled || tiles.columns >= TILE_MAX_COLUMNS}
title="More cards per row (smaller tiles)"
>
+
</button>
</span>
);
}
/**
* A single card rendered as a full image tile, with optional corner + footer.
* Clicking the image opens a larger click-to-preview modal.