Spaces:
Sleeping
Sleeping
| import React, { useState, useRef, useEffect } from 'react'; | |
| import { cn } from '@/lib/utils'; | |
| import { ChevronDown, Check, Search, X } from 'lucide-react'; | |
| import { Input } from '@/components/ui/input'; | |
| export interface SearchableDropdownOption { | |
| value: string; | |
| label: string; | |
| disabled?: boolean; | |
| } | |
| interface SearchableDropdownProps { | |
| options: SearchableDropdownOption[]; | |
| value: string; | |
| onChange: (value: string) => void; | |
| placeholder?: string; | |
| searchPlaceholder?: string; | |
| className?: string; | |
| triggerClassName?: string; | |
| dropdownClassName?: string; | |
| disabled?: boolean; | |
| error?: boolean; | |
| 'aria-label'?: string; | |
| 'aria-describedby'?: string; | |
| emptyMessage?: string; | |
| } | |
| export const SearchableDropdown: React.FC<SearchableDropdownProps> = ({ | |
| options, | |
| value, | |
| onChange, | |
| placeholder = "Select an option", | |
| searchPlaceholder = "Search...", | |
| className = "", | |
| triggerClassName = "", | |
| dropdownClassName = "", | |
| disabled = false, | |
| error = false, | |
| 'aria-label': ariaLabel, | |
| 'aria-describedby': ariaDescribedBy, | |
| emptyMessage = "No options found" | |
| }) => { | |
| const [isOpen, setIsOpen] = useState(false); | |
| const [searchTerm, setSearchTerm] = useState(''); | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| const triggerRef = useRef<HTMLDivElement>(null); | |
| const searchInputRef = useRef<HTMLInputElement>(null); | |
| // Get the selected option label | |
| const selectedOption = options.find(option => option.value === value); | |
| const displayValue = selectedOption ? selectedOption.label : placeholder; | |
| // Filter options based on search term | |
| const filteredOptions = options.filter(option => | |
| option.label.toLowerCase().includes(searchTerm.toLowerCase()) | |
| ); | |
| // Close dropdown when clicking outside | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (containerRef.current && !containerRef.current.contains(event.target as Node)) { | |
| setIsOpen(false); | |
| setSearchTerm(''); | |
| } | |
| }; | |
| const handleTouchStart = (event: TouchEvent) => { | |
| if (containerRef.current && !containerRef.current.contains(event.target as Node)) { | |
| setIsOpen(false); | |
| setSearchTerm(''); | |
| } | |
| }; | |
| if (isOpen) { | |
| document.addEventListener('mousedown', handleClickOutside); | |
| document.addEventListener('touchstart', handleTouchStart, { passive: true }); | |
| } | |
| return () => { | |
| document.removeEventListener('mousedown', handleClickOutside); | |
| document.removeEventListener('touchstart', handleTouchStart); | |
| }; | |
| }, [isOpen]); | |
| // Focus search input when dropdown opens | |
| useEffect(() => { | |
| if (isOpen && searchInputRef.current) { | |
| setTimeout(() => { | |
| searchInputRef.current?.focus(); | |
| }, 100); | |
| } | |
| }, [isOpen]); | |
| // Handle keyboard navigation | |
| useEffect(() => { | |
| const handleKeyDown = (e: KeyboardEvent) => { | |
| if (!isOpen) { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| if (!disabled) { | |
| setIsOpen(true); | |
| } | |
| } | |
| return; | |
| } | |
| if (e.key === 'Escape') { | |
| setIsOpen(false); | |
| setSearchTerm(''); | |
| triggerRef.current?.focus(); | |
| } | |
| }; | |
| if (isOpen) { | |
| window.addEventListener('keydown', handleKeyDown); | |
| } | |
| return () => { | |
| window.removeEventListener('keydown', handleKeyDown); | |
| }; | |
| }, [isOpen, disabled]); | |
| // Handle scroll locking when dropdown is open | |
| useEffect(() => { | |
| if (isOpen && dropdownRef.current) { | |
| // Scroll selected item into view | |
| const selectedItem = dropdownRef.current.querySelector('[aria-selected="true"]'); | |
| if (selectedItem) { | |
| selectedItem.scrollIntoView({ block: 'nearest' }); | |
| } | |
| } | |
| }, [isOpen, value]); | |
| // Toggle dropdown | |
| const handleToggle = (e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => { | |
| e.stopPropagation(); | |
| if (!disabled) { | |
| setIsOpen(!isOpen); | |
| if (!isOpen) { | |
| setSearchTerm(''); | |
| } | |
| } | |
| }; | |
| // Select an option | |
| const handleSelect = (optionValue: string, e: React.MouseEvent | React.KeyboardEvent) => { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| const option = options.find(opt => opt.value === optionValue); | |
| if (option && !option.disabled) { | |
| onChange(optionValue); | |
| setIsOpen(false); | |
| setSearchTerm(''); | |
| triggerRef.current?.focus(); | |
| } | |
| }; | |
| // Clear search | |
| const handleClearSearch = () => { | |
| setSearchTerm(''); | |
| searchInputRef.current?.focus(); | |
| }; | |
| return ( | |
| <div | |
| ref={containerRef} | |
| className={cn( | |
| "relative inline-block w-full", | |
| className | |
| )} | |
| > | |
| {/* Trigger button */} | |
| <div | |
| ref={triggerRef} | |
| className={cn( | |
| "flex items-center justify-between w-full px-3 py-2 text-sm border rounded-md cursor-pointer select-none transition-colors", | |
| "bg-background border-input focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", | |
| disabled ? "opacity-50 cursor-not-allowed" : "hover:bg-accent/10", | |
| error && "border-destructive focus:ring-destructive", | |
| triggerClassName | |
| )} | |
| onClick={handleToggle} | |
| onKeyDown={handleToggle} | |
| onTouchEnd={(e) => { | |
| e.stopPropagation(); | |
| if (!disabled) { | |
| setIsOpen(!isOpen); | |
| } | |
| }} | |
| aria-haspopup="listbox" | |
| aria-expanded={isOpen} | |
| aria-label={ariaLabel} | |
| aria-describedby={ariaDescribedBy} | |
| role="combobox" | |
| tabIndex={disabled ? -1 : 0} | |
| > | |
| <span className={cn( | |
| "truncate", | |
| !selectedOption && "text-muted-foreground" | |
| )}> | |
| {displayValue} | |
| </span> | |
| <ChevronDown className={cn( | |
| "h-4 w-4 transition-transform duration-200 shrink-0 ml-2", | |
| isOpen && "transform rotate-180" | |
| )} /> | |
| </div> | |
| {/* Dropdown menu */} | |
| {isOpen && ( | |
| <div | |
| ref={dropdownRef} | |
| className={cn( | |
| "absolute z-[9999] w-full mt-1 bg-popover border border-border rounded-md shadow-lg max-h-80 overflow-hidden", | |
| "animate-in fade-in-0 zoom-in-95", | |
| dropdownClassName | |
| )} | |
| role="listbox" | |
| aria-orientation="vertical" | |
| style={{ | |
| transformOrigin: 'top', | |
| boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', | |
| }} | |
| > | |
| {/* Search input */} | |
| <div className="p-2 border-b border-border"> | |
| <div className="relative"> | |
| <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" /> | |
| <Input | |
| ref={searchInputRef} | |
| type="text" | |
| placeholder={searchPlaceholder} | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| className="pl-8 pr-8 h-8 text-sm" | |
| onClick={(e) => e.stopPropagation()} | |
| /> | |
| {searchTerm && ( | |
| <button | |
| type="button" | |
| onClick={handleClearSearch} | |
| className="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground" | |
| > | |
| <X className="h-4 w-4" /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| {/* Options list */} | |
| <div className="max-h-60 overflow-auto"> | |
| {filteredOptions.length > 0 ? ( | |
| <div className="py-1"> | |
| {filteredOptions.map((option) => ( | |
| <div | |
| key={option.value} | |
| className={cn( | |
| "flex items-center px-3 py-2 text-sm cursor-pointer select-none transition-colors", | |
| option.disabled | |
| ? "opacity-50 cursor-not-allowed text-muted-foreground" | |
| : option.value === value | |
| ? "bg-primary/10 text-primary font-medium" | |
| : "text-foreground hover:bg-primary/5 hover:text-primary" | |
| )} | |
| role="option" | |
| aria-selected={option.value === value} | |
| aria-disabled={option.disabled} | |
| tabIndex={-1} | |
| onClick={(e) => !option.disabled && handleSelect(option.value, e)} | |
| onKeyDown={(e) => { | |
| if ((e.key === 'Enter' || e.key === ' ') && !option.disabled) { | |
| handleSelect(option.value, e); | |
| } | |
| }} | |
| onTouchEnd={(e) => { | |
| e.stopPropagation(); | |
| if (!option.disabled) { | |
| handleSelect(option.value, e as any); | |
| } | |
| }} | |
| > | |
| <span className="flex-grow truncate">{option.label}</span> | |
| {option.value === value && ( | |
| <Check className="h-4 w-4 ml-2 shrink-0 text-primary" /> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| ) : ( | |
| <div className="px-3 py-6 text-center text-sm text-muted-foreground"> | |
| {emptyMessage} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; |