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.

View File

@@ -2304,6 +2304,11 @@ tbody tr:hover {
.tile-rotated {
grid-column: span 2;
}
/* …except when the per-row control is down to one column, where a span of 2
would add an implicit second column and overflow. */
.tile-grid-single .tile-rotated {
grid-column: auto;
}
.tile-img-wrap {
position: relative;
}
@@ -2447,6 +2452,11 @@ tbody tr:hover {
gap: 4px;
align-items: center;
}
/* Inputs keep an intrinsic ~160px width that would overflow tiles narrowed
by the per-row control; let them shrink with the tile instead. */
.tile-footer input {
min-width: 0;
}
/* ============================================================
Toolbar controls: group-by + view toggle
@@ -2491,6 +2501,25 @@ tbody tr:hover {
background: var(--accent);
color: #fff;
}
.view-toggle button:disabled {
background: transparent;
opacity: 0.4;
cursor: default;
}
/* Tiles-per-row stepper (see `TileColumnsControl`): same segmented look as
the List/Tiles toggle, with a fixed-width count between − and +. */
.tile-columns button {
padding: 5px 9px;
font-variant-numeric: tabular-nums;
}
.tile-columns .tile-columns-count {
min-width: 2.2em;
padding-left: 4px;
padding-right: 4px;
}
.tile-columns .tile-columns-count:disabled {
opacity: 1;
}
/* ============================================================
Deck metadata (format + commander)

View File

@@ -14,6 +14,7 @@ import {
SortDir,
SortMode,
TagInput,
TileColumnsControl,
ViewToggle,
CardCriteriaFields,
applyCardCriteria,
@@ -22,6 +23,7 @@ import {
sortCards,
useCardCriteria,
usePersistedState,
useTileColumns,
useViewMode,
} from "../components";
@@ -76,6 +78,7 @@ export function CardSearch({
const selectAllRef = useRef<HTMLInputElement>(null);
const [view, setView] = useViewMode("cardsearch-view");
const tiles = useTileColumns();
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("cardsearch-group", "none");
const [sortBy, setSortBy] = usePersistedState<SortMode>("cardsearch-sort", "name");
const [sortDir, setSortDir] = usePersistedState<SortDir>("cardsearch-sort-dir", "asc");
@@ -335,7 +338,7 @@ export function CardSearch({
function refTiles(cards: LibraryCard[]) {
return (
<div className="tile-grid">
<div {...tiles.gridProps}>
{cards.map((c) => (
<CardTile
key={c.oracle_id}
@@ -572,6 +575,7 @@ export function CardSearch({
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
/>
<ViewToggle mode={view} onChange={setView} />
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
<label
className="row"
style={{ gap: 4, cursor: view === "tiles" ? "pointer" : "default", opacity: view === "tiles" ? 1 : 0.5 }}

View File

@@ -18,11 +18,13 @@ import {
QtyStepper,
SampleHandModal,
StatTile,
TileColumnsControl,
ViewToggle,
groupCards,
useClickPreview,
useHoverPreview,
usePersistedState,
useTileColumns,
useViewMode,
} from "../components";
import { SuggestionsModal } from "../SuggestionsModal";
@@ -363,6 +365,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
const [newName, setNewName] = useState("");
const [contents, setContents] = useState<DeckEntry[]>([]);
const [view, setView] = useViewMode("deck-view");
const tiles = useTileColumns();
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
const [filterQuery, setFilterQuery] = useState("");
@@ -754,7 +757,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
function deckTiles(entries: DeckEntry[]) {
return (
<div className="tile-grid">
<div {...tiles.gridProps}>
{entries.map((e) => (
<CardTile
key={e.oracle_id}
@@ -1120,6 +1123,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
<span className="spacer" />
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<ViewToggle mode={view} onChange={setView} />
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
</div>
{selectedCards.size > 0 && (

View File

@@ -24,6 +24,7 @@ import {
SortDir,
SortMode,
TagInput,
TileColumnsControl,
ViewToggle,
CardCriteriaFields,
applyCardCriteria,
@@ -31,6 +32,7 @@ import {
sortCards,
useCardCriteria,
usePersistedState,
useTileColumns,
useViewMode,
} from "../components";
@@ -78,6 +80,7 @@ export function LibraryBrowser({
const [decks, setDecks] = useState<Deck[]>([]);
const [rows, setRows] = useState<LibraryCard[]>([]);
const [view, setView] = useViewMode("library-view");
const tiles = useTileColumns();
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("library-group", "none");
const [sortBy, setSortBy] = usePersistedState<SortMode>("library-sort", "name");
const [sortDir, setSortDir] = usePersistedState<SortDir>("library-sort-dir", "asc");
@@ -405,7 +408,7 @@ export function LibraryBrowser({
function libTiles(cards: LibraryCard[]) {
return (
<div className="tile-grid">
<div {...tiles.gridProps}>
{cards.map((c) => (
<CardTile
key={c.oracle_id}
@@ -693,6 +696,7 @@ export function LibraryBrowser({
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
/>
<ViewToggle mode={view} onChange={setView} />
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
</div>
</div>

View File

@@ -6,8 +6,10 @@ import {
CardTile,
ColorToggles,
QtyStepper,
TileColumnsControl,
applyCardCriteria,
useCardCriteria,
useTileColumns,
} from "../components";
import { useBackButton } from "../backButton";
@@ -75,6 +77,7 @@ export function NewsView() {
// doing nothing. Null while we work out which.
const [source, setSource] = useState<"local" | "live" | null>(null);
const [cardsError, setCardsError] = useState<string | null>(null);
const tiles = useTileColumns();
// The same criteria the Library and Card Search panels use — the set browser
// runs through search_reference rather than its own path, so every filter
// works here identically instead of being reimplemented.
@@ -197,6 +200,7 @@ export function NewsView() {
{cards && <span className="badge">{cards.length}</span>}
</h2>
<div className="page-header-actions">
<TileColumnsControl tiles={tiles} />
<button onClick={() => setOpenSet(null)}>← Back to news</button>
</div>
</div>
@@ -253,7 +257,7 @@ export function NewsView() {
: "No cards listed for this set yet."}
</p>
) : (
<div className="tile-grid" style={{ marginTop: "var(--space-4)" }}>
<div {...tiles.gridProps} style={{ ...tiles.gridProps.style, marginTop: "var(--space-4)" }}>
{cards.map((c) => (
<CardTile
key={c.oracle_id}