Files
common-availability/frontend/src/components/ParticipantSelector.tsx
2026-02-05 17:07:42 -05:00

204 lines
7.9 KiB
TypeScript

import { useState, useRef, useCallback, useEffect } from 'react';
import { Participant } from '@/types/calendar';
import { Input } from '@/components/ui/input';
import { X, Plus, Search, AlertCircle, Info } from 'lucide-react';
import { cn, getAvatarColor, getCalendarNameFromUrl } from '@/lib/utils';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
interface ParticipantSelectorProps {
participants: Participant[];
selectedParticipants: Participant[];
onSelectionChange: (participants: Participant[]) => void;
}
export const ParticipantSelector = ({
participants,
selectedParticipants,
onSelectionChange,
}: ParticipantSelectorProps) => {
const [searchQuery, setSearchQuery] = useState('');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filteredParticipants = participants.filter(
(p) =>
!selectedParticipants.find((sp) => sp.id === p.id) &&
(p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.email.toLowerCase().includes(searchQuery.toLowerCase()))
);
const addParticipant = useCallback((participant: Participant) => {
onSelectionChange([...selectedParticipants, participant]);
setSearchQuery('');
// Keep dropdown open for multi-select; clamp highlight to new list length
setHighlightedIndex((prev) => {
const newLength = filteredParticipants.length - 1;
return prev >= newLength ? Math.max(0, newLength - 1) : prev;
});
// Keep focus on input so user can continue selecting
requestAnimationFrame(() => inputRef.current?.focus());
}, [onSelectionChange, selectedParticipants, filteredParticipants.length]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isDropdownOpen || filteredParticipants.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightedIndex((prev) =>
prev < filteredParticipants.length - 1 ? prev + 1 : 0
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIndex((prev) =>
prev > 0 ? prev - 1 : filteredParticipants.length - 1
);
} else if (e.key === 'Enter') {
e.preventDefault();
addParticipant(filteredParticipants[highlightedIndex]);
} else if (e.key === 'Escape') {
setIsDropdownOpen(false);
}
};
const removeParticipant = (participantId: string) => {
onSelectionChange(selectedParticipants.filter((p) => p.id !== participantId));
setIsDropdownOpen(false);
inputRef.current?.blur();
};
const getInitials = (name: string) => {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
return (
<div ref={containerRef} className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
ref={inputRef}
placeholder="Search people..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setHighlightedIndex(0);
setIsDropdownOpen(true);
}}
onFocus={() => setIsDropdownOpen(true)}
onKeyDown={handleKeyDown}
className="pl-10 h-12 bg-background border-border"
/>
{isDropdownOpen && filteredParticipants.length > 0 && (
<div className="absolute z-50 w-full mt-2 bg-popover border border-border rounded-lg shadow-popover animate-scale-in overflow-hidden">
{filteredParticipants.map((participant, index) => (
<button
key={participant.id}
onClick={() => addParticipant(participant)}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
"w-full px-4 py-3 flex items-center gap-3 hover:bg-accent transition-colors text-left",
index === highlightedIndex && "bg-accent"
)}
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium text-white"
style={{ backgroundColor: getAvatarColor(participant.name) }}
>
{getInitials(participant.name)}
</div>
<div>
<div className="font-medium text-foreground">{participant.name}</div>
<div className="text-xs text-muted-foreground">{participant.email}</div>
</div>
<span className="ml-auto text-xs flex items-center gap-1">
{participant.icsLink ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground cursor-help flex items-center gap-1">
📅 {getCalendarNameFromUrl(participant.icsLink) || 'Calendar'}
<Info className="w-3 h-3 opacity-50" />
</span>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-[220px]">
<p>Availability is based on this calendar only. Other calendars on the same account are not included.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-amber-600 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
No calendar
</span>
)}
</span>
</button>
))}
</div>
)}
</div>
{selectedParticipants.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedParticipants.map((participant, index) => (
<div
key={participant.id}
className={cn(
"flex items-center gap-2 bg-accent text-accent-foreground px-3 py-2 rounded-full text-sm animate-scale-in",
"border border-primary/20"
)}
style={{ animationDelay: `${index * 50}ms` }}
>
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium text-white"
style={{ backgroundColor: getAvatarColor(participant.name) }}
>
{getInitials(participant.name)}
</div>
<span className="font-medium">{participant.name.split(' ')[0]}</span>
{!participant.icsLink && (
<AlertCircle className="w-3 h-3 text-amber-600" title="No calendar linked" />
)}
<button
onClick={() => removeParticipant(participant.id)}
className="w-5 h-5 rounded-full hover:bg-primary/20 flex items-center justify-center transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
))}
<button
onClick={() => setIsDropdownOpen(true)}
className="flex items-center gap-1 px-3 py-2 rounded-full text-sm border-2 border-dashed border-border text-muted-foreground hover:border-primary hover:text-primary transition-colors"
>
<Plus className="w-4 h-4" />
<span>Add</span>
</button>
</div>
)}
</div>
);
};