mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 12:55:23 +02:00
Sets up the project with Vite, React, Tailwind CSS, Gemini AI integration, and necessary dependencies for data analysis. Includes initial configuration for TypeScript, Tailwind, and project metadata.
152 lines
6.0 KiB
TypeScript
152 lines
6.0 KiB
TypeScript
|
|
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
|
import { SearchIcon } from './Icons';
|
|
|
|
interface MultiSelectDropdownProps {
|
|
label: string;
|
|
selected: string[];
|
|
options: string[];
|
|
onChange: (newSelected: string[]) => void;
|
|
className?: string;
|
|
}
|
|
|
|
const MultiSelectDropdown: React.FC<MultiSelectDropdownProps> = ({ label, selected, options, onChange, className }) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
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);
|
|
}, []);
|
|
|
|
// Focus input when opening
|
|
useEffect(() => {
|
|
if (isOpen && inputRef.current) {
|
|
inputRef.current.focus();
|
|
}
|
|
if (!isOpen) {
|
|
setSearchTerm(''); // Reset search when closing
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const filteredOptions = useMemo(() => {
|
|
if (!searchTerm) return options;
|
|
return options.filter(opt => opt.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
}, [options, searchTerm]);
|
|
|
|
const toggleOption = (option: string) => {
|
|
if (selected.includes(option)) {
|
|
onChange(selected.filter((item) => item !== option));
|
|
} else {
|
|
onChange([...selected, option]);
|
|
}
|
|
};
|
|
|
|
const handleSelectAll = () => {
|
|
// If searching, only select/deselect visible options
|
|
if (searchTerm) {
|
|
const allFilteredSelected = filteredOptions.every(opt => selected.includes(opt));
|
|
if (allFilteredSelected) {
|
|
// Deselect all filtered options
|
|
onChange(selected.filter(item => !filteredOptions.includes(item)));
|
|
} else {
|
|
// Select all filtered options (add unique ones)
|
|
const newSelected = Array.from(new Set([...selected, ...filteredOptions]));
|
|
onChange(newSelected);
|
|
}
|
|
} else {
|
|
// Standard behavior
|
|
if (selected.length === options.length) {
|
|
onChange([]); // Deselect all
|
|
} else {
|
|
onChange([...options]); // Select all
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleClear = () => {
|
|
onChange([]);
|
|
};
|
|
|
|
return (
|
|
<div className={`flex flex-col min-w-[150px] relative ${className}`} ref={dropdownRef}>
|
|
<label className="text-xs font-semibold text-slate-400 mb-1 uppercase tracking-wider">{label}</label>
|
|
|
|
<button
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className={`w-full text-left bg-surface border border-border hover:border-slate-600 text-sm rounded-lg py-2 px-3 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors flex justify-between items-center
|
|
${selected.length > 0 ? 'text-white font-medium border-primary/50' : 'text-slate-400'}`}
|
|
>
|
|
<span className="truncate">
|
|
{selected.length === 0
|
|
? (label === 'Customer' ? 'All Customers' : `All ${label}s`)
|
|
: `${selected.length} selected`}
|
|
</span>
|
|
<svg className={`fill-current h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
|
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" />
|
|
</svg>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className="absolute top-[calc(100%+4px)] left-0 w-64 max-h-96 overflow-hidden bg-slate-900 border border-slate-700 rounded-xl shadow-2xl z-[100] animate-fade-in flex flex-col">
|
|
|
|
{/* Search Bar */}
|
|
<div className="p-2 border-b border-slate-800 sticky top-0 bg-slate-900 z-10">
|
|
<div className="relative">
|
|
<span className="absolute left-2.5 top-2.5 text-slate-500">
|
|
<SearchIcon />
|
|
</span>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
placeholder={`Search ${label}...`}
|
|
className="w-full bg-slate-950 border border-slate-700 text-slate-200 text-sm rounded-md py-1.5 pl-9 pr-2 focus:outline-none focus:border-primary"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-between py-2 px-2 border-b border-slate-800 bg-slate-900/50">
|
|
<button onClick={handleSelectAll} className="text-xs text-primary hover:text-indigo-400 font-medium px-2">
|
|
{searchTerm
|
|
? (filteredOptions.every(opt => selected.includes(opt)) ? 'Unselect Results' : 'Select Results')
|
|
: (selected.length === options.length ? 'Unselect All' : 'Select All')
|
|
}
|
|
</button>
|
|
<button onClick={handleClear} className="text-xs text-slate-400 hover:text-white px-2">
|
|
Clear
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-1 overflow-y-auto custom-scrollbar p-2 max-h-60">
|
|
{filteredOptions.map((opt) => (
|
|
<label key={opt} className="flex items-center space-x-3 p-2 rounded hover:bg-slate-800 cursor-pointer group">
|
|
<input
|
|
type="checkbox"
|
|
checked={selected.includes(opt)}
|
|
onChange={() => toggleOption(opt)}
|
|
className="form-checkbox h-4 w-4 text-primary rounded border-slate-600 bg-slate-800 focus:ring-primary focus:ring-offset-slate-900 transition duration-150 ease-in-out"
|
|
/>
|
|
<span className={`text-sm break-all group-hover:text-white ${selected.includes(opt) ? 'text-white' : 'text-slate-400'}`}>
|
|
{opt}
|
|
</span>
|
|
</label>
|
|
))}
|
|
{filteredOptions.length === 0 && <div className="p-4 text-center text-xs text-slate-500">No matches found</div>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default MultiSelectDropdown;
|