pmtool / src /components /ui /dropdown.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
7.62 kB
import React, { useState, useRef, useEffect } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
useClick,
useDismiss,
useRole,
useInteractions,
FloatingFocusManager,
FloatingPortal,
useId,
Placement
} from '@floating-ui/react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
export interface DropdownOption {
label: string;
value: string;
}
interface DropdownProps {
options: DropdownOption[];
value?: string;
onValueChange?: (value: string) => void;
placeholder?: string;
emptyMessage?: string;
className?: string;
containerClassName?: string;
disabled?: boolean;
side?: 'top' | 'bottom';
}
export function Dropdown({
options,
value,
onValueChange,
placeholder = 'Select option...',
emptyMessage = 'No options found.',
className,
containerClassName,
disabled = false,
side = 'bottom',
}: DropdownProps) {
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState('');
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [width, setWidth] = useState<number | null>(null);
const selectedOption = options.find(option => option.value === value);
const headingId = useId();
const searchRef = useRef<HTMLInputElement>(null);
const optionsContainerRef = useRef<HTMLDivElement>(null);
const selectedOptionRef = useRef<HTMLDivElement>(null);
const placement: Placement = side === 'top' ? 'top-start' : 'bottom-end';
const { refs, floatingStyles, context } = useFloating({
open,
onOpenChange: setOpen,
placement,
middleware: [
offset(8),
flip({
fallbackPlacements: ['bottom-start', 'top-start'],
padding: 16
}),
shift({ padding: 8 }),
],
whileElementsMounted: autoUpdate,
});
const click = useClick(context);
const dismiss = useDismiss(context, {
outsidePress: true,
});
const role = useRole(context);
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
role,
]);
// Update width when reference element changes
useEffect(() => {
if (refs.reference.current && 'getBoundingClientRect' in refs.reference.current) {
const element = refs.reference.current as HTMLElement;
setWidth(element.getBoundingClientRect().width);
}
}, [refs.reference.current]);
useEffect(() => {
if (open && searchRef.current) {
searchRef.current.focus();
}
}, [open]);
// Scroll to selected option when dropdown opens
useEffect(() => {
if (open && optionsContainerRef.current && selectedOptionRef.current && value) {
// Delay the scroll to ensure the dropdown is fully rendered
setTimeout(() => {
selectedOptionRef.current?.scrollIntoView({ block: 'center', behavior: 'auto' });
}, 50);
}
}, [open, value]);
const filteredOptions = options.filter(option =>
option.label.toLowerCase().includes(searchValue.toLowerCase())
);
const handleSelect = (option: DropdownOption) => {
onValueChange?.(option.value);
setOpen(false);
setSearchValue('');
};
return (
<div className={cn("relative w-full", containerClassName)}>
<Button
ref={refs.setReference}
variant="outline"
role="combobox"
aria-expanded={open}
aria-controls={open ? headingId : undefined}
className={cn("w-full justify-between", className)}
disabled={disabled}
type="button"
{...getReferenceProps()}
>
<span className="truncate mr-2">{selectedOption ? selectedOption.label : placeholder}</span>
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50 flex-shrink-0" />
</Button>
{open && (
<FloatingPortal>
<FloatingFocusManager context={context} modal={false}>
<div
ref={refs.setFloating}
style={{
...floatingStyles,
width: width ? `${Math.max(width, 180)}px` : 'auto',
maxWidth: '300px',
zIndex: 10000,
}}
className="floating-ui-dropdown shadow-md rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-800"
aria-labelledby={headingId}
{...getFloatingProps()}
>
<div className="p-2 border-b border-input bg-white dark:bg-zinc-900">
<input
ref={searchRef}
type="text"
value={searchValue}
onChange={(e) => {
setSearchValue(e.target.value);
setActiveIndex(0);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && activeIndex !== null && filteredOptions[activeIndex]) {
handleSelect(filteredOptions[activeIndex]);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex(prev =>
prev === null || prev === filteredOptions.length - 1 ? 0 : prev + 1
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(prev =>
prev === null || prev === 0 ? filteredOptions.length - 1 : prev - 1
);
} else if (e.key === 'Escape') {
setOpen(false);
}
}}
className="w-full p-2 text-sm border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring bg-white dark:bg-zinc-950 text-foreground"
placeholder="Search..."
/>
</div>
<div
ref={optionsContainerRef}
className="max-h-64 overflow-auto p-1 bg-white dark:bg-zinc-900"
>
{filteredOptions.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground">{emptyMessage}</div>
) : (
filteredOptions.map((option, index) => (
<div
key={option.value}
ref={option.value === value ? selectedOptionRef : null}
onClick={() => handleSelect(option)}
onMouseEnter={() => setActiveIndex(index)}
className={cn(
"flex items-center gap-2 p-2 text-sm rounded-md cursor-pointer text-foreground",
activeIndex === index ? "bg-accent text-accent-foreground" : "",
value === option.value ? "bg-accent/50" : "hover:bg-accent/50",
)}
>
<Check
className={cn(
"h-4 w-4 flex-shrink-0",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{option.label}</span>
</div>
))
)}
</div>
</div>
</FloatingFocusManager>
</FloatingPortal>
)}
</div>
);
}