feat: Add ParticipantSelector component for improved participant selection in brackets

This commit is contained in:
Chris Parsons 2025-11-08 21:36:29 -08:00
parent bbacb00d6c
commit 4d305c4117
2 changed files with 217 additions and 47 deletions

View file

@ -0,0 +1,148 @@
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>
);
}

View file

@ -1,5 +1,5 @@
import { Form, Link } from "react-router";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.bracket.server";
import { Button } from "~/components/ui/button";
@ -29,6 +29,7 @@ import {
TableHeader,
TableRow,
} from "~/components/ui/table";
import { ParticipantSelector } from "~/components/ParticipantSelector";
export { loader, action };
@ -64,15 +65,32 @@ export default function EventBracket({
}));
};
// Get available participants for a specific seed (excluding already selected)
const getAvailableParticipants = (currentSeedIndex: number) => {
const selectedIds = Object.entries(selectedParticipants)
.filter(([seedIdx]) => Number(seedIdx) !== currentSeedIndex)
.map(([_, id]) => id);
// Memoized available participants calculation - only recalculates when dependencies change
// This prevents 68 × 68 filter operations on every render
const availableParticipantsMap = useMemo(() => {
// Build a Set of selected IDs for O(1) lookup
const selectedIdsSet = new Set(Object.values(selectedParticipants));
return participants.filter(
(p: { id: string; name: string }) => !selectedIds.includes(p.id)
);
// Pre-calculate available participants for each seed slot
const map: Record<number, typeof participants> = {};
const totalSeeds = template?.totalTeams || 8;
for (let i = 0; i < totalSeeds; i++) {
// For this seed slot, allow its current selection but exclude all others
const currentSelection = selectedParticipants[i];
map[i] = participants.filter((p: { id: string; name: string }) => {
// Allow if not selected, OR if it's the current selection for this seed
return !selectedIdsSet.has(p.id) || p.id === currentSelection;
});
}
return map;
}, [selectedParticipants, participants, template?.totalTeams]);
// Get available participants for a specific seed (now just a lookup)
const getAvailableParticipants = (seedIndex: number) => {
return availableParticipantsMap[seedIndex] || participants;
};
// Update selected winner for a match
@ -92,17 +110,20 @@ export default function EventBracket({
});
};
// Group matches by round
const matchesByRound: Record<string, typeof matches> = {};
// Memoized: Group matches by round
const matchesByRound = useMemo(() => {
const grouped: Record<string, typeof matches> = {};
for (const match of matches) {
if (!matchesByRound[match.round]) {
matchesByRound[match.round] = [];
if (!grouped[match.round]) {
grouped[match.round] = [];
}
matchesByRound[match.round].push(match);
grouped[match.round].push(match);
}
return grouped;
}, [matches]);
// Get available rounds in chronological order (first round to finals)
const getOrderedRounds = () => {
// Memoized: Get available rounds in chronological order (first round to finals)
const availableRounds = useMemo(() => {
const roundsInMatches = Array.from(new Set(matches.map(m => m.round)));
// If event has a bracket template, use its round order
@ -118,17 +139,17 @@ export default function EventBracket({
// Fallback: return rounds as they appear
return roundsInMatches;
};
}, [matches, event.bracketTemplateId]);
const availableRounds = getOrderedRounds();
// Get participants not yet in any match
// Memoized: Get participants not yet in any match
const availableParticipants = useMemo(() => {
const participantsInMatches = new Set(
matches.flatMap(m => [m.participant1Id, m.participant2Id].filter(Boolean))
);
const availableParticipants = participants.filter(
return participants.filter(
(p: { id: string }) => !participantsInMatches.has(p.id)
);
}, [matches, participants]);
return (
<div className="p-8">
@ -205,30 +226,31 @@ export default function EventBracket({
<div className="space-y-2">
<Label>Select Participants (in order)</Label>
<p className="text-sm text-muted-foreground">
Select {template?.totalTeams || 8} participants for the bracket
Select {template?.totalTeams || 8} participants for the bracket. Use search to quickly find participants.
</p>
{[...Array(template?.totalTeams || 8)].map((_, i) => {
const availableParticipants = getAvailableParticipants(i);
return (
<Select
key={i}
<div key={i} className="flex items-center gap-2">
<Label className="w-20 text-sm text-muted-foreground shrink-0">
Seed {i + 1}
</Label>
<div className="flex-1 min-w-0">
<input
type="hidden"
name={`participant${i}`}
value={selectedParticipants[i] || ""}
onValueChange={(value) => handleParticipantChange(i, value)}
required
>
<SelectTrigger>
<SelectValue placeholder={`Seed ${i + 1}`} />
</SelectTrigger>
<SelectContent>
{availableParticipants.map((participant: { id: string; name: string }) => (
<SelectItem key={participant.id} value={participant.id}>
{participant.name}
</SelectItem>
))}
</SelectContent>
</Select>
/>
<ParticipantSelector
participants={availableParticipants}
value={selectedParticipants[i] || ""}
onValueChange={(value) => handleParticipantChange(i, value)}
placeholder={`Select seed ${i + 1}...`}
/>
</div>
</div>
);
})}
</div>