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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user