Files
saas_frontend/src/components/custom/CustomSearchableDropdown.tsx
T
2026-01-19 11:05:40 +05:30

248 lines
7.9 KiB
TypeScript

import React, { useState, useRef, useEffect } from "react";
import { ChevronDown, X } from "lucide-react";
interface Option {
label: string;
value: string | number;
}
interface CustomSearchableDropdownProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
label?: string;
options: Option[];
value?: string | number;
onChange?: (value: string) => void;
placeholder?: string;
leftIcon?: React.ReactNode;
error?: string;
}
const CustomSearchableDropdown = React.forwardRef<
HTMLDivElement,
CustomSearchableDropdownProps
>(
(
{
label,
options,
value,
onChange,
placeholder = "Select...",
disabled,
className = "",
required,
leftIcon,
...props
},
ref
) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(0);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Sync search term with selected value on mount or value update
useEffect(() => {
const selectedOption = options.find((opt) => String(opt.value) === String(value));
if (selectedOption) {
setSearchTerm(selectedOption.label);
} else {
setSearchTerm("");
}
}, [value, options]);
const filteredOptions = options.filter((option) =>
option.label.toLowerCase().includes(searchTerm.toLowerCase())
);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
// Revert to selected value label if closing without selection
const selectedOption = options.find((opt) => String(opt.value) === String(value));
if (selectedOption) {
setSearchTerm(selectedOption.label);
} else if (!value) {
setSearchTerm("");
}
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [value, options]);
useEffect(() => {
if (!isOpen) {
setHighlightedIndex(0);
}
}, [isOpen]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
setIsOpen(true);
if (e.target.value === "") {
onChange?.(""); // Clear selection if input is cleared
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen && e.key !== "Tab") {
setIsOpen(true);
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIndex((prev) =>
prev < filteredOptions.length - 1 ? prev + 1 : prev
);
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
break;
case "Enter":
e.preventDefault();
if (isOpen && filteredOptions[highlightedIndex]) {
handleSelect(filteredOptions[highlightedIndex]);
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
break;
case "Tab":
setIsOpen(false);
break;
}
};
const handleSelect = (option: Option) => {
onChange?.(String(option.value));
setSearchTerm(option.label);
setIsOpen(false);
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onChange?.("");
setSearchTerm("");
inputRef.current?.focus();
};
const handleFocus = () => {
if (!disabled) setIsOpen(true);
};
return (
<div className="w-full flex flex-col gap-1.5" ref={ref}>
{label && (
<label className="text-sm font-medium text-(--text-primary)">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className="relative w-full" ref={dropdownRef}>
{leftIcon && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-(--text-secondary) pointer-events-none z-[99999]">
{leftIcon}
</span>
)}
<input
{...props}
ref={inputRef}
type="text"
value={searchTerm}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
placeholder={placeholder}
disabled={disabled}
className={`
w-full rounded-lg
bg-(--background) text-(--text-primary) text-sm
border ${props.error ? 'border-red-500' : 'border-(--card-border)'}
py-3
outline-none
transition-all duration-200
hover:border-blue-500
focus:border-blue-600 focus:ring-2 focus:ring-blue-600/20
disabled:bg-(--background-secondary) disabled:text-(--text-secondary) disabled:cursor-not-allowed disabled:border-(--card-border)
placeholder-(--text-secondary)/50
${leftIcon ? "pl-10" : "px-3"}
pr-16
${className}
`}
autoComplete="off"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1">
<button
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
className="text-(--text-secondary) hover:text-(--text-primary) cursor-pointer disabled:cursor-not-allowed p-0.5"
disabled={disabled}
tabIndex={-1}
>
<ChevronDown
size={16}
className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
/>
</button>
{searchTerm && !disabled && (
<button type="button" onClick={handleClear} className="text-(--text-secondary) hover:text-(--text-primary) p-0.5">
<X size={16} />
</button>
)}
</div>
{/* Dropdown Menu */}
{isOpen && !disabled && filteredOptions.length > 0 && (
<div className="absolute z-[999] w-full mt-2 bg-(--card-bg) border border-(--card-border) rounded-lg shadow-lg max-h-60 overflow-y-auto flex flex-col">
{filteredOptions.map((option, index) => (
<button
key={option.value}
type="button"
onClick={() => handleSelect(option)}
onMouseEnter={() => setHighlightedIndex(index)}
className={`
w-full text-left px-4 py-2 text-sm
transition-colors duration-150
${String(option.value) === String(value)
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 font-medium"
: highlightedIndex === index
? "bg-(--table-row-hover) text-(--text-primary)"
: "text-(--text-primary) hover:bg-(--table-row-hover)"
}
`}
>
{option.label}
</button>
))}
</div>
)}
{isOpen && !disabled && filteredOptions.length === 0 && (
<div className="absolute z-[999] w-full mt-2 bg-(--card-bg) border border-(--card-border) rounded-lg shadow-lg p-4 text-center text-sm text-(--text-secondary)">
No results found
</div>
)}
</div>
{props.error && <p className="text-xs text-red-500 mt-1">{props.error}</p>}
</div>
);
}
);
CustomSearchableDropdown.displayName = "CustomSearchableDropdown";
export default CustomSearchableDropdown;