Spaces:
Running
Running
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> | |
); | |
} | |