목록 로딩 로직 개선

This commit is contained in:
Macbook
2026-06-07 16:57:30 +09:00
parent 59c548cb41
commit 7ec754770d
25 changed files with 1081 additions and 431 deletions
@@ -0,0 +1,241 @@
/**
* 가상 스크롤 목록 — 화면에 보이는 행만 DOM 에 유지 (@tanstack/react-virtual)
*/
import React, { forwardRef, useLayoutEffect, useRef, type CSSProperties, type ReactNode } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import '../../styles/virtualList.css';
export interface VirtualScrollProps<T> {
items: T[];
estimateSize: number;
overscan?: number;
className?: string;
style?: CSSProperties;
innerClassName?: string;
empty?: ReactNode;
getItemKey?: (item: T, index: number) => string | number;
/** 행 높이 가변 시 DOM 측정 */
measureDynamic?: boolean;
children: (item: T, index: number) => ReactNode;
role?: string;
'aria-label'?: string;
}
function mergeRefs<T>(...refs: Array<React.Ref<T> | undefined>) {
return (value: T | null) => {
refs.forEach(ref => {
if (!ref) return;
if (typeof ref === 'function') ref(value);
else (ref as React.MutableRefObject<T | null>).current = value;
});
};
}
function VirtualScrollInner<T>(
{
items,
estimateSize,
overscan = 6,
className = 'vl-scroll',
style,
innerClassName = 'vl-scroll-inner',
empty,
getItemKey,
measureDynamic = false,
children,
role,
'aria-label': ariaLabel,
}: VirtualScrollProps<T>,
forwardedRef: React.ForwardedRef<HTMLDivElement>,
) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => estimateSize,
overscan,
getItemKey: getItemKey
? index => String(getItemKey(items[index]!, index))
: undefined,
measureElement: measureDynamic
? el => el?.getBoundingClientRect().height ?? estimateSize
: undefined,
});
if (items.length === 0) {
return (
<div ref={forwardedRef} className={className} style={style} role={role} aria-label={ariaLabel}>
{empty}
</div>
);
}
return (
<div
ref={mergeRefs(parentRef, forwardedRef)}
className={className}
style={style}
role={role}
aria-label={ariaLabel}
>
<div
className={innerClassName}
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map(vi => (
<div
key={vi.key}
data-index={vi.index}
ref={measureDynamic ? virtualizer.measureElement : undefined}
className="vl-scroll-row"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
}}
>
{children(items[vi.index]!, vi.index)}
</div>
))}
</div>
</div>
);
}
export const VirtualScroll = forwardRef(VirtualScrollInner) as <T>(
props: VirtualScrollProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> },
) => ReturnType<typeof VirtualScrollInner>;
export interface VirtualGridScrollProps<T> {
items: T[];
columns: number;
estimateRowSize: number;
rowGap?: number;
overscan?: number;
className?: string;
style?: CSSProperties;
rowClassName?: string;
empty?: ReactNode;
getItemKey?: (item: T, index: number) => string | number;
measureDynamic?: boolean;
/** 카드 높이 변경 시 재측정 트리거 */
remeasureKey?: unknown;
children: (item: T, index: number) => ReactNode;
role?: string;
'aria-label'?: string;
}
function VirtualGridScrollInner<T>(
{
items,
columns,
estimateRowSize,
rowGap = 14,
overscan = 4,
className = 'vl-scroll',
style,
rowClassName = 'vl-grid-row',
empty,
getItemKey,
measureDynamic = false,
remeasureKey,
children,
role,
'aria-label': ariaLabel,
}: VirtualGridScrollProps<T>,
forwardedRef: React.ForwardedRef<HTMLDivElement>,
) {
const parentRef = useRef<HTMLDivElement>(null);
const rowCount = Math.ceil(items.length / Math.max(1, columns));
const rowStride = estimateRowSize + rowGap;
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize: () => rowStride,
overscan,
getItemKey: index => {
const start = index * columns;
const first = items[start];
return getItemKey && first ? String(getItemKey(first, start)) : `row-${index}`;
},
measureElement: measureDynamic
? el => {
const h = el?.getBoundingClientRect().height ?? 0;
return h > 0 ? h : rowStride;
}
: undefined,
});
useLayoutEffect(() => {
if (!measureDynamic) return;
virtualizer.measure();
}, [measureDynamic, remeasureKey, items.length, columns, rowStride, virtualizer]);
if (items.length === 0) {
return (
<div ref={forwardedRef} className={className} style={style} role={role} aria-label={ariaLabel}>
{empty}
</div>
);
}
const colCount = Math.max(1, columns);
return (
<div
ref={mergeRefs(parentRef, forwardedRef)}
className={className}
style={style}
role={role}
aria-label={ariaLabel}
>
<div
className="vl-scroll-inner"
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map(vi => {
const startIdx = vi.index * colCount;
const rowItems = items.slice(startIdx, startIdx + colCount);
return (
<div
key={vi.key}
data-index={vi.index}
ref={measureDynamic ? virtualizer.measureElement : undefined}
className={rowClassName}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
boxSizing: 'border-box',
}}
>
{rowItems.map((item, i) => (
<React.Fragment key={getItemKey ? getItemKey(item, startIdx + i) : startIdx + i}>
{children(item, startIdx + i)}
</React.Fragment>
))}
</div>
);
})}
</div>
</div>
);
}
export const VirtualGridScroll = forwardRef(VirtualGridScrollInner) as <T>(
props: VirtualGridScrollProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> },
) => ReturnType<typeof VirtualGridScrollInner>;