목록 로딩 로직 개선
This commit is contained in:
@@ -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>;
|
||||
Reference in New Issue
Block a user