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
+42
View File
@@ -0,0 +1,42 @@
import { useLayoutEffect, useState, type RefObject } from 'react';
import {
CARD_GRID_MAX_COLS,
type CardGridRowHeightVariant,
computeCardGridColumnCount,
computeCellWidthPx,
estimateRowHeightPx,
} from '../utils/cardGridLayout';
/**
* ResizeObserver-driven column count (max six) and virtual row height estimate
* from the measured cell width.
*/
export function useCardGridMetrics(
measureRef: RefObject<HTMLElement | null>,
observerEnabled: boolean,
variant: CardGridRowHeightVariant,
layoutSignal: number,
): { gridCols: number; rowHeightEst: number } {
const [gridCols, setGridCols] = useState(4);
const [rowHeightEst, setRowHeightEst] = useState(() =>
estimateRowHeightPx(computeCellWidthPx(960, CARD_GRID_MAX_COLS), variant),
);
useLayoutEffect(() => {
if (!observerEnabled) return;
const el = measureRef.current;
if (!el) return;
const onResize = () => {
const w = el.clientWidth;
const cols = computeCardGridColumnCount(w);
setGridCols(cols);
setRowHeightEst(estimateRowHeightPx(computeCellWidthPx(w, cols), variant));
};
onResize();
const ro = new ResizeObserver(onResize);
ro.observe(el);
return () => ro.disconnect();
}, [observerEnabled, variant, layoutSignal]);
return { gridCols, rowHeightEst };
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect } from 'react';
import type { Virtualizer } from '@tanstack/react-virtual';
/** When grid column count or row height estimate changes, TanStack can keep stale offsets until scroll — force a remeasure. */
export function useRemeasureGridVirtualizer<TScroll extends Element, TItem extends Element>(
virtualizer: Virtualizer<TScroll, TItem>,
args: {
active: boolean;
gridCols: number;
rowHeightEst: number;
virtualRowCount: number;
},
): void {
useEffect(() => {
if (!args.active || args.virtualRowCount === 0) return;
virtualizer.measure();
}, [
args.active,
args.gridCols,
args.rowHeightEst,
args.virtualRowCount,
virtualizer,
]);
}