157 lines
4.2 KiB
TypeScript
157 lines
4.2 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useRef, useState } from 'react';
|
|
import { Loader2 } from 'lucide-react';
|
|
import type { Virtualizer } from '@tanstack/react-virtual';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Combobox,
|
|
ComboboxContent,
|
|
ComboboxEmpty,
|
|
ComboboxInput,
|
|
ComboboxList,
|
|
ComboboxTrigger,
|
|
ComboboxValue,
|
|
} from '@/components/ui/combobox';
|
|
|
|
import type { AsyncComboboxOption, AsyncComboboxProps } from './AsyncCombobox.types';
|
|
import { VirtualComboboxList } from './VirtualComboboxList';
|
|
|
|
type VirtualizerHandle = Virtualizer<HTMLDivElement, Element>;
|
|
|
|
export function AsyncCombobox({
|
|
lookup,
|
|
onValueChange,
|
|
placeholder = 'Select option',
|
|
searchPlaceholder = 'Search...',
|
|
emptyMessage = 'No items found.',
|
|
disabled = false,
|
|
portalContainer,
|
|
}: AsyncComboboxProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const virtualizerRef = useRef<VirtualizerHandle | null>(null);
|
|
const {
|
|
options,
|
|
selectedOption,
|
|
search,
|
|
setSearch,
|
|
isLoading,
|
|
isFetchingNextPage,
|
|
isError,
|
|
errorMessage,
|
|
hasNextPage,
|
|
fetchNextPage,
|
|
} = lookup;
|
|
|
|
const handleOpenChange = useCallback(
|
|
(nextOpen: boolean) => {
|
|
setOpen(nextOpen);
|
|
if (!nextOpen) {
|
|
setSearch('');
|
|
}
|
|
},
|
|
[setSearch],
|
|
);
|
|
|
|
const handleValueChange = useCallback(
|
|
(item: AsyncComboboxOption | null) => {
|
|
if (item?.value) {
|
|
onValueChange(item.value);
|
|
}
|
|
},
|
|
[onValueChange],
|
|
);
|
|
|
|
const handleItemHighlighted = useCallback(
|
|
(
|
|
_item: AsyncComboboxOption | undefined,
|
|
event: { reason: string; index: number },
|
|
) => {
|
|
const virtualizer = virtualizerRef.current;
|
|
if (!virtualizer) {
|
|
return;
|
|
}
|
|
|
|
const { reason, index } = event;
|
|
const isStart = index === 0;
|
|
const isEnd = index === virtualizer.options.count - 1;
|
|
const shouldScroll =
|
|
reason === 'none' || (reason === 'keyboard' && (isStart || isEnd));
|
|
|
|
if (shouldScroll) {
|
|
queueMicrotask(() => {
|
|
virtualizer.scrollToIndex(index, { align: isEnd ? 'start' : 'end' });
|
|
});
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleLoadMore = useCallback(() => {
|
|
void fetchNextPage();
|
|
}, [fetchNextPage]);
|
|
|
|
const isDisabled = disabled || isLoading;
|
|
const triggerPlaceholder = isLoading ? 'Loading...' : placeholder;
|
|
|
|
return (
|
|
<Combobox
|
|
virtualized
|
|
items={options}
|
|
value={selectedOption}
|
|
onValueChange={handleValueChange}
|
|
open={open}
|
|
onOpenChange={handleOpenChange}
|
|
inputValue={search}
|
|
onInputValueChange={setSearch}
|
|
filter={null}
|
|
disabled={isDisabled}
|
|
itemToStringLabel={(item) => item.label}
|
|
isItemEqualToValue={(item, currentValue) => item.value === currentValue.value}
|
|
onItemHighlighted={handleItemHighlighted}
|
|
>
|
|
<ComboboxTrigger
|
|
render={
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full justify-between font-normal"
|
|
disabled={isDisabled}
|
|
>
|
|
{isLoading && !selectedOption ? (
|
|
<span className="flex items-center gap-2 text-muted-foreground">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
Loading...
|
|
</span>
|
|
) : (
|
|
<ComboboxValue placeholder={triggerPlaceholder} />
|
|
)}
|
|
</Button>
|
|
}
|
|
/>
|
|
<ComboboxContent container={portalContainer}>
|
|
<ComboboxInput showTrigger={false} placeholder={searchPlaceholder} disabled={isDisabled} />
|
|
{isError ? (
|
|
<div className="px-3 py-2 text-sm text-destructive">
|
|
{errorMessage || 'Unable to load options.'}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
|
|
<ComboboxList className="p-0">
|
|
<VirtualComboboxList
|
|
open={open}
|
|
virtualizerRef={virtualizerRef}
|
|
hasNextPage={hasNextPage}
|
|
isFetchingNextPage={isFetchingNextPage}
|
|
onLoadMore={handleLoadMore}
|
|
/>
|
|
</ComboboxList>
|
|
</>
|
|
)}
|
|
</ComboboxContent>
|
|
</Combobox>
|
|
);
|
|
}
|