pmtool / src /components /asset-management /shared /CustomDropdown.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
8.21 kB
import React, { useState, useRef, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { ChevronDown, Check } from 'lucide-react';
export interface CustomDropdownOption {
value: string;
label: string;
disabled?: boolean;
}
interface CustomDropdownProps {
options: CustomDropdownOption[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
triggerClassName?: string;
dropdownClassName?: string;
disabled?: boolean;
error?: boolean;
'aria-label'?: string;
'aria-describedby'?: string;
}
export const CustomDropdown: React.FC<CustomDropdownProps> = ({
options,
value,
onChange,
placeholder = "Select an option",
className = "",
triggerClassName = "",
dropdownClassName = "",
disabled = false,
error = false,
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
}) => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = 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) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!disabled) {
setIsOpen(true);
}
}
return;
}
if (e.key === 'Escape') {
setIsOpen(false);
triggerRef.current?.focus();
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const availableOptions = options.filter(option => !option.disabled);
const currentIndex = availableOptions.findIndex(option => option.value === value);
let newIndex = currentIndex;
if (e.key === 'ArrowDown') {
newIndex = currentIndex < availableOptions.length - 1 ? currentIndex + 1 : 0;
} else {
newIndex = currentIndex > 0 ? currentIndex - 1 : availableOptions.length - 1;
}
onChange(availableOptions[newIndex].value);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
// Enter/Space on an option is handled by the option's click handler
}
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, options, value, onChange, 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);
}
};
// 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);
triggerRef.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-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-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>
)}
</div>
);
};