diff --git a/src/components/reusable/Select.tsx b/src/components/reusable/Select.tsx index 23870e0..0a3f290 100644 --- a/src/components/reusable/Select.tsx +++ b/src/components/reusable/Select.tsx @@ -1,5 +1,6 @@ -import type { SelectHTMLAttributes } from 'react'; -import { ChevronDown } from 'lucide-react'; +import { useState, useRef, useEffect, useCallback, type SelectHTMLAttributes } from 'react'; +import { createPortal } from 'react-dom'; +import { ChevronDown, Search, X, Check } from 'lucide-react'; import { cn } from '../../lib/cn'; export interface SelectOption { @@ -11,43 +12,208 @@ export interface SelectProps extends SelectHTMLAttributes { label?: string; hint?: string; error?: string; + placeholder?: string; /** Either strings or {value,label} objects. */ options?: Array; /** Class for the outer label wrapper. */ className?: string; + /** Disable search header filter if set to false */ + searchable?: boolean; } -/** Labeled native select styled to match Input. */ -export function Select({ label, hint, error, options = [], required, className, ...rest }: SelectProps) { +/** Custom searchable select component using React Portal to prevent container clipping. */ +export function Select({ + label, + hint, + error, + options = [], + required, + className, + disabled, + placeholder, + searchable = true, + ...rest +}: SelectProps) { + const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [dropdownStyle, setDropdownStyle] = useState({}); + + const containerRef = useRef(null); + const dropdownRef = useRef(null); + + const normalizedOptions: SelectOption[] = options.map((o) => + typeof o === 'string' ? { value: o, label: o } : { value: String(o.value ?? ''), label: o.label } + ); + + const currentValue = String(rest.value ?? ''); + const selectedOption = normalizedOptions.find((o) => o.value === currentValue); + const selectedOptionLabel = selectedOption ? selectedOption.label : ''; + + const filteredOptions = normalizedOptions.filter((o) => + o.label.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const updatePosition = useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + const dropdownHeight = 260; // Max height approximation + const spaceBelow = window.innerHeight - rect.bottom; + const openUpwards = spaceBelow < dropdownHeight && rect.top > dropdownHeight; + + setDropdownStyle({ + position: 'fixed', + left: `${rect.left}px`, + width: `${rect.width}px`, + zIndex: 999999, + ...(openUpwards + ? { bottom: `${window.innerHeight - rect.top + 4}px` } + : { top: `${rect.bottom + 4}px` }), + }); + } + }, []); + + useEffect(() => { + if (isOpen) { + updatePosition(); + const handleScrollOrResize = () => updatePosition(); + window.addEventListener('resize', handleScrollOrResize); + window.addEventListener('scroll', handleScrollOrResize, true); + return () => { + window.removeEventListener('resize', handleScrollOrResize); + window.removeEventListener('scroll', handleScrollOrResize, true); + }; + } + }, [isOpen, updatePosition]); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node; + const isOutsideContainer = containerRef.current && !containerRef.current.contains(target); + const isOutsideDropdown = dropdownRef.current && !dropdownRef.current.contains(target); + + if (isOutsideContainer && isOutsideDropdown) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleSelect = (val: string) => { + if (rest.onChange) { + const syntheticEvent = { + target: { value: val, name: rest.name, id: rest.id }, + currentTarget: { value: val, name: rest.name, id: rest.id }, + } as unknown as React.ChangeEvent; + rest.onChange(syntheticEvent); + } + setIsOpen(false); + setSearchQuery(''); + }; + return ( -