wikiracing-llms / src /components /ui /virtualized-combobox.tsx
stillerman's picture
stillerman HF Staff
try this path button, ui improvements
8def8f4
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Check, ChevronsUpDown } from "lucide-react";
import * as React from "react";
type Option = {
value: string;
label: string;
};
interface VirtualizedCommandProps {
height: string;
options: Option[];
placeholder: string;
selectedOption: string;
onSelectOption?: (option: string) => void;
}
const VirtualizedCommand = ({
height,
options,
placeholder,
selectedOption,
onSelectOption,
}: VirtualizedCommandProps) => {
const [filteredOptions, setFilteredOptions] =
React.useState<Option[]>(options);
const [focusedIndex, setFocusedIndex] = React.useState(0);
const [isKeyboardNavActive, setIsKeyboardNavActive] = React.useState(false);
const parentRef = React.useRef(null);
const virtualizer = useVirtualizer({
count: filteredOptions.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
});
const virtualOptions = virtualizer.getVirtualItems();
const scrollToIndex = (index: number) => {
virtualizer.scrollToIndex(index, {
align: "center",
});
};
const handleSearch = (search: string) => {
setIsKeyboardNavActive(false);
setFilteredOptions(
options.filter((option) =>
option.value.toLowerCase().includes(search.toLowerCase() ?? [])
)
);
};
const handleKeyDown = (event: React.KeyboardEvent) => {
switch (event.key) {
case "ArrowDown": {
event.preventDefault();
setIsKeyboardNavActive(true);
setFocusedIndex((prev) => {
const newIndex =
prev === -1 ? 0 : Math.min(prev + 1, filteredOptions.length - 1);
scrollToIndex(newIndex);
return newIndex;
});
break;
}
case "ArrowUp": {
event.preventDefault();
setIsKeyboardNavActive(true);
setFocusedIndex((prev) => {
const newIndex =
prev === -1 ? filteredOptions.length - 1 : Math.max(prev - 1, 0);
scrollToIndex(newIndex);
return newIndex;
});
break;
}
case "Enter": {
event.preventDefault();
if (filteredOptions[focusedIndex]) {
onSelectOption?.(filteredOptions[focusedIndex].value);
}
break;
}
default:
break;
}
};
React.useEffect(() => {
if (selectedOption) {
const option = filteredOptions.find(
(option) => option.value === selectedOption
);
if (option) {
const index = filteredOptions.indexOf(option);
setFocusedIndex(index);
virtualizer.scrollToIndex(index, {
align: "center",
});
}
}
}, [selectedOption, filteredOptions, virtualizer]);
return (
<Command shouldFilter={false} onKeyDown={handleKeyDown}>
<CommandInput onValueChange={handleSearch} placeholder={placeholder} />
<CommandList
ref={parentRef}
style={{
height: height,
width: "100%",
overflow: "auto",
}}
onMouseDown={() => setIsKeyboardNavActive(false)}
onMouseMove={() => setIsKeyboardNavActive(false)}
>
<CommandEmpty>No item found.</CommandEmpty>
<CommandGroup>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualOptions.map((virtualOption) => (
<CommandItem
key={filteredOptions[virtualOption.index].value}
disabled={isKeyboardNavActive}
className={cn(
"absolute left-0 top-0 w-full bg-transparent",
focusedIndex === virtualOption.index &&
"bg-accent text-accent-foreground",
isKeyboardNavActive &&
focusedIndex !== virtualOption.index &&
"aria-selected:bg-transparent aria-selected:text-primary"
)}
style={{
height: `${virtualOption.size}px`,
transform: `translateY(${virtualOption.start}px)`,
}}
value={filteredOptions[virtualOption.index].value}
onMouseEnter={() =>
!isKeyboardNavActive && setFocusedIndex(virtualOption.index)
}
onMouseLeave={() => !isKeyboardNavActive && setFocusedIndex(-1)}
onSelect={onSelectOption}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedOption ===
filteredOptions[virtualOption.index].value
? "opacity-100"
: "opacity-0"
)}
/>
{filteredOptions[virtualOption.index].label}
</CommandItem>
))}
</div>
</CommandGroup>
</CommandList>
</Command>
);
};
interface VirtualizedComboboxProps {
options: string[];
searchPlaceholder?: string;
width?: string;
height?: string;
value: string;
onValueChange: (value: string) => void;
}
export function VirtualizedCombobox({
options,
searchPlaceholder = "Search items...",
value,
onValueChange,
width = "400px",
height = "400px",
}: VirtualizedComboboxProps) {
const [open, setOpen] = React.useState(false);
const [selectedOption, setSelectedOption] = React.useState("");
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="justify-between"
style={{
width: width,
}}
>
{value}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: width }}>
<VirtualizedCommand
height={height}
options={options.map((option) => ({ value: option, label: option }))}
placeholder={searchPlaceholder}
selectedOption={value}
onSelectOption={(currentValue) => {
onValueChange(currentValue);
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
}