Files
road-monitoring-ui/src/components/async-combobox/AsyncCombobox.tsx

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>
);
}