- Updated icon imports in dashboard, policy engine, and custom components to use phosphor-icons. - Replaced icons such as Check, Plus, Trash2, and others with their phosphor equivalents. - Ensured consistent icon usage across the application for better visual coherence.
276 lines
8.6 KiB
TypeScript
276 lines
8.6 KiB
TypeScript
import React, { useState, useRef, useEffect } from "react";
|
|
import { CaretDownIcon, XIcon, CheckIcon } from "@phosphor-icons/react";
|
|
import DropdownPortal from "./DropdownPortal";
|
|
|
|
interface Option {
|
|
label: string;
|
|
value: string | number;
|
|
}
|
|
|
|
interface CustomSearchableDropdownProps
|
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size'> {
|
|
label?: string;
|
|
options: Option[];
|
|
value?: string | number;
|
|
onChange?: (value: string) => void;
|
|
placeholder?: string;
|
|
leftIcon?: React.ReactNode;
|
|
error?: string;
|
|
size?: "sm" | "md" | "lg";
|
|
}
|
|
|
|
const CustomSearchableDropdown = React.forwardRef<
|
|
HTMLDivElement,
|
|
CustomSearchableDropdownProps
|
|
>(
|
|
(
|
|
{
|
|
label,
|
|
options,
|
|
value,
|
|
onChange,
|
|
placeholder = "Select...",
|
|
disabled,
|
|
className = "",
|
|
required,
|
|
leftIcon,
|
|
size = "md",
|
|
...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);
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
|
|
const sizeClasses = {
|
|
sm: "py-1 text-sm",
|
|
md: "py-1.5 text-sm",
|
|
lg: "py-2.5 text-base",
|
|
};
|
|
|
|
// 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) => {
|
|
const target = event.target as Node;
|
|
if (
|
|
dropdownRef.current &&
|
|
!dropdownRef.current.contains(target) &&
|
|
!(panelRef.current && panelRef.current.contains(target))
|
|
) {
|
|
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-gray-900">
|
|
{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-gray-500 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-white text-gray-900
|
|
border ${props.error ? 'border-red-500' : 'border-gray-300'}
|
|
${sizeClasses[size]}
|
|
outline-none
|
|
transition-all duration-200
|
|
hover:border-primary
|
|
focus:border-primary focus:ring-2 focus:ring-primary/20
|
|
disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed disabled:border-gray-300
|
|
placeholder:text-gray-500
|
|
${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">
|
|
{!value && (
|
|
<button
|
|
type="button"
|
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
|
className="text-gray-500 hover:text-gray-900 cursor-pointer disabled:cursor-not-allowed p-0.5"
|
|
disabled={disabled}
|
|
tabIndex={-1}
|
|
>
|
|
<CaretDownIcon
|
|
size={16}
|
|
className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
|
/>
|
|
</button>
|
|
)}
|
|
|
|
{searchTerm && !disabled && (
|
|
<button type="button" onClick={handleClear} className="text-gray-500 hover:text-gray-900 p-0.5">
|
|
<XIcon size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Dropdown Menu */}
|
|
<DropdownPortal
|
|
anchorRef={dropdownRef}
|
|
isOpen={isOpen && !disabled}
|
|
ref={panelRef}
|
|
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-y-auto flex flex-col"
|
|
>
|
|
{filteredOptions.length === 0 ? (
|
|
<div className="px-4 py-3 text-sm text-gray-500 text-center">No results found</div>
|
|
) : (
|
|
filteredOptions.map((option, index) => {
|
|
const isSelected = String(option.value) === String(value);
|
|
return (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => handleSelect(option)}
|
|
onMouseEnter={() => setHighlightedIndex(index)}
|
|
className={`
|
|
w-full text-left px-3 min-h-[42px] text-[14px] leading-none
|
|
transition-colors duration-150 flex items-center gap-2
|
|
${isSelected
|
|
? "bg-[#EEF9EF]"
|
|
: highlightedIndex === index
|
|
? "bg-gray-50 text-gray-900"
|
|
: "text-gray-700 hover:bg-gray-50"
|
|
}
|
|
`}
|
|
>
|
|
{isSelected && <CheckIcon size={16} className="text-primary shrink-0" strokeWidth={2.5} />}
|
|
<span
|
|
className={
|
|
isSelected
|
|
? "ml-1 font-medium bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent"
|
|
: "ml-6"
|
|
}
|
|
>
|
|
{option.label}
|
|
</span>
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</DropdownPortal>
|
|
</div>
|
|
{props.error && <p className="text-xs text-red-500 mt-1">{props.error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
CustomSearchableDropdown.displayName = "CustomSearchableDropdown";
|
|
|
|
export default CustomSearchableDropdown;
|