138 lines
3.7 KiB
TypeScript
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>
|
|
);
|
|
}
|