yari-garan/src/core/components/base/base-drop-down.tsx
MehrdadAdabi d725c1b7d7 feat: Implement searchable dropdown component and refactor campaign page
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.
2025-11-25 13:42:19 +03:30

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";