Spaces:
Sleeping
Sleeping
| 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<number | null>(null); | |
| const [width, setWidth] = useState<number | null>(null); | |
| const selectedOption = options.find(option => option.value === value); | |
| const headingId = useId(); | |
| const searchRef = useRef<HTMLInputElement>(null); | |
| const optionsContainerRef = useRef<HTMLDivElement>(null); | |
| const selectedOptionRef = useRef<HTMLDivElement>(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 ( | |
| <div className={cn("relative w-full", containerClassName)}> | |
| <Button | |
| ref={refs.setReference} | |
| variant="outline" | |
| role="combobox" | |
| aria-expanded={open} | |
| aria-controls={open ? headingId : undefined} | |
| className={cn("w-full justify-between", className)} | |
| disabled={disabled} | |
| type="button" | |
| {...getReferenceProps()} | |
| > | |
| <span className="truncate mr-2">{selectedOption ? selectedOption.label : placeholder}</span> | |
| <ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50 flex-shrink-0" /> | |
| </Button> | |
| {open && ( | |
| <FloatingPortal> | |
| <FloatingFocusManager context={context} modal={false}> | |
| <div | |
| ref={refs.setFloating} | |
| style={{ | |
| ...floatingStyles, | |
| width: width ? `${Math.max(width, 180)}px` : 'auto', | |
| maxWidth: '300px', | |
| zIndex: 10000, | |
| }} | |
| className="floating-ui-dropdown shadow-md rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-800" | |
| aria-labelledby={headingId} | |
| {...getFloatingProps()} | |
| > | |
| <div className="p-2 border-b border-input bg-white dark:bg-zinc-900"> | |
| <input | |
| ref={searchRef} | |
| type="text" | |
| value={searchValue} | |
| onChange={(e) => { | |
| 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..." | |
| /> | |
| </div> | |
| <div | |
| ref={optionsContainerRef} | |
| className="max-h-64 overflow-auto p-1 bg-white dark:bg-zinc-900" | |
| > | |
| {filteredOptions.length === 0 ? ( | |
| <div className="p-2 text-sm text-muted-foreground">{emptyMessage}</div> | |
| ) : ( | |
| filteredOptions.map((option, index) => ( | |
| <div | |
| key={option.value} | |
| ref={option.value === value ? selectedOptionRef : null} | |
| onClick={() => 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", | |
| )} | |
| > | |
| <Check | |
| className={cn( | |
| "h-4 w-4 flex-shrink-0", | |
| value === option.value ? "opacity-100" : "opacity-0" | |
| )} | |
| /> | |
| <span className="truncate">{option.label}</span> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| </FloatingFocusManager> | |
| </FloatingPortal> | |
| )} | |
| </div> | |
| ); | |
| } |