Files
road-monitoring-ui/src/components/async-select/VirtualSelectList.tsx

138 lines
3.7 KiB
TypeScript

'use client';
import {
useCallback,
useEffect,
useImperativeHandle,
useRef,
type RefObject,
} from 'react';
import { Combobox } from '@base-ui/react/combobox';
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
import { Loader2 } from 'lucide-react';
import { ComboboxItem } from '@/components/ui/combobox';
import { cn } from '@/lib/utils';
import type { AsyncSelectOption } from './AsyncSelect.types';
type VirtualizerHandle = Virtualizer<HTMLDivElement, Element>;
const LOAD_MORE_THRESHOLD = 3;
interface VirtualSelectListProps {
open: boolean;
virtualizerRef: RefObject<VirtualizerHandle | null>;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onLoadMore: () => void;
estimateSize?: number;
}
export function VirtualSelectList({
open,
virtualizerRef,
hasNextPage,
isFetchingNextPage,
onLoadMore,
estimateSize = 36,
}: VirtualSelectListProps) {
const filteredItems = Combobox.useFilteredItems<AsyncSelectOption>();
const scrollElementRef = useRef<HTMLDivElement | null>(null);
const itemCount = filteredItems.length + (hasNextPage ? 1 : 0);
const virtualizer = useVirtualizer({
enabled: open,
count: itemCount,
getScrollElement: () => scrollElementRef.current,
estimateSize: () => estimateSize,
overscan: 8,
paddingStart: 4,
paddingEnd: 4,
});
useImperativeHandle(virtualizerRef, () => virtualizer, [virtualizer]);
const handleScrollElementRef = useCallback(
(element: HTMLDivElement | null) => {
scrollElementRef.current = element;
if (element) {
virtualizer.measure();
}
},
[virtualizer],
);
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const lastVirtualIndex = virtualItems[virtualItems.length - 1]?.index ?? -1;
useEffect(() => {
if (lastVirtualIndex < 0) {
return;
}
if (
lastVirtualIndex >= filteredItems.length - LOAD_MORE_THRESHOLD &&
hasNextPage &&
!isFetchingNextPage
) {
onLoadMore();
}
}, [
filteredItems.length,
hasNextPage,
isFetchingNextPage,
lastVirtualIndex,
onLoadMore,
]);
if (!filteredItems.length && !hasNextPage) {
return null;
}
return (
<div
ref={handleScrollElementRef}
className={cn(
'max-h-[min(18rem,calc(var(--available-height)-2.25rem))] overflow-y-auto overscroll-contain p-1',
)}
style={{ height: Math.min(totalSize + 8, 200) }}
>
<div className="relative w-full" style={{ height: totalSize }}>
{virtualItems.map((virtualItem) => {
const isLoaderRow = virtualItem.index >= filteredItems.length;
const item = filteredItems[virtualItem.index];
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{isLoaderRow ? (
<div className="flex items-center justify-center py-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
</div>
) : (
<ComboboxItem
value={item}
index={virtualItem.index}
aria-setsize={filteredItems.length}
aria-posinset={virtualItem.index + 1}
>
{item.label}
</ComboboxItem>
)}
</div>
);
})}
</div>
</div>
);
}