pmtool / src /components /ui /custom-select.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
6.3 kB
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>
);
};