Spaces:
Sleeping
Sleeping
| import React, { useState, useRef, useEffect } from 'react'; | |
| import { cn } from '@/lib/utils'; | |
| import { ChevronDown, Check } from 'lucide-react'; | |
| export interface CustomSelectOption { | |
| value: string; | |
| label: string; | |
| } | |
| interface CustomSelectProps { | |
| options: CustomSelectOption[]; | |
| value: string; | |
| onChange: (value: string) => void; | |
| placeholder?: string; | |
| className?: string; | |
| triggerClassName?: string; | |
| dropdownClassName?: string; | |
| disabled?: boolean; | |
| } | |
| export const CustomSelect: React.FC<CustomSelectProps> = ({ | |
| options, | |
| value, | |
| onChange, | |
| placeholder = "Select an option", | |
| className = "", | |
| triggerClassName = "", | |
| dropdownClassName = "", | |
| disabled = false, | |
| }) => { | |
| const [isOpen, setIsOpen] = useState(false); | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| // Get the selected option label | |
| const selectedOption = options.find(option => option.value === value); | |
| const displayValue = selectedOption ? selectedOption.label : placeholder; | |
| // Close dropdown when clicking outside | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (containerRef.current && !containerRef.current.contains(event.target as Node)) { | |
| setIsOpen(false); | |
| } | |
| }; | |
| const handleTouchStart = (event: TouchEvent) => { | |
| if (containerRef.current && !containerRef.current.contains(event.target as Node)) { | |
| setIsOpen(false); | |
| } | |
| }; | |
| if (isOpen) { | |
| document.addEventListener('mousedown', handleClickOutside); | |
| document.addEventListener('touchstart', handleTouchStart, { passive: true }); | |
| } | |
| return () => { | |
| document.removeEventListener('mousedown', handleClickOutside); | |
| document.removeEventListener('touchstart', handleTouchStart); | |
| }; | |
| }, [isOpen]); | |
| // Handle keyboard navigation | |
| useEffect(() => { | |
| const handleKeyDown = (e: KeyboardEvent) => { | |
| if (!isOpen) return; | |
| if (e.key === 'Escape') { | |
| setIsOpen(false); | |
| } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { | |
| e.preventDefault(); | |
| const currentIndex = options.findIndex(option => option.value === value); | |
| let newIndex = currentIndex; | |
| if (e.key === 'ArrowDown') { | |
| newIndex = currentIndex < options.length - 1 ? currentIndex + 1 : 0; | |
| } else { | |
| newIndex = currentIndex > 0 ? currentIndex - 1 : options.length - 1; | |
| } | |
| onChange(options[newIndex].value); | |
| } | |
| }; | |
| if (isOpen) { | |
| window.addEventListener('keydown', handleKeyDown); | |
| } | |
| return () => { | |
| window.removeEventListener('keydown', handleKeyDown); | |
| }; | |
| }, [isOpen, options, value, onChange]); | |
| // 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>) => { | |
| e.stopPropagation(); | |
| if (!disabled) { | |
| setIsOpen(!isOpen); | |
| } | |
| }; | |
| // Select an option | |
| const handleSelect = (optionValue: string, e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| onChange(optionValue); | |
| setIsOpen(false); | |
| }; | |
| return ( | |
| <div | |
| ref={containerRef} | |
| className={cn( | |
| "relative inline-block w-full", | |
| className | |
| )} | |
| > | |
| {/* Trigger button */} | |
| <div | |
| className={cn( | |
| "flex items-center justify-between w-full px-3 py-2 text-sm border rounded-md cursor-pointer select-none", | |
| "bg-background border-input", | |
| disabled ? "opacity-50 cursor-not-allowed" : "hover:bg-accent/10", | |
| triggerClassName | |
| )} | |
| onClick={handleToggle} | |
| onTouchEnd={(e) => { | |
| e.stopPropagation(); | |
| if (!disabled) { | |
| setIsOpen(!isOpen); | |
| } | |
| }} | |
| aria-haspopup="listbox" | |
| aria-expanded={isOpen} | |
| role="combobox" | |
| tabIndex={0} | |
| > | |
| <span className="truncate">{displayValue}</span> | |
| <ChevronDown className={cn("h-4 w-4 transition-transform duration-200", 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-md max-h-60 overflow-auto", | |
| "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)', | |
| }} | |
| > | |
| <div className="py-1"> | |
| {options.map((option) => ( | |
| <div | |
| key={option.value} | |
| className={cn( | |
| "flex items-center px-3 py-1.5 text-sm cursor-pointer select-none", | |
| 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} | |
| tabIndex={-1} | |
| onClick={(e) => handleSelect(option.value, e)} | |
| onTouchEnd={(e) => { | |
| e.stopPropagation(); | |
| 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> | |
| )} | |
| </div> | |
| ); | |
| }; |