brackt/app/components/ParticipantSelector.tsx

148 lines
4.5 KiB
TypeScript

import { useState, useMemo } from "react";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Check, ChevronsUpDown, X } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
interface Participant {
id: string;
name: string;
}
interface ParticipantSelectorProps {
participants: Participant[];
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
}
/**
* Searchable participant selector optimized for large lists (305+ items)
* Only renders filtered results to maintain performance
*/
export function ParticipantSelector({
participants,
value,
onValueChange,
placeholder = "Select participant...",
disabled = false,
}: ParticipantSelectorProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
// Find selected participant
const selectedParticipant = useMemo(
() => participants.find((p) => p.id === value),
[participants, value]
);
// Filter participants based on search (case-insensitive)
const filteredParticipants = useMemo(() => {
if (!search) return participants;
const searchLower = search.toLowerCase();
return participants.filter((p) =>
p.name.toLowerCase().includes(searchLower)
);
}, [participants, search]);
// Limit displayed results to prevent DOM overload
const displayedParticipants = useMemo(
() => filteredParticipants.slice(0, 50),
[filteredParticipants]
);
const handleSelect = (participantId: string) => {
onValueChange(participantId);
setOpen(false);
setSearch("");
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onValueChange("");
setSearch("");
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled}
>
<span className="truncate">
{selectedParticipant ? selectedParticipant.name : placeholder}
</span>
<div className="flex items-center gap-1">
{selectedParticipant && !disabled && (
<X
className="h-4 w-4 shrink-0 opacity-50 hover:opacity-100"
onClick={handleClear}
/>
)}
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<div className="flex flex-col">
{/* Search input */}
<div className="p-2 border-b">
<Input
placeholder="Search participants..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9"
autoFocus
/>
</div>
{/* Results */}
<div className="max-h-[300px] overflow-y-auto">
{displayedParticipants.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground text-center">
No participants found.
</div>
) : (
<>
{displayedParticipants.map((participant) => (
<button
key={participant.id}
onClick={() => handleSelect(participant.id)}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground cursor-pointer text-left",
participant.id === value && "bg-accent"
)}
>
<Check
className={cn(
"h-4 w-4 shrink-0",
participant.id === value ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{participant.name}</span>
</button>
))}
{filteredParticipants.length > 50 && (
<div className="p-2 text-xs text-muted-foreground text-center border-t">
Showing 50 of {filteredParticipants.length} results. Keep typing to refine...
</div>
)}
</>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
}