brackt/app/components/draft/ParticipantSelectionDialog.tsx
Claude e25cba09ac
Fix code review findings from WCAG compliance pass
- Add Arrow key navigation + roving tabindex to all role=radiogroup
  components (AutodraftSettings x2, TimerModeSelector,
  OvernightPauseSettings, ScoringPresetPicker) per ARIA radio pattern
- Extract shared focus-trap logic into useFocusTrap hook; update
  ConnectionOverlay and AuthRecoveryOverlay to use it
- Add tabIndex={-1} to ConnectionOverlay Card so focus can land in
  spinner-only state (no interactive children)
- Replace aria-live on loading dots container with sr-only span so
  status changes are announced by text content, not aria-label
- Remove contradictory aria-hidden+role=columnheader from
  AvailableParticipantsSection visual-only header row
- Remove invalid scope="col" from div[role=columnheader] in
  DraftSummaryView (scope is only valid on <th>)
- Remove redundant aria-label from ParticipantSelectionDialog sport
  select (htmlFor label is sufficient)
- Change WizardStepper connector <li> to role=presentation
- Revert muted-foreground from 62% to 55% (original already passes
  contrast; footer was fixed separately via text-muted-foreground)

https://claude.ai/code/session_01JXajpFxhqLf8aPCncP81k3
2026-05-17 23:46:57 +00:00

198 lines
7 KiB
TypeScript

import { useState, useEffect, useMemo } from "react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import type { DraftIneligibilityReason } from "~/lib/draft-eligibility";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
interface ParticipantSelectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
participants: Array<{
id: string;
name: string;
sport: { id: string; name: string };
}>;
draftedParticipantIds: Set<string>;
/** Pass the old participant's ID to keep them in the list (replace-pick dialog) */
allowParticipantId?: string;
eligibility: {
eligibleSportIds: Set<string>;
ineligibleReasons: Record<string, DraftIneligibilityReason>;
} | null;
/** Marks a row with "Current" badge and "Keep" button label */
currentParticipantId?: string;
uniqueSports: string[];
onSelect: (participantId: string) => void;
}
export function ParticipantSelectionDialog({
open,
onOpenChange,
title,
description,
participants,
draftedParticipantIds,
allowParticipantId,
eligibility,
currentParticipantId,
uniqueSports,
onSelect,
}: ParticipantSelectionDialogProps) {
const [searchQuery, setSearchQuery] = useState("");
const [sportFilter, setSportFilter] = useState("all");
// Reset filters each time the dialog opens
useEffect(() => {
if (open) {
setSearchQuery("");
setSportFilter("all");
}
}, [open]);
const filtered = useMemo(
() =>
participants.filter((p) => {
if (p.id !== allowParticipantId && draftedParticipantIds.has(p.id))
return false;
if (
searchQuery &&
!p.name.toLowerCase().includes(searchQuery.toLowerCase())
)
return false;
if (sportFilter !== "all" && p.sport.name !== sportFilter) return false;
return true;
}),
[participants, draftedParticipantIds, allowParticipantId, searchQuery, sportFilter]
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col gap-0 p-0">
<DialogHeader className="p-6 pb-4">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="px-6 pb-4">
<div className="flex gap-2 mb-4">
<label htmlFor="participant-dialog-search" className="sr-only">Search participants</label>
<input
id="participant-dialog-search"
type="text"
placeholder="Search participants..."
className="flex-1 px-3 py-2 border rounded-md text-base md:text-sm bg-background text-foreground"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<label htmlFor="participant-dialog-sport" className="sr-only">Filter by sport</label>
<select
id="participant-dialog-sport"
className="px-3 py-2 border rounded-md text-base md:text-sm bg-background text-foreground"
value={sportFilter}
onChange={(e) => setSportFilter(e.target.value)}
>
<option value="all">All Sports</option>
{uniqueSports.map((sport) => (
<option key={sport} value={sport}>
{sport}
</option>
))}
</select>
</div>
<div className="max-h-96 overflow-y-auto border rounded-lg">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="text-left p-3 font-semibold">Participant</th>
<th className="text-left p-3 font-semibold">Sport</th>
<th className="text-right p-3 font-semibold">Action</th>
</tr>
</thead>
<tbody>
{filtered.map((participant) => {
const isCurrentPick =
participant.id === currentParticipantId;
const isEligible = eligibility
? eligibility.eligibleSportIds.has(participant.sport.id)
: true;
const ineligibleReason =
eligibility?.ineligibleReasons[participant.sport.id] ?? null;
return (
<tr
key={participant.id}
className={`border-t ${
isEligible
? "hover:bg-muted/50"
: "bg-destructive/10 opacity-60"
}`}
title={!isEligible ? ineligibleReason?.message : undefined}
>
<td className="p-3">
<div className="flex items-center gap-2">
<span
className={`font-medium ${!isEligible ? "text-muted-foreground" : ""}`}
>
{participant.name}
</span>
{isCurrentPick && (
<Badge variant="outline" className="text-xs">
Current
</Badge>
)}
{!isEligible && (
<Badge
variant="destructive"
className="text-xs"
title={ineligibleReason?.message}
>
Ineligible
</Badge>
)}
</div>
{!isEligible && ineligibleReason && (
<p className="mt-1 text-xs text-destructive/90">
{ineligibleReason.message}
</p>
)}
</td>
<td className="p-3 text-muted-foreground">
{participant.sport.name}
</td>
<td className="p-3 text-right">
<Button
size="sm"
onClick={() => onSelect(participant.id)}
disabled={!isEligible}
title={!isEligible ? ineligibleReason?.message : undefined}
>
{isCurrentPick ? "Keep" : "Draft"}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<DialogFooter className="px-6 pb-6">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}