This commit introduces a new `BaseDropdown` component with search functionality and refactors the campaign listing page to utilize this new component and improve its layout. The `BaseDropdown` component was significantly refactored from a native `<select>` element to a custom component built with `div` and `input` elements. This allows for: - **Search functionality**: Users can now type to filter dropdown options. - **Improved accessibility**: Custom handling of focus and keyboard navigation. - **Enhanced styling**: More control over the visual appearance. The campaign listing page (`src/pages/campaigns/index.tsx`) was updated to: - Replace the previous dropdowns with the new `BaseDropdown` component. - Adjust the layout of the header, search bar, and filter section for better responsiveness and visual appeal. - Update the `VITE_API_URL` in `.env` to ensure a newline at the end of the file. These changes enhance the user experience by providing a more interactive and user-friendly way to select options and navigate the campaign page.
167 lines
5.0 KiB
TypeScript
167 lines
5.0 KiB
TypeScript
import { cn } from "@/core/lib/utils";
|
|
import { ChevronDown, Search } from "lucide-react";
|
|
import {
|
|
forwardRef,
|
|
type InputHTMLAttributes,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
|
|
type Option = { value: string; label: string };
|
|
|
|
type BaseDropdownProps = Omit<
|
|
InputHTMLAttributes<HTMLInputElement>,
|
|
"onChange"
|
|
> & {
|
|
label?: string;
|
|
error?: string;
|
|
variant?: "primary" | "error";
|
|
options: Option[];
|
|
placeholder?: string;
|
|
value?: string;
|
|
onChange?: (value: string) => void;
|
|
onInputChange?: (inputValue: string) => void;
|
|
};
|
|
|
|
export const BaseDropdown = forwardRef<HTMLDivElement, BaseDropdownProps>(
|
|
(
|
|
{
|
|
label,
|
|
error,
|
|
variant = "primary",
|
|
options,
|
|
placeholder = "انتخاب کنید",
|
|
className,
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
onInputChange,
|
|
},
|
|
ref
|
|
) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const selectedOption = options.find((option) => option.value === value);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (
|
|
dropdownRef.current &&
|
|
!dropdownRef.current.contains(event.target as Node)
|
|
) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setTimeout(() => inputRef.current?.focus(), 0);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const handleSelect = (option: Option) => {
|
|
if (onChange) {
|
|
onChange(option.value);
|
|
}
|
|
setIsOpen(false);
|
|
setSearchQuery("");
|
|
};
|
|
|
|
const filteredOptions = options.filter((option) =>
|
|
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
|
);
|
|
|
|
const hasError = !!error;
|
|
|
|
return (
|
|
<div className="w-full" ref={ref}>
|
|
{label && (
|
|
<label className="mb-2 block text-sm font-medium text-foreground text-right">
|
|
{label}
|
|
</label>
|
|
)}
|
|
|
|
<div className="relative" ref={dropdownRef}>
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className={cn(
|
|
"flex h-12 w-full items-center justify-between rounded-lg border-2 bg-background px-4 py-2 text-sm transition-all duration-200",
|
|
"focus-visible:outline-none focus-visible:ring-1",
|
|
variant === "error" || hasError
|
|
? "border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500/20"
|
|
: "border-gray-300 focus-visible:border-blue-600 focus-visible:ring-blue-600/20",
|
|
disabled && "opacity-60 cursor-not-allowed bg-gray-50",
|
|
className
|
|
)}
|
|
>
|
|
<span className={selectedOption ? "text-black" : "text-gray-400"}>
|
|
{selectedOption ? selectedOption.label : placeholder}
|
|
</span>
|
|
<ChevronDown
|
|
className={cn(
|
|
"h-5 w-5 transition-transform",
|
|
isOpen && "rotate-180",
|
|
hasError ? "text-red-500" : "text-gray-500"
|
|
)}
|
|
/>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className="absolute z-10 mt-1 w-full rounded-lg border-2 border-gray-200 bg-white shadow-lg max-h-60 overflow-auto">
|
|
<div className="p-2">
|
|
<div className="relative">
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
placeholder="جستجو..."
|
|
className="w-full rounded-md border border-gray-300 px-3 py-2 pl-8 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
value={searchQuery}
|
|
onChange={(e) => {
|
|
setSearchQuery(e.target.value);
|
|
if (onInputChange) {
|
|
onInputChange(e.target.value);
|
|
}
|
|
}}
|
|
/>
|
|
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
|
</div>
|
|
</div>
|
|
<ul className="max-h-48 overflow-auto">
|
|
{filteredOptions.map((option) => (
|
|
<li
|
|
key={option.value}
|
|
onClick={() => handleSelect(option)}
|
|
className="cursor-pointer px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
|
>
|
|
{option.label}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{hasError && (
|
|
<p className="mt-2 text-sm text-red-600 flex items-center gap-1 text-end">
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
BaseDropdown.displayName = "BaseDropdown";
|