Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } |