Spaces:
Sleeping
Sleeping
| import React, { useState, useRef, useEffect } from "react"; | |
| import { ChevronsUpDown, Plus, Search, X } from "lucide-react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Avatar, AvatarFallback } from "@/components/ui/avatar"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { cn } from "@/lib/utils"; | |
| import { Team } from "@/types"; | |
| interface MultiTeamSelectorProps { | |
| teams: Team[]; | |
| selectedTeams: Team[]; | |
| onSelect: (team: Team) => void; | |
| onRemove: (teamId: number) => void; | |
| placeholder?: string; | |
| disabled?: boolean; | |
| className?: string; | |
| } | |
| const MultiTeamSelector = ({ | |
| teams, | |
| selectedTeams, | |
| onSelect, | |
| onRemove, | |
| placeholder = "Select teams to add", | |
| disabled = false, | |
| className | |
| }: MultiTeamSelectorProps) => { | |
| const [isOpen, setIsOpen] = useState(false); | |
| const [searchQuery, setSearchQuery] = useState(""); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| // Filter out already selected teams | |
| const availableTeams = teams.filter(team => | |
| !selectedTeams.some(selected => selected.id === team.id) | |
| ); | |
| // Filter teams based on search query | |
| const filteredTeams = availableTeams.filter(team => | |
| team.name.toLowerCase().includes(searchQuery.toLowerCase()) || | |
| (team.description && team.description.toLowerCase().includes(searchQuery.toLowerCase())) | |
| ); | |
| // Handle click outside to close dropdown | |
| useEffect(() => { | |
| function handleClickOutside(event: MouseEvent) { | |
| if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { | |
| setIsOpen(false); | |
| } | |
| } | |
| document.addEventListener("mousedown", handleClickOutside); | |
| return () => { | |
| document.removeEventListener("mousedown", handleClickOutside); | |
| }; | |
| }, []); | |
| // Handle selecting a team | |
| const handleSelectTeam = (team: Team) => { | |
| onSelect(team); | |
| setSearchQuery(""); | |
| if (availableTeams.length <= 1) { | |
| setIsOpen(false); | |
| } | |
| }; | |
| return ( | |
| <div className={cn("relative w-full", className)} ref={dropdownRef}> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| role="combobox" | |
| aria-expanded={isOpen} | |
| className={cn( | |
| "w-full justify-between font-normal", | |
| availableTeams.length === 0 && "opacity-50" | |
| )} | |
| onClick={() => setIsOpen(!isOpen)} | |
| disabled={disabled || availableTeams.length === 0} | |
| > | |
| <span>{availableTeams.length === 0 ? "No more teams available" : placeholder}</span> | |
| <div className="flex items-center"> | |
| <Plus className="mr-1 h-4 w-4 opacity-50" /> | |
| <ChevronsUpDown className="h-4 w-4 opacity-50" /> | |
| </div> | |
| </Button> | |
| {selectedTeams.length > 0 && ( | |
| <div className="flex flex-wrap gap-2 mt-2"> | |
| {selectedTeams.map(team => ( | |
| <Badge | |
| key={team.id} | |
| variant="secondary" | |
| className="flex items-center gap-1 px-2 py-1" | |
| > | |
| <Avatar className="h-4 w-4"> | |
| <AvatarFallback className="text-[10px]"> | |
| {team.name.slice(0, 2).toUpperCase()} | |
| </AvatarFallback> | |
| </Avatar> | |
| <span>{team.name}</span> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-4 w-4 p-0 ml-1 opacity-70 hover:opacity-100 hover:bg-transparent" | |
| onClick={() => onRemove(team.id)} | |
| > | |
| <X className="h-3 w-3" /> | |
| <span className="sr-only">Remove {team.name}</span> | |
| </Button> | |
| </Badge> | |
| ))} | |
| </div> | |
| )} | |
| {isOpen && ( | |
| <div className="absolute top-full z-[9999] mt-1 w-full rounded-md border border-input bg-popover shadow-md animate-in fade-in-0 zoom-in-95"> | |
| <div className="flex items-center border-b px-3 py-2"> | |
| <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> | |
| <Input | |
| placeholder="Search teams..." | |
| className="flex h-8 w-full rounded-md bg-transparent py-2 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 border-0 focus-visible:ring-0 focus-visible:ring-offset-0" | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| onClick={(e) => e.stopPropagation()} | |
| /> | |
| </div> | |
| <div className="max-h-[15rem] overflow-y-auto py-1"> | |
| {filteredTeams.length === 0 ? ( | |
| <div className="px-2 py-3 text-sm text-center text-muted-foreground"> | |
| {searchQuery | |
| ? "No matching teams found" | |
| : "No more teams available to add"} | |
| </div> | |
| ) : ( | |
| filteredTeams.map(team => ( | |
| <div | |
| key={team.id} | |
| className="flex items-center justify-between px-3 py-2 text-sm cursor-pointer hover:bg-accent" | |
| onClick={() => handleSelectTeam(team)} | |
| > | |
| <div className="flex items-center gap-2"> | |
| <Avatar className="h-6 w-6"> | |
| <AvatarFallback> | |
| {team.name.slice(0, 2).toUpperCase()} | |
| </AvatarFallback> | |
| </Avatar> | |
| <div> | |
| <p className="font-medium">{team.name}</p> | |
| {team.description && ( | |
| <p className="text-xs text-muted-foreground truncate max-w-[200px]"> | |
| {team.description} | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="h-6 w-6" | |
| > | |
| <Plus className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default MultiTeamSelector; |