214 lines
8.4 KiB
TypeScript
214 lines
8.4 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={cn(
|
|
"relative min-h-[48px] bg-background border border-border rounded-lg shadow-sm flex flex-wrap items-center gap-2 px-3 py-1.5 transition-all",
|
|
"focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary"
|
|
)}
|
|
onClick={() => inputRef.current?.focus()}
|
|
>
|
|
<Search className="w-4 h-4 text-muted-foreground shrink-0 mr-1" />
|
|
|
|
{selectedParticipants.map((participant) => (
|
|
<div
|
|
key={participant.id}
|
|
className={cn(
|
|
"flex items-center gap-1.5 bg-accent text-accent-foreground pl-2 pr-1 py-1 rounded-full text-xs font-medium animate-scale-in",
|
|
"border border-primary/10 group hover:border-destructive/30 hover:bg-destructive/10 hover:text-destructive transition-colors cursor-default"
|
|
)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div
|
|
className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold text-white shrink-0"
|
|
style={{ backgroundColor: getAvatarColor(participant.name) }}
|
|
>
|
|
{getInitials(participant.name)}
|
|
</div>
|
|
<span className="max-w-[100px] truncate">{participant.name.split(' ')[0]}</span>
|
|
{!participant.icsLink && (
|
|
<AlertCircle className="w-3 h-3 text-amber-600 shrink-0" title="No calendar linked" />
|
|
)}
|
|
<button
|
|
onClick={() => removeParticipant(participant.id)}
|
|
className="w-4 h-4 rounded-full hover:bg-black/10 dark:hover:bg-white/10 flex items-center justify-center transition-colors shrink-0"
|
|
>
|
|
<X className="w-2.5 h-2.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
|
|
<Input
|
|
ref={inputRef}
|
|
placeholder={selectedParticipants.length === 0 ? "Search people..." : "Add more..."}
|
|
value={searchQuery}
|
|
onChange={(e) => {
|
|
setSearchQuery(e.target.value);
|
|
setHighlightedIndex(0);
|
|
setIsDropdownOpen(true);
|
|
}}
|
|
onFocus={() => setIsDropdownOpen(true)}
|
|
onKeyDown={handleKeyDown}
|
|
className="flex-1 min-w-[100px] h-7 border-none shadow-none focus-visible:ring-0 p-0 text-sm bg-transparent placeholder:text-muted-foreground/70"
|
|
/>
|
|
|
|
{isDropdownOpen && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setIsDropdownOpen(false);
|
|
inputRef.current?.blur();
|
|
}}
|
|
className="p-1 hover:bg-muted rounded-full transition-colors ml-1"
|
|
title="Close"
|
|
>
|
|
<X className="w-4 h-4 text-muted-foreground" />
|
|
</button>
|
|
)}
|
|
|
|
{isDropdownOpen && filteredParticipants.length > 0 && (
|
|
<div className="absolute top-full left-0 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>
|
|
</div>
|
|
);
|
|
};
|