import React, { useState, useRef, useEffect } from 'react'; import { Check, ChevronsUpDown } from 'lucide-react'; import { useFloating, autoUpdate, offset, flip, shift, useClick, useDismiss, useRole, useInteractions, FloatingFocusManager, FloatingPortal, useId, Placement } from '@floating-ui/react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; export interface DropdownOption { label: string; value: string; } interface DropdownProps { options: DropdownOption[]; value?: string; onValueChange?: (value: string) => void; placeholder?: string; emptyMessage?: string; className?: string; containerClassName?: string; disabled?: boolean; side?: 'top' | 'bottom'; } export function Dropdown({ options, value, onValueChange, placeholder = 'Select option...', emptyMessage = 'No options found.', className, containerClassName, disabled = false, side = 'bottom', }: DropdownProps) { const [open, setOpen] = useState(false); const [searchValue, setSearchValue] = useState(''); const [activeIndex, setActiveIndex] = useState(null); const [width, setWidth] = useState(null); const selectedOption = options.find(option => option.value === value); const headingId = useId(); const searchRef = useRef(null); const optionsContainerRef = useRef(null); const selectedOptionRef = useRef(null); const placement: Placement = side === 'top' ? 'top-start' : 'bottom-end'; const { refs, floatingStyles, context } = useFloating({ open, onOpenChange: setOpen, placement, middleware: [ offset(8), flip({ fallbackPlacements: ['bottom-start', 'top-start'], padding: 16 }), shift({ padding: 8 }), ], whileElementsMounted: autoUpdate, }); const click = useClick(context); const dismiss = useDismiss(context, { outsidePress: true, }); const role = useRole(context); const { getReferenceProps, getFloatingProps } = useInteractions([ click, dismiss, role, ]); // Update width when reference element changes useEffect(() => { if (refs.reference.current && 'getBoundingClientRect' in refs.reference.current) { const element = refs.reference.current as HTMLElement; setWidth(element.getBoundingClientRect().width); } }, [refs.reference.current]); useEffect(() => { if (open && searchRef.current) { searchRef.current.focus(); } }, [open]); // Scroll to selected option when dropdown opens useEffect(() => { if (open && optionsContainerRef.current && selectedOptionRef.current && value) { // Delay the scroll to ensure the dropdown is fully rendered setTimeout(() => { selectedOptionRef.current?.scrollIntoView({ block: 'center', behavior: 'auto' }); }, 50); } }, [open, value]); const filteredOptions = options.filter(option => option.label.toLowerCase().includes(searchValue.toLowerCase()) ); const handleSelect = (option: DropdownOption) => { onValueChange?.(option.value); setOpen(false); setSearchValue(''); }; return (
{open && (
{ setSearchValue(e.target.value); setActiveIndex(0); }} onKeyDown={(e) => { if (e.key === 'Enter' && activeIndex !== null && filteredOptions[activeIndex]) { handleSelect(filteredOptions[activeIndex]); } else if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex(prev => prev === null || prev === filteredOptions.length - 1 ? 0 : prev + 1 ); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex(prev => prev === null || prev === 0 ? filteredOptions.length - 1 : prev - 1 ); } else if (e.key === 'Escape') { setOpen(false); } }} className="w-full p-2 text-sm border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring bg-white dark:bg-zinc-950 text-foreground" placeholder="Search..." />
{filteredOptions.length === 0 ? (
{emptyMessage}
) : ( filteredOptions.map((option, index) => (
handleSelect(option)} onMouseEnter={() => setActiveIndex(index)} className={cn( "flex items-center gap-2 p-2 text-sm rounded-md cursor-pointer text-foreground", activeIndex === index ? "bg-accent text-accent-foreground" : "", value === option.value ? "bg-accent/50" : "hover:bg-accent/50", )} > {option.label}
)) )}
)}
); }