mirror of
https://github.com/aphrody-labs/aphrody-m3-react.git
synced 2026-10-14 04:09:19 +00:00
110 lines
3.1 KiB
TypeScript
110 lines
3.1 KiB
TypeScript
// SPDX-License-Identifier: Apache-2.0
|
|
/** M3 VirtualList: a `role="list"` scroll container that renders only the visible items (TanStack Virtual). */
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
import { type CSSProperties, type Key, type ReactNode, useRef } from "react";
|
|
|
|
export interface VirtualListProps<T> {
|
|
items: readonly T[];
|
|
renderItem: (item: T, index: number) => ReactNode;
|
|
/** Scroll container height in px (the visible window). */
|
|
height: number;
|
|
/** Estimated item size in px (default 56, the M3 one-line list item); items are measured once rendered. */
|
|
estimateSize?: number;
|
|
overscan?: number;
|
|
getKey?: (item: T, index: number) => Key;
|
|
/** Gap between items in px. */
|
|
gap?: number;
|
|
/** Called when the last item becomes visible (infinite loading). */
|
|
onEndReached?: () => void;
|
|
"aria-label"?: string;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
export function VirtualList<T>(props: VirtualListProps<T>) {
|
|
const {
|
|
items,
|
|
renderItem,
|
|
height,
|
|
estimateSize = 56,
|
|
overscan = 6,
|
|
getKey,
|
|
gap = 0,
|
|
onEndReached,
|
|
className,
|
|
style,
|
|
} = props;
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const endReported = useRef(-1);
|
|
// oxlint-disable-next-line react/incompatible-library -- TanStack Virtual renvoie des fonctions non mémoïsables (getVirtualItems, measureElement) : le compilateur React doit l'ignorer
|
|
const virtualizer = useVirtualizer({
|
|
count: items.length,
|
|
getScrollElement: () => scrollRef.current,
|
|
estimateSize: () => estimateSize,
|
|
overscan,
|
|
gap,
|
|
getItemKey: getKey ? (index) => getKey(items[index]!, index) : undefined,
|
|
initialRect: { width: 0, height },
|
|
});
|
|
const virtualItems = virtualizer.getVirtualItems();
|
|
const last = virtualItems.at(-1);
|
|
if (
|
|
onEndReached &&
|
|
last &&
|
|
last.index === items.length - 1 &&
|
|
endReported.current !== items.length
|
|
) {
|
|
endReported.current = items.length;
|
|
queueMicrotask(onEndReached);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={scrollRef}
|
|
className={className}
|
|
data-m3-virtual-list=""
|
|
style={{
|
|
height,
|
|
overflow: "auto",
|
|
contain: "strict",
|
|
background: "var(--md-sys-color-surface)",
|
|
color: "var(--md-sys-color-on-surface)",
|
|
...style,
|
|
}}
|
|
>
|
|
<ul
|
|
aria-label={props["aria-label"]}
|
|
style={{
|
|
position: "relative",
|
|
width: "100%",
|
|
height: virtualizer.getTotalSize(),
|
|
margin: 0,
|
|
padding: 0,
|
|
listStyle: "none",
|
|
}}
|
|
>
|
|
{virtualItems.map((item) => (
|
|
<li
|
|
key={item.key}
|
|
aria-setsize={items.length}
|
|
aria-posinset={item.index + 1}
|
|
data-index={item.index}
|
|
ref={virtualizer.measureElement}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
width: "100%",
|
|
margin: 0,
|
|
listStyle: "none",
|
|
transform: `translateY(${item.start}px)`,
|
|
}}
|
|
>
|
|
{renderItem(items[item.index]!, item.index)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|