/** * Dropdown Select Component * Custom dropdown without search functionality * Uses Portal + Fixed positioning to avoid Modal overflow issues */ import React, { useState, useRef, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { ChevronDown, X } from 'lucide-react'; export interface DropdownSelectOption { value: string; label: string; disabled?: boolean; } interface DropdownSelectProps { value: string; onChange: (value: string) => void; options: DropdownSelectOption[]; placeholder?: string; required?: boolean; disabled?: boolean; className?: string; zIndex?: number; // Custom z-index for the dropdown } export const DropdownSelect: React.FC = ({ value, onChange, options, placeholder = 'Select...', required = false, disabled = false, className = '', zIndex = 110, // Default z-index for dropdown }) => { const [isOpen, setIsOpen] = useState(false); const [highlightedIndex, setHighlightedIndex] = useState(0); const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); const containerRef = useRef(null); const listRef = useRef(null); const dropdownRef = useRef(null); // Get enabled options const enabledOptions = options.filter(opt => !opt.disabled); // Close dropdown on scroll (prevents it from covering modal header) useEffect(() => { if (isOpen && containerRef.current) { const updatePosition = () => { const rect = containerRef.current!.getBoundingClientRect(); // Always position below the trigger setDropdownPosition({ top: rect.bottom + 4, left: rect.left, width: rect.width, }); }; updatePosition(); // Close dropdown on scroll (prevents it from covering modal header) const handleScroll = (e: Event) => { // Only close if scroll is not from the dropdown itself if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { setIsOpen(false); } }; // Update position on resize, close on scroll window.addEventListener('scroll', handleScroll, true); window.addEventListener('resize', updatePosition); return () => { window.removeEventListener('scroll', handleScroll, true); window.removeEventListener('resize', updatePosition); }; } }, [isOpen]); // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(event.target as Node) && dropdownRef.current && !dropdownRef.current.contains(event.target as Node) ) { setIsOpen(false); } }; if (isOpen) { document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); } }, [isOpen]); // Handle keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { if (disabled) return; if (!isOpen) { if (e.key === 'Enter' || e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === ' ') { setIsOpen(true); e.preventDefault(); } return; } switch (e.key) { case 'ArrowDown': e.preventDefault(); setHighlightedIndex((prev) => prev < enabledOptions.length - 1 ? prev + 1 : prev ); break; case 'ArrowUp': e.preventDefault(); setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0)); break; case 'Enter': case ' ': e.preventDefault(); if (enabledOptions[highlightedIndex]) { handleSelect(enabledOptions[highlightedIndex].value); } break; case 'Escape': setIsOpen(false); break; } }; // Scroll highlighted item into view useEffect(() => { if (isOpen && listRef.current) { const highlightedElement = listRef.current.children[highlightedIndex] as HTMLElement; if (highlightedElement) { highlightedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } } }, [highlightedIndex, isOpen]); const handleSelect = (optionValue: string) => { onChange(optionValue); setIsOpen(false); }; const handleClear = (e: React.MouseEvent) => { e.stopPropagation(); onChange(''); }; const handleToggle = () => { if (!disabled) { setIsOpen(!isOpen); } }; // Get display label const selectedOption = options.find(opt => opt.value === value); const displayText = selectedOption?.label || ''; // Render dropdown in portal const renderDropdown = () => { if (!isOpen) return null; return createPortal(
{/* Options List */}
    {options.length === 0 ? (
  • No options available
  • ) : ( options.map((option) => { const enabledIndex = enabledOptions.findIndex(opt => opt.value === option.value); const isHighlighted = enabledIndex === highlightedIndex && !option.disabled; return (
  • !option.disabled && handleSelect(option.value)} className={`px-4 py-3 text-sm transition border-b border-slate-100 last:border-0 ${ option.disabled ? 'text-slate-400 cursor-not-allowed bg-slate-50' : isHighlighted ? 'bg-blue-600 text-white cursor-pointer' : value === option.value ? 'bg-blue-50 text-blue-700 cursor-pointer' : 'text-slate-700 hover:bg-slate-50 cursor-pointer' }`} role="option" aria-selected={value === option.value} aria-disabled={option.disabled} >
    {option.label}
  • ); }) )}
, document.body ); }; return (
{/* Trigger Button */}
{displayText || placeholder}
{value && !required && !disabled && ( )}
{/* Required indicator (hidden input for form validation) */} {required && ( {}} required className="absolute opacity-0 pointer-events-none" tabIndex={-1} /> )} {/* Dropdown (rendered via portal) */} {renderDropdown()}
); };