212 lines
6.7 KiB
TypeScript
212 lines
6.7 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import type { ReactElement, SelectHTMLAttributes } from 'react';
|
|
import { ChevronDown } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface SelectOption {
|
|
value: string;
|
|
label: string;
|
|
}
|
|
|
|
interface FormSelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'onChange'> {
|
|
label: string;
|
|
required?: boolean;
|
|
error?: string;
|
|
helperText?: string;
|
|
options: SelectOption[];
|
|
placeholder?: string;
|
|
onValueChange?: (value: string) => void;
|
|
}
|
|
|
|
export const FormSelect = ({
|
|
label,
|
|
required = false,
|
|
error,
|
|
helperText,
|
|
options,
|
|
placeholder = 'Select Item',
|
|
className,
|
|
id,
|
|
value,
|
|
onValueChange,
|
|
...props
|
|
}: FormSelectProps): ReactElement => {
|
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
|
const [selectedValue, setSelectedValue] = useState<string>(value as string || '');
|
|
const [dropdownStyle, setDropdownStyle] = useState<{ top?: string; bottom?: string; left: string; width: string }>({ left: '0', width: '0' });
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
const dropdownMenuRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (
|
|
dropdownRef.current &&
|
|
!dropdownRef.current.contains(event.target as Node) &&
|
|
dropdownMenuRef.current &&
|
|
!dropdownMenuRef.current.contains(event.target as Node)
|
|
) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleScroll = (event: Event) => {
|
|
// Don't close if scrolling inside the dropdown menu itself
|
|
const target = event.target as HTMLElement;
|
|
if (dropdownMenuRef.current && (dropdownMenuRef.current === target || dropdownMenuRef.current.contains(target))) {
|
|
return;
|
|
}
|
|
setIsOpen(false);
|
|
};
|
|
|
|
if (isOpen && buttonRef.current) {
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
window.addEventListener('scroll', handleScroll, true);
|
|
// Calculate position when dropdown opens
|
|
const rect = buttonRef.current.getBoundingClientRect();
|
|
const spaceBelow = window.innerHeight - rect.bottom;
|
|
const spaceAbove = rect.top;
|
|
const dropdownHeight = 240; // max-h-60 = 240px
|
|
|
|
// Determine if should open upward or downward
|
|
const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
|
|
|
|
// Calculate dropdown position
|
|
const left = rect.left;
|
|
const width = rect.width;
|
|
|
|
if (shouldOpenUp) {
|
|
// Position above the button
|
|
const bottom = window.innerHeight - rect.top;
|
|
setDropdownStyle({
|
|
bottom: `${bottom}px`,
|
|
left: `${left}px`,
|
|
width: `${width}px`,
|
|
});
|
|
} else {
|
|
// Position below the button
|
|
const top = rect.bottom;
|
|
setDropdownStyle({
|
|
top: `${top}px`,
|
|
left: `${left}px`,
|
|
width: `${width}px`,
|
|
});
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
window.removeEventListener('scroll', handleScroll, true);
|
|
};
|
|
}, [isOpen]);
|
|
|
|
useEffect(() => {
|
|
if (value !== undefined) {
|
|
setSelectedValue(value as string);
|
|
}
|
|
}, [value]);
|
|
|
|
const fieldId = id || `select-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
|
const hasError = Boolean(error);
|
|
const selectedOption = options.find((opt) => opt.value === selectedValue);
|
|
|
|
const handleSelect = (optionValue: string) => {
|
|
setSelectedValue(optionValue);
|
|
if (onValueChange) {
|
|
onValueChange(optionValue);
|
|
}
|
|
setIsOpen(false);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2 pb-4">
|
|
<label
|
|
htmlFor={fieldId}
|
|
className="flex items-center gap-1 text-[13px] font-medium text-[#0e1b2a]"
|
|
>
|
|
<span>{label}</span>
|
|
{required && <span className="text-[#e02424]">*</span>}
|
|
</label>
|
|
<div className="relative" ref={dropdownRef}>
|
|
<button
|
|
ref={buttonRef}
|
|
type="button"
|
|
id={fieldId}
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className={cn(
|
|
'h-10 w-full px-3.5 py-1 bg-white border rounded-md text-sm transition-colors',
|
|
'flex items-center justify-between',
|
|
hasError
|
|
? 'border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20'
|
|
: 'border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20',
|
|
'focus-visible:outline-none focus-visible:ring-2',
|
|
selectedValue ? 'text-[#0e1b2a]' : 'text-[#9aa6b2]',
|
|
className
|
|
)}
|
|
aria-invalid={hasError}
|
|
aria-expanded={isOpen}
|
|
aria-haspopup="listbox"
|
|
>
|
|
<span>{selectedOption ? selectedOption.label : placeholder}</span>
|
|
<ChevronDown
|
|
className={cn('w-4 h-4 transition-transform', isOpen && 'rotate-180')}
|
|
/>
|
|
</button>
|
|
|
|
{isOpen && buttonRef.current && createPortal(
|
|
<div
|
|
ref={dropdownMenuRef}
|
|
data-dropdown-menu="true"
|
|
className="fixed bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-lg z-[250] max-h-60 overflow-y-auto"
|
|
style={dropdownStyle}
|
|
>
|
|
<ul role="listbox" className="py-1.5">
|
|
{options.map((option) => (
|
|
<li key={option.value} role="option" aria-selected={selectedValue === option.value}>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSelect(option.value)}
|
|
className={cn(
|
|
'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors',
|
|
selectedValue === option.value && 'bg-gray-50'
|
|
)}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
{error && (
|
|
<p id={`${fieldId}-error`} className="text-sm text-[#ef4444]" role="alert">
|
|
{error}
|
|
</p>
|
|
)}
|
|
{helperText && !error && (
|
|
<p id={`${fieldId}-helper`} className="text-sm text-[#6b7280]">
|
|
{helperText}
|
|
</p>
|
|
)}
|
|
<select
|
|
value={selectedValue}
|
|
onChange={(e) => handleSelect(e.target.value)}
|
|
className="sr-only"
|
|
aria-hidden="true"
|
|
tabIndex={-1}
|
|
{...props}
|
|
>
|
|
<option value="">{placeholder}</option>
|
|
{options.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
};
|