* 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>
784 lines
31 KiB
TypeScript
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>
|
|
);
|
|
});
|