pmtool / src /components /asset-management /shared /SearchableDropdown.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
9.9 kB
import React, { useState, useRef, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { ChevronDown, Check, Search, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
export interface SearchableDropdownOption {
value: string;
label: string;
disabled?: boolean;
}
interface SearchableDropdownProps {
options: SearchableDropdownOption[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
className?: string;
triggerClassName?: string;
dropdownClassName?: string;
disabled?: boolean;
error?: boolean;
'aria-label'?: string;
'aria-describedby'?: string;
emptyMessage?: string;
}
export const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
options,
value,
onChange,
placeholder = "Select an option",
searchPlaceholder = "Search...",
className = "",
triggerClassName = "",
dropdownClassName = "",
disabled = false,
error = false,
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
emptyMessage = "No options found"
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// Get the selected option label
const selectedOption = options.find(option => option.value === value);
const displayValue = selectedOption ? selectedOption.label : placeholder;
// Filter options based on search term
const filteredOptions = options.filter(option =>
option.label.toLowerCase().includes(searchTerm.toLowerCase())
);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
setSearchTerm('');
}
};
const handleTouchStart = (event: TouchEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
setSearchTerm('');
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchstart', handleTouchStart, { passive: true });
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchstart', handleTouchStart);
};
}, [isOpen]);
// Focus search input when dropdown opens
useEffect(() => {
if (isOpen && searchInputRef.current) {
setTimeout(() => {
searchInputRef.current?.focus();
}, 100);
}
}, [isOpen]);
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!disabled) {
setIsOpen(true);
}
}
return;
}
if (e.key === 'Escape') {
setIsOpen(false);
setSearchTerm('');
triggerRef.current?.focus();
}
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, disabled]);
// 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> | React.KeyboardEvent<HTMLDivElement>) => {
e.stopPropagation();
if (!disabled) {
setIsOpen(!isOpen);
if (!isOpen) {
setSearchTerm('');
}
}
};
// Select an option
const handleSelect = (optionValue: string, e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation();
e.preventDefault();
const option = options.find(opt => opt.value === optionValue);
if (option && !option.disabled) {
onChange(optionValue);
setIsOpen(false);
setSearchTerm('');
triggerRef.current?.focus();
}
};
// Clear search
const handleClearSearch = () => {
setSearchTerm('');
searchInputRef.current?.focus();
};
return (
<div
ref={containerRef}
className={cn(
"relative inline-block w-full",
className
)}
>
{/* Trigger button */}
<div
ref={triggerRef}
className={cn(
"flex items-center justify-between w-full px-3 py-2 text-sm border rounded-md cursor-pointer select-none transition-colors",
"bg-background border-input focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
disabled ? "opacity-50 cursor-not-allowed" : "hover:bg-accent/10",
error && "border-destructive focus:ring-destructive",
triggerClassName
)}
onClick={handleToggle}
onKeyDown={handleToggle}
onTouchEnd={(e) => {
e.stopPropagation();
if (!disabled) {
setIsOpen(!isOpen);
}
}}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
role="combobox"
tabIndex={disabled ? -1 : 0}
>
<span className={cn(
"truncate",
!selectedOption && "text-muted-foreground"
)}>
{displayValue}
</span>
<ChevronDown className={cn(
"h-4 w-4 transition-transform duration-200 shrink-0 ml-2",
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-lg max-h-80 overflow-hidden",
"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)',
}}
>
{/* Search input */}
<div className="p-2 border-b border-border">
<div className="relative">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
ref={searchInputRef}
type="text"
placeholder={searchPlaceholder}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8 pr-8 h-8 text-sm"
onClick={(e) => e.stopPropagation()}
/>
{searchTerm && (
<button
type="button"
onClick={handleClearSearch}
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Options list */}
<div className="max-h-60 overflow-auto">
{filteredOptions.length > 0 ? (
<div className="py-1">
{filteredOptions.map((option) => (
<div
key={option.value}
className={cn(
"flex items-center px-3 py-2 text-sm cursor-pointer select-none transition-colors",
option.disabled
? "opacity-50 cursor-not-allowed text-muted-foreground"
: 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}
aria-disabled={option.disabled}
tabIndex={-1}
onClick={(e) => !option.disabled && handleSelect(option.value, e)}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && !option.disabled) {
handleSelect(option.value, e);
}
}}
onTouchEnd={(e) => {
e.stopPropagation();
if (!option.disabled) {
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 className="px-3 py-6 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
)}
</div>
</div>
)}
</div>
);
};