pmtool / src /components /meeting-room /ComboboxMulti.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
10.1 kB
import * as React from "react";
import { X, Check, ChevronsUpDown, Search, User, Users } from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
export interface ComboboxOption {
value: number;
label: string;
department?: string;
role?: string;
}
interface ComboboxMultiProps {
options: ComboboxOption[];
selectedValues: number[];
onValuesChange: (values: number[]) => void;
placeholder?: string;
emptyText?: string;
label?: string;
}
export function ComboboxMulti({
options,
selectedValues,
onValuesChange,
placeholder = "Select options",
emptyText = "No options found.",
label,
}: ComboboxMultiProps) {
const [isOpen, setIsOpen] = React.useState(false);
const [searchTerm, setSearchTerm] = React.useState("");
const containerRef = React.useRef<HTMLDivElement>(null);
// Safety checks
const safeOptions = Array.isArray(options) ? options : [];
const safeSelectedValues = Array.isArray(selectedValues) ? selectedValues : [];
// Close dropdown when clicking outside
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Filter options based on search term
const filteredOptions = React.useMemo(() => {
if (!searchTerm) return safeOptions;
return safeOptions.filter(option =>
option.label.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [safeOptions, searchTerm]);
// Toggle option selection
const toggleOption = React.useCallback((value: number) => {
try {
if (safeSelectedValues.includes(value)) {
onValuesChange(safeSelectedValues.filter(v => v !== value));
} else {
onValuesChange([...safeSelectedValues, value]);
}
} catch (error) {
console.error("Error toggling option selection:", error);
}
}, [safeSelectedValues, onValuesChange]);
// Select all options
const selectAll = React.useCallback(() => {
try {
const allValues = safeOptions.map(option => option.value);
onValuesChange(allValues);
} catch (error) {
console.error("Error selecting all options:", error);
}
}, [safeOptions, onValuesChange]);
// Clear all selections (except any that should be preserved)
const clearAll = React.useCallback(() => {
try {
onValuesChange([]);
} catch (error) {
console.error("Error clearing selections:", error);
}
}, [onValuesChange]);
// Get selected option names for display
const getSelectedOptionLabels = React.useCallback(() => {
const selectedCount = safeSelectedValues.length;
if (selectedCount === 0) {
return placeholder;
} else if (selectedCount === 1) {
const option = safeOptions.find(o => o.value === safeSelectedValues[0]);
return option ? option.label : '1 selected';
} else if (selectedCount <= 2) {
return safeSelectedValues
.map(value => safeOptions.find(option => option.value === value)?.label)
.filter(Boolean)
.join(", ");
} else {
return `${selectedCount} attendees selected`;
}
}, [safeOptions, safeSelectedValues, placeholder]);
// Calculate unselected options count
const unselectedCount = React.useMemo(() => {
return safeOptions.length - safeSelectedValues.length;
}, [safeOptions, safeSelectedValues]);
return (
<div className="space-y-2">
{label && <Label>{label}</Label>}
<div ref={containerRef} className="relative">
<div
className={cn(
"flex min-h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background cursor-pointer",
isOpen && "ring-2 ring-ring ring-offset-2"
)}
onClick={() => setIsOpen(!isOpen)}
>
<div className="flex items-center gap-2 flex-1 truncate">
<Users className="h-4 w-4 text-muted-foreground" />
<span className={safeSelectedValues.length === 0 ? "text-muted-foreground" : ""}>
{getSelectedOptionLabels()}
</span>
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
{isOpen && (
<div className="absolute z-50 mt-1 w-full overflow-auto rounded-md border bg-popover shadow-md">
<div className="sticky top-0 bg-popover p-2 border-b">
<div className="relative mb-2">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search employees..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-9 pl-8"
onClick={(e) => e.stopPropagation()}
/>
</div>
{safeOptions.length > 3 && (
<div className="flex justify-between items-center">
<p className="text-xs text-muted-foreground">
{safeSelectedValues.length} of {safeOptions.length} selected
</p>
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
selectAll();
}}
disabled={safeSelectedValues.length === safeOptions.length}
>
Select All
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
clearAll();
}}
disabled={safeSelectedValues.length === 0}
>
Clear All
</Button>
</div>
</div>
)}
</div>
<div className="max-h-60 overflow-auto">
{filteredOptions.length === 0 ? (
<div className="py-6 px-2 text-sm text-center text-muted-foreground">
{emptyText}
</div>
) : (
<div className="py-1">
{filteredOptions.map((option) => (
<div
key={option.value}
className={cn(
"flex items-center justify-between py-1.5 px-2 text-sm rounded-sm cursor-pointer",
safeSelectedValues.includes(option.value)
? "bg-primary text-primary-foreground"
: "hover:bg-accent hover:text-accent-foreground"
)}
onClick={(e) => {
e.stopPropagation();
toggleOption(option.value);
}}
>
<div className="flex items-center gap-2">
<div className={cn(
"flex h-5 w-5 items-center justify-center rounded-full border",
safeSelectedValues.includes(option.value)
? "border-primary-foreground"
: "border-muted-foreground"
)}>
<User className="h-3 w-3" />
</div>
<span>{option.label}</span>
{option.role && (
<Badge variant="outline" className="ml-2 text-xs py-0">
{option.role}
</Badge>
)}
</div>
{safeSelectedValues.includes(option.value) && (
<Check className="h-4 w-4" />
)}
</div>
))}
</div>
)}
</div>
</div>
)}
{safeSelectedValues.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{safeSelectedValues.map(value => {
const option = safeOptions.find(o => o.value === value);
if (!option) return null;
return (
<Badge
key={value}
variant="secondary"
className="flex items-center gap-1"
>
{option.label}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleOption(value);
}}
className="ml-1 rounded-full hover:bg-accent p-0.5"
>
<X className="h-3 w-3" />
<span className="sr-only">Remove {option.label}</span>
</button>
</Badge>
);
})}
</div>
)}
</div>
</div>
);
}