feat(ui): shared card grid with max six columns and virtualization

Add cardGridLayout helpers, useCardGridMetrics, useRemeasureGridVirtualizer, and VirtualCardGrid (TanStack row virtualization, always remeasure on layout changes).

Apply to Artists (grid + list unchanged policy), Albums, Composers grid, playlists, radio stations, offline library, and album-heavy browse/detail pages. Respect disableMainstageVirtualLists for a non-virtual grid with the same column rules.

Includes vitest coverage for column cap.
This commit is contained in:
Maxim Isaev
2026-05-15 02:05:34 +03:00
parent 778c9e00fd
commit 7c6d3694d4
20 changed files with 601 additions and 202 deletions
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { computeCardGridColumnCount, CARD_GRID_MAX_COLS } from './cardGridLayout';
describe('computeCardGridColumnCount', () => {
it('never exceeds CARD_GRID_MAX_COLS', () => {
expect(computeCardGridColumnCount(20_000)).toBe(CARD_GRID_MAX_COLS);
});
it('returns at least one column', () => {
expect(computeCardGridColumnCount(50)).toBe(1);
});
it('uses six columns on wide desktop widths', () => {
expect(computeCardGridColumnCount(1200)).toBe(6);
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* Shared responsive card grids: capped columns, even stretch (`minmax(0, 1fr)`),
* and row-height estimates derived from measured cell width (TanStack virtual rows).
*/
export const CARD_GRID_GAP_PX = 16;
export const CARD_GRID_MIN_TILE_PX = 140;
export const CARD_GRID_MAX_COLS = 6;
export function computeCardGridColumnCount(containerWidthPx: number): number {
const raw = Math.floor(
(containerWidthPx + CARD_GRID_GAP_PX) / (CARD_GRID_MIN_TILE_PX + CARD_GRID_GAP_PX),
);
return Math.min(CARD_GRID_MAX_COLS, Math.max(1, raw));
}
export function computeCellWidthPx(containerWidthPx: number, columnCount: number): number {
const c = Math.max(1, columnCount);
return (containerWidthPx - (c - 1) * CARD_GRID_GAP_PX) / c;
}
export type CardGridRowHeightVariant = 'artist' | 'album' | 'playlist' | 'composer';
const VARIANT: Record<CardGridRowHeightVariant, { extra: number; min: number; max: number }> = {
artist: { extra: 72, min: 200, max: 520 },
/** Cover scales with cell width; ~108px headroom matches prior ~288px row at ~180px tiles. */
album: { extra: 108, min: 260, max: 560 },
playlist: { extra: 108, min: 260, max: 560 },
/** Text-only composer tiles (~78px intrinsic) with some slack for wrapping. */
composer: { extra: 56, min: 88, max: 200 },
};
export function estimateRowHeightPx(cellWidthPx: number, variant: CardGridRowHeightVariant): number {
const { extra, min, max } = VARIANT[variant];
return Math.max(min, Math.min(max, Math.ceil(cellWidthPx + extra)));
}