brackt/app/components/draft/AvailableParticipantsSection.tsx
Chris Parsons 4bbcac1949
fix: resolve all 48 WCAG 2.2 AA accessibility issues (#439)
* fix: resolve all 48 WCAG 2.2 AA accessibility issues

Critical fixes:
- Add aria-label to all unlabeled inputs/selects in draft dialogs (ParticipantSelectionDialog, TimeBankAdjustmentDialog, AvailableParticipantsSection)
- Add role="dialog" + aria-modal + focus trap to ConnectionOverlay and AuthRecoveryOverlay
- Add aria-live region and connection status announcement to ConnectionOverlay

Serious fixes:
- Add skip-to-content link in root.tsx with id="main-content" on <main>
- Add aria-label to UserMenu trigger button
- Add aria-describedby + role="alert" to all auth form error messages (login, register, onboarding, forgot-password, reset-password)
- Replace emoji column headers in StandingsTable with aria-label + aria-hidden spans
- Add aria-live="assertive" to "It's your turn" desktop and mobile on-clock indicators
- Add aria-live="polite" to draft room countdown timer
- Add pause button to SportTicker (WCAG 2.2.2); add aria-hidden to ticker content
- Fix Footer text contrast (changed from 28% to text-muted-foreground)
- Fix OvernightPauseSettings: add htmlFor/id pairs and role="radiogroup"+aria-checked to mode buttons
- Fix DraftSetupSection: replace broken htmlFor with aria-label on date picker button
- Add aria-label to PeopleSection owner and commissioner selects
- Add labels to ScoringPresetPicker score inputs; add role="radiogroup"+aria-checked to preset buttons
- Add role="radiogroup"+aria-checked to AutodraftSettings option buttons
- Add accessible names, aria-current="step", and <ol> list semantics to WizardStepper

Moderate fixes:
- Add aria-controls to RecentPicksFeed toggle button; wrap picks list in aria-live region
- Add role="tab"+aria-selected+aria-controls to mobile board sub-tabs + role="tabpanel"
- Add role="radiogroup"+aria-checked to TimerModeSelector
- Add aria-current="page" + aria-label to SettingsDesktopNav
- Add aria-label="Admin navigation" to admin sidebar nav
- Add scope="col" + <caption> to StandingsTable and ScoringTables
- Add ARIA table roles (role="table/rowgroup/row/columnheader/rowheader/cell") to DraftSummaryView CSS grid

Minor fixes:
- Add aria-hidden="true" to decorative trend icons in StandingsTable
- Add aria-hidden="true" to desktop column header labels row in AvailableParticipantsSection
- Replace title with aria-label on all icon-only buttons (watchlist, queue) in AvailableParticipantsSection
- Add aria-label to NotificationSettings switchOnly Switch
- Add prefers-reduced-motion check to SlotMachineHeadline JS animation
- Bump --muted-foreground from 55% to 62% opacity for improved contrast margin

https://claude.ai/code/session_01JXajpFxhqLf8aPCncP81k3

* 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

* Fix lint error and update tests for WCAG role changes

- Replace el! non-null assertion with optional chaining in useFocusTrap
- Update AutodraftSettings tests to query role="radio" instead of
  role="button" (buttons have an explicit radio role since the WCAG pass)
- Update AvailableParticipantsSection watchlist tests to use
  getByRole/getAllByRole instead of getByTitle/getAllByTitle (watchlist
  buttons now use aria-label instead of title)

https://claude.ai/code/session_01JXajpFxhqLf8aPCncP81k3

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-17 20:11:38 -07:00

784 lines
31 KiB
TypeScript

import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Checkbox } from "~/components/ui/checkbox";
import { MiniDraftGrid, type MiniDraftGridProps } from "~/components/draft/MiniDraftGrid";
import type { DraftIneligibilityReason } from "~/lib/draft-eligibility";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/ui/popover";
import {
Sheet,
SheetTrigger,
SheetContent,
SheetTitle,
SheetFooter,
SheetClose,
} from "~/components/ui/sheet";
import { ListPlus, ListX, ChevronDown, Eye, EyeOff, SlidersHorizontal } from "lucide-react";
type ListItem =
| { type: "participant"; index: number; key: string }
| { type: "divider"; round: number; key: string };
function getParticipantState(
participant: { id: string; sport: { id: string } },
draftedParticipantIds: Set<string>,
queueMap: Map<string, string>,
eligibility: {
eligibleSportIds: Set<string>;
ineligibleReasons: Record<string, DraftIneligibilityReason>;
} | null,
watchedParticipantIds: Set<string>
) {
const isDrafted = draftedParticipantIds.has(participant.id);
const isInQueue = queueMap.has(participant.id);
const isEligible = eligibility
? eligibility.eligibleSportIds.has(participant.sport.id)
: true;
const ineligibleReason = eligibility?.ineligibleReasons[participant.sport.id] ?? null;
const isWatched = watchedParticipantIds.has(participant.id);
return { isDrafted, isInQueue, isEligible, ineligibleReason, isWatched };
}
interface SportFilterContentProps {
sportsForDropdown: Array<{ name: string; isDrafted: boolean }>;
sportFilterSet: Set<string>;
hideCompletedSports: boolean;
hasTeam: boolean;
variant: "sheet" | "popover";
onToggleSport: (sport: string, checked: boolean | "indeterminate") => void;
onHideCompletedSportsChange: (hide: boolean) => void;
}
function SportFilterContent({
sportsForDropdown,
sportFilterSet,
hideCompletedSports,
hasTeam,
variant,
onToggleSport,
onHideCompletedSportsChange,
}: SportFilterContentProps) {
const isSheet = variant === "sheet";
const idPrefix = isSheet ? "sheet-sport-filter-" : "popover-sport-filter-";
const listClass = isSheet
? "overflow-y-auto flex-1 px-4"
: "max-h-64 overflow-y-auto p-2 space-y-0.5";
const itemBaseClass = isSheet
? "flex items-center gap-4 py-4 border-b last:border-b-0 cursor-pointer text-base"
: "flex items-center gap-2 px-2 py-2 rounded-sm hover:bg-accent cursor-pointer text-sm";
const toggleLabelClass = isSheet
? "flex items-center gap-4 py-2 cursor-pointer text-base"
: "flex items-center gap-2 px-4 py-3 cursor-pointer text-sm";
return (
<>
<div className={listClass}>
{sportsForDropdown
.filter(({ isDrafted }) => !isDrafted || !hideCompletedSports)
.map(({ name, isDrafted }) => {
const checkboxId = `${idPrefix}${name.replace(/\s+/g, "-").toLowerCase()}`;
return (
<label
key={name}
htmlFor={checkboxId}
className={`${itemBaseClass}${isDrafted ? " text-muted-foreground" : ""}`}
>
<Checkbox
id={checkboxId}
checked={sportFilterSet.has(name)}
onCheckedChange={(checked) => onToggleSport(name, checked)}
/>
{name}
</label>
);
})}
</div>
{hasTeam && (
isSheet ? (
<div className="px-4 border-t pt-3">
<label className={toggleLabelClass}>
<Checkbox
checked={!hideCompletedSports}
onCheckedChange={(checked) =>
onHideCompletedSportsChange(checked === false)
}
/>
<span>Show drafted sports</span>
</label>
</div>
) : (
<>
<hr className="mx-2 border-border" />
<label className={toggleLabelClass}>
<Checkbox
checked={!hideCompletedSports}
onCheckedChange={(checked) =>
onHideCompletedSportsChange(checked === false)
}
/>
<span>Show drafted sports</span>
</label>
</>
)
)}
</>
);
}
interface AvailableParticipantsSectionProps {
participants: Array<{
id: string;
name: string;
sport: {
id: string;
name: string;
};
}>;
participantRanks: Map<string, { overallRank: number; sportRank: number }>;
miniDraftGrid?: MiniDraftGridProps;
searchQuery: string;
sportFilters: string[];
hideDrafted: boolean;
hideIneligible: boolean;
hideCompletedSports: boolean;
userDraftedSportNames: Set<string>;
uniqueSports: string[];
draftedParticipantIds: Set<string>;
queue: Array<{ id: string; participantId: string }>;
eligibility: {
eligibleSportIds: Set<string>;
ineligibleReasons: Record<string, DraftIneligibilityReason>;
} | null;
canPick: boolean;
hasTeam: boolean;
onSearchChange: (query: string) => void;
onSportFiltersChange: (sports: string[]) => void;
onHideDraftedChange: (hide: boolean) => void;
onHideIneligibleChange: (hide: boolean) => void;
onHideCompletedSportsChange: (hide: boolean) => void;
onMakePick: (participantId: string) => void;
onAddToQueue: (participantId: string) => void;
onRemoveFromQueue: (queueId: string) => void;
projectedPicks?: Array<{ round: number; picksFromNow: number }>;
watchedParticipantIds: Set<string>;
onToggleWatchlist: (participantId: string) => void;
showOnlyWatched: boolean;
onShowOnlyWatchedChange: (show: boolean) => void;
pickAnimationSignal: { id: string; seq: number } | null;
}
export const AvailableParticipantsSection = memo(function AvailableParticipantsSection({
participants,
participantRanks,
miniDraftGrid,
searchQuery,
sportFilters,
hideDrafted,
hideIneligible,
hideCompletedSports,
userDraftedSportNames,
uniqueSports,
draftedParticipantIds,
queue,
eligibility,
canPick,
hasTeam,
onSearchChange,
onSportFiltersChange,
onHideDraftedChange,
onHideIneligibleChange,
onHideCompletedSportsChange,
onMakePick,
onAddToQueue,
onRemoveFromQueue,
projectedPicks,
watchedParticipantIds,
onToggleWatchlist,
showOnlyWatched,
onShowOnlyWatchedChange,
pickAnimationSignal,
}: AvailableParticipantsSectionProps) {
const [animatingOutParticipantIds, setAnimatingOutParticipantIds] = useState<Set<string>>(new Set());
const animationTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(() => {
if (!pickAnimationSignal) return;
const { id } = pickAnimationSignal;
const existing = animationTimersRef.current.get(id);
if (existing) clearTimeout(existing);
setAnimatingOutParticipantIds((prev) => new Set([...prev, id]));
const timer = setTimeout(() => {
setAnimatingOutParticipantIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
animationTimersRef.current.delete(id);
}, 650);
animationTimersRef.current.set(id, timer);
}, [pickAnimationSignal]);
useEffect(() => () => { animationTimersRef.current.forEach(clearTimeout); }, []);
const queueMap = useMemo(
() => new Map(queue.map((item) => [item.participantId, item.id])),
[queue]
);
const sportFilterSet = useMemo(() => new Set(sportFilters), [sportFilters]);
const sportsForDropdown = useMemo(() => {
return uniqueSports.map((s) => ({
name: s,
isDrafted: hasTeam && userDraftedSportNames.has(s),
}));
}, [uniqueSports, userDraftedSportNames, hasTeam]);
const triggerText = useMemo(
() =>
sportFilters.length === 0
? "All Sports"
: sportFilters.length === 1
? sportFilters[0]
: `${sportFilters.length} Sports`,
[sportFilters]
);
const triggerAriaLabel = useMemo(
() =>
sportFilters.length === 0
? "Filter by sport: All Sports"
: sportFilters.length === 1
? `Filter by sport: ${sportFilters[0]}`
: `Filter by sport: ${sportFilters.length} sports selected`,
[sportFilters]
);
const handleToggleSport = useCallback(
(sport: string, checked: boolean | "indeterminate") => {
if (checked === true) {
onSportFiltersChange([...sportFilters, sport]);
} else {
onSportFiltersChange(sportFilters.filter((s) => s !== sport));
}
},
[sportFilters, onSportFiltersChange]
);
const handleReset = useCallback(() => {
onSportFiltersChange([]);
onHideCompletedSportsChange(false);
}, [onSportFiltersChange, onHideCompletedSportsChange]);
const emptyMessage = useMemo(() => {
if (participants.length > 0) return null;
const active: string[] = [];
if (hideDrafted) active.push("drafted players");
if (hideIneligible && eligibility) active.push("ineligible players");
if (hideCompletedSports) active.push("drafted sports");
if (sportFilters.length > 0) active.push("other sports");
if (active.length === 0) return "No participants found.";
return `No participants found. Try showing ${active.join(", ")}.`;
}, [participants.length, hideDrafted, hideIneligible, hideCompletedSports, eligibility, sportFilters]);
const hasActiveFilters = sportFilters.length > 0 || hideCompletedSports;
const anyFilterActive = searchQuery !== "" || sportFilters.length > 0 || hideDrafted || hideIneligible || hideCompletedSports;
const listItems = useMemo(() => {
const items: ListItem[] = [];
if (!projectedPicks || projectedPicks.length === 0 || anyFilterActive) {
for (let i = 0; i < participants.length; i++) {
items.push({ type: "participant", index: i, key: participants[i].id });
}
return items;
}
const dividersByAfterIndex = new Map<number, number>();
for (const pp of projectedPicks) {
if (pp.picksFromNow <= 0) continue;
const afterIndex = pp.picksFromNow - 1;
if (afterIndex >= 0 && afterIndex < participants.length - 1) {
dividersByAfterIndex.set(afterIndex, pp.round);
}
}
for (let i = 0; i < participants.length; i++) {
items.push({ type: "participant", index: i, key: participants[i].id });
const dividerRound = dividersByAfterIndex.get(i);
if (dividerRound !== undefined) {
items.push({ type: "divider", round: dividerRound, key: `divider-rd${dividerRound}` });
}
}
return items;
}, [participants, projectedPicks, anyFilterActive]);
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: listItems.length,
getScrollElement: () => parentRef.current,
estimateSize: (index) => listItems[index]?.type === "divider" ? 36 : 72,
overscan: 5,
getItemKey: (index) => listItems[index].key,
});
const desktopGridClass = hasTeam
? "grid-cols-[60px_60px_1fr_140px]"
: "grid-cols-[60px_60px_1fr]";
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
return (
<div className="flex flex-col h-full">
{miniDraftGrid && (
<div className="px-4 pt-4 pb-2 flex-shrink-0 border-b">
<MiniDraftGrid {...miniDraftGrid} />
</div>
)}
<div className="px-4 pt-4 pb-2 flex-shrink-0">
<div className="flex flex-col gap-2 md:flex-row md:flex-wrap md:items-center">
<div className="flex gap-2 md:contents">
<label htmlFor="available-participants-search" className="sr-only">Search participants</label>
<input
id="available-participants-search"
type="text"
placeholder="Search participants..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="flex-1 min-w-0 px-3 py-2 h-9 border rounded-md text-base md:text-sm bg-background text-foreground"
/>
<Button
variant="outline"
size="sm"
className="md:hidden shrink-0 h-9 gap-1.5"
onClick={() => setMobileFiltersOpen((prev) => !prev)}
aria-expanded={mobileFiltersOpen}
aria-label="Toggle filters"
>
<SlidersHorizontal className="h-4 w-4" />
Filters
</Button>
<div className="hidden md:block shrink-0">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-label={triggerAriaLabel}
className="w-[160px] justify-between text-sm font-normal"
>
<span className="truncate">{triggerText}</span>
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-0" align="start">
{hasActiveFilters && (
<div className="flex justify-end px-2 pt-2">
<Button
variant="ghost"
size="sm"
className="h-auto px-2 py-1 text-xs"
onClick={handleReset}
>
Reset
</Button>
</div>
)}
<SportFilterContent
sportsForDropdown={sportsForDropdown}
sportFilterSet={sportFilterSet}
hideCompletedSports={hideCompletedSports}
hasTeam={hasTeam}
variant="popover"
onToggleSport={handleToggleSport}
onHideCompletedSportsChange={onHideCompletedSportsChange}
/>
</PopoverContent>
</Popover>
</div>
</div>
<div className={`flex gap-2 flex-wrap md:contents ${mobileFiltersOpen ? "" : "hidden md:contents"}`}>
<div className="md:hidden">
<Sheet>
<SheetTrigger asChild>
<Button
variant="outline"
aria-label={triggerAriaLabel}
className="justify-between text-base font-normal w-full"
>
<span className="truncate">{triggerText}</span>
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</SheetTrigger>
<SheetContent side="bottom" className="max-h-[70vh] pt-12" aria-describedby={undefined}>
<SheetTitle className="sr-only">Filter by sport</SheetTitle>
<SportFilterContent
sportsForDropdown={sportsForDropdown}
sportFilterSet={sportFilterSet}
hideCompletedSports={hideCompletedSports}
hasTeam={hasTeam}
variant="sheet"
onToggleSport={handleToggleSport}
onHideCompletedSportsChange={onHideCompletedSportsChange}
/>
<SheetFooter className="px-4 pb-8 flex-row gap-2">
{hasActiveFilters && (
<Button
variant="outline"
className="flex-1"
onClick={handleReset}
>
Reset
</Button>
)}
<SheetClose asChild>
<Button className="flex-1">Done</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
<label className="flex flex-1 items-center gap-2 text-sm cursor-pointer px-3 py-2 border rounded-md md:shrink-0 md:flex-none">
<Checkbox
checked={!hideDrafted}
onCheckedChange={(checked) => onHideDraftedChange(checked === false)}
/>
<span>Show Drafted</span>
</label>
{hasTeam && eligibility && (
<label className="flex flex-1 items-center gap-2 text-sm cursor-pointer px-3 py-2 border rounded-md md:shrink-0 md:flex-none">
<Checkbox
checked={!hideIneligible}
onCheckedChange={(checked) => onHideIneligibleChange(checked === false)}
/>
<span>Show Ineligible</span>
</label>
)}
{hasTeam && (
<label className="flex flex-1 items-center gap-2 text-sm cursor-pointer px-3 py-2 border rounded-md md:shrink-0 md:flex-none">
<Checkbox
checked={showOnlyWatched}
onCheckedChange={(checked) => onShowOnlyWatchedChange(checked === true)}
disabled={watchedParticipantIds.size === 0}
/>
<span>Watched Only</span>
</label>
)}
</div>
</div>
</div>
<div aria-hidden="true" className={`hidden md:grid ${desktopGridClass} bg-muted border-b text-sm font-semibold flex-shrink-0 px-4`}>
<span className="text-center p-3 px-0">OVR</span>
<span className="text-center p-3 px-0">SPR</span>
<span className="text-left p-3">Participant</span>
</div>
<div ref={parentRef} className="flex-1 overflow-y-auto">
{participants.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
{emptyMessage}
</div>
) : (
<div
style={{
height: virtualizer.getTotalSize(),
position: "relative",
width: "100%",
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const listItem = listItems[virtualItem.index];
if (listItem.type === "divider") {
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: virtualItem.start,
left: 0,
right: 0,
}}
>
<div className="flex items-center gap-2 px-4 py-2">
<div className="flex-1 border-t-2 border-dashed border-electric/40" />
<span className="text-sm font-semibold text-electric whitespace-nowrap">
Projected Round {listItem.round} Pick
</span>
<div className="flex-1 border-t-2 border-dashed border-electric/40" />
</div>
</div>
);
}
const participant = participants[listItem.index];
const { isDrafted, isInQueue, isEligible, ineligibleReason, isWatched } =
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility, watchedParticipantIds);
const rank = participantRanks.get(participant.id);
const followsDivider = listItems[virtualItem.index - 1]?.type === "divider";
const isAnimatingOut = animatingOutParticipantIds.has(participant.id);
const animationClass = isAnimatingOut
? (hideDrafted ? "animate-pick-flash-out" : "animate-pick-flash")
: "";
// Freeze display state while animating so content doesn't shift height
const displayDrafted = isDrafted && !isAnimatingOut;
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: virtualItem.start,
left: 0,
right: 0,
}}
>
<div className={animationClass}>
<div className="md:hidden px-4 py-1">
<div
className={`bg-card border rounded-lg p-3 flex items-start justify-between gap-3 transition-colors ${
displayDrafted
? "bg-muted/50 opacity-60"
: !isEligible
? "bg-destructive/10 opacity-75"
: isWatched
? "bg-emerald-500/10 border-l-2 border-l-emerald-500/30"
: ""
}`}
>
<div className="flex flex-col gap-1 min-w-0">
<span
className={`font-semibold text-sm truncate ${!isEligible && !displayDrafted ? "text-muted-foreground" : ""}`}
>
{participant.name}
</span>
<div className="flex flex-wrap gap-1 items-center">
<Badge variant="outline" className="text-xs">
{participant.sport.name}
</Badge>
<span className="text-xs text-muted-foreground tabular-nums">
OVR {rank?.overallRank} · SPR {rank?.sportRank}
</span>
{displayDrafted && (
<Badge variant="secondary" className="text-xs">
Drafted
</Badge>
)}
{!displayDrafted && !isEligible && (
<Badge
variant="destructive"
className="text-xs"
title={ineligibleReason?.message}
>
Ineligible
</Badge>
)}
{!displayDrafted && !isEligible && ineligibleReason && (
<p className="text-xs text-destructive/90">
{ineligibleReason.message}
</p>
)}
{isInQueue && !displayDrafted && isEligible && (
<Badge variant="default" className="text-xs">
Queued
</Badge>
)}
</div>
</div>
{hasTeam && !displayDrafted && (
<div className="flex gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
className="min-h-[44px] min-w-[44px]"
onClick={() => onToggleWatchlist(participant.id)}
aria-label={isWatched ? "Remove from watchlist" : "Add to watchlist"}
>
{isWatched
? <EyeOff className="h-4 w-4 text-emerald-400" aria-hidden="true" />
: <Eye className="h-4 w-4" aria-hidden="true" />}
</Button>
{!isInQueue ? (
<Button
variant="ghost"
size="sm"
className="min-h-[44px] min-w-[44px]"
onClick={() => onAddToQueue(participant.id)}
aria-label={!isEligible ? (ineligibleReason?.message ?? "Add to queue") : "Add to queue"}
disabled={!isEligible}
>
<ListPlus className="h-4 w-4" aria-hidden="true" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="min-h-[44px] min-w-[44px]"
onClick={() => {
const queueId = queueMap.get(participant.id);
if (queueId) onRemoveFromQueue(queueId);
}}
aria-label="Remove from queue"
>
<ListX className="h-4 w-4" aria-hidden="true" />
</Button>
)}
<Button
variant="default"
size="sm"
className="min-h-[44px]"
onClick={() => onMakePick(participant.id)}
disabled={!canPick || !isEligible}
aria-label={
!canPick
? "Draft (not your turn)"
: !isEligible
? (ineligibleReason?.message ?? "Draft (ineligible)")
: `Draft ${participant.name}`
}
>
Draft
</Button>
</div>
)}
</div>
</div>{/* md:hidden */}
<div
className={`hidden md:grid ${desktopGridClass} items-center px-4 transition-colors ${
followsDivider ? "" : "border-t"
} ${
displayDrafted
? "bg-muted/50 opacity-60"
: !isEligible
? "bg-destructive/10 opacity-75"
: isWatched
? "bg-emerald-500/10 border-l-2 border-l-emerald-500/30"
: "hover:bg-muted/50"
}`}
title={
!isEligible && !displayDrafted ? ineligibleReason?.message : undefined
}
>
<span className="text-center text-muted-foreground tabular-nums p-3 px-0">
{rank?.overallRank}
</span>
<span className="text-center text-muted-foreground tabular-nums p-3 px-0">
{rank?.sportRank}
</span>
<div className="p-3">
<div className="flex items-center gap-2">
<span
className={`font-medium ${!isEligible && !displayDrafted ? "text-muted-foreground" : ""}`}
>
{participant.name}
</span>
<Badge variant="outline" className="text-xs">
{participant.sport.name}
</Badge>
{displayDrafted && (
<Badge variant="secondary" className="text-xs">
Drafted
</Badge>
)}
{!displayDrafted && !isEligible && (
<Badge
variant="destructive"
className="text-xs"
title={ineligibleReason?.message}
>
Ineligible
</Badge>
)}
{isInQueue && !displayDrafted && isEligible && (
<Badge variant="default" className="text-xs">
Queued
</Badge>
)}
{!displayDrafted && !isEligible && ineligibleReason && (
<p className="mt-1 text-xs text-destructive/90">
{ineligibleReason.message}
</p>
)}
</div>
</div>
{hasTeam && (
<div className="p-3">
<div className="flex gap-2 justify-end items-center">
{!displayDrafted && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => onToggleWatchlist(participant.id)}
aria-label={isWatched ? "Remove from watchlist" : "Add to watchlist"}
>
{isWatched
? <EyeOff className="h-4 w-4 text-emerald-400" aria-hidden="true" />
: <Eye className="h-4 w-4" aria-hidden="true" />}
</Button>
{!isInQueue ? (
<Button
variant="ghost"
size="sm"
onClick={() => onAddToQueue(participant.id)}
aria-label={!isEligible ? (ineligibleReason?.message ?? "Add to queue") : "Add to queue"}
disabled={!isEligible}
>
<ListPlus className="h-4 w-4" aria-hidden="true" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => {
const queueId = queueMap.get(participant.id);
if (queueId) onRemoveFromQueue(queueId);
}}
aria-label="Remove from queue"
>
<ListX className="h-4 w-4" aria-hidden="true" />
</Button>
)}
<Button
variant="default"
size="sm"
onClick={() => onMakePick(participant.id)}
disabled={!canPick || !isEligible}
aria-label={
!canPick
? "Draft (not your turn)"
: !isEligible
? (ineligibleReason?.message ?? "Draft (ineligible)")
: `Draft ${participant.name}`
}
>
Draft
</Button>
</>
)}
</div>
</div>
)}
</div>{/* hidden md:grid */}
</div>{/* animationClass wrapper */}
</div>
);
})}
</div>
)}
</div>
</div>
);
});