diff --git a/app/components/__tests__/AvailableParticipantsSection.test.tsx b/app/components/__tests__/AvailableParticipantsSection.test.tsx index 76ccd15..5c6c433 100644 --- a/app/components/__tests__/AvailableParticipantsSection.test.tsx +++ b/app/components/__tests__/AvailableParticipantsSection.test.tsx @@ -3,6 +3,29 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection"; +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: ({ + count, + estimateSize, + }: { + count: number; + estimateSize: () => number; + }) => { + const size = estimateSize(); + const items = Array.from({ length: count }, (_, i) => ({ + index: i, + start: i * size, + size, + key: i, + })); + return { + getTotalSize: () => count * size, + getVirtualItems: () => items, + measureElement: vi.fn(), + }; + }, +})); + const defaultProps = { participants: [ { id: "1", name: "Player A", sport: { id: "s1", name: "NFL" } }, diff --git a/app/components/draft/AvailableParticipantsSection.tsx b/app/components/draft/AvailableParticipantsSection.tsx index 61dfb6e..12a74f8 100644 --- a/app/components/draft/AvailableParticipantsSection.tsx +++ b/app/components/draft/AvailableParticipantsSection.tsx @@ -1,4 +1,5 @@ -import { memo, useCallback, useMemo } from "react"; +import { memo, useCallback, useMemo, useRef } 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"; @@ -271,6 +272,19 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS const hasActiveFilters = sportFilters.length > 0 || hideCompletedSports; + const parentRef = useRef(null); + const virtualizer = useVirtualizer({ + count: participants.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 72, + overscan: 5, + getItemKey: (index) => participants[index]?.id ?? index, + }); + + const desktopGridClass = hasTeam + ? "grid-cols-[60px_60px_1fr_140px]" + : "grid-cols-[60px_60px_1fr]"; + return (
{miniDraftGrid && ( @@ -280,7 +294,6 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS )}
- {/* Search Input */}
- {/* Sport Filter Multi-Select */}
- {/* Mobile: bottom sheet */}
@@ -334,7 +345,6 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
- {/* Desktop: popover */}
@@ -374,7 +384,6 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
- {/* Show/Hide Drafted Toggle */} - {/* Show/Hide Ineligible Toggle - only show if user has a team */} {hasTeam && eligibility && (
- {/* Mobile Card List - visible on mobile only */} -
+
+ OVR + SPR + Participant +
+ +
{participants.length === 0 ? (
{emptyMessage}
) : ( - participants.map((participant) => { - const { isDrafted, isInQueue, isEligible, ineligibleReason } = - getParticipantState(participant, draftedParticipantIds, queueMap, eligibility); +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const participant = participants[virtualItem.index]; + const { isDrafted, isInQueue, isEligible, ineligibleReason } = + getParticipantState(participant, draftedParticipantIds, queueMap, eligibility); + const rank = participantRanks.get(participant.id); - return ( -
-
- - {participant.name} - -
- - {participant.sport.name} - - - OVR {participantRanks.get(participant.id)?.overallRank} · SPR {participantRanks.get(participant.id)?.sportRank} - - {isDrafted && ( - - Drafted - - )} - {!isDrafted && !isEligible && ( - - Ineligible - - )} - {isInQueue && !isDrafted && isEligible && ( - - Queued - - )} -
-
- {hasTeam && !isDrafted && ( -
- {!isInQueue ? ( - - ) : ( - - )} - -
- )} -
- ); - }) - )} -
- - {/* Desktop Table - hidden on mobile */} -
-
- - - - - - - - {hasTeam && ( - - )} - - - - {participants.length === 0 ? ( - - - - ) : ( - participants.map((participant) => { - const { isDrafted, isInQueue, isEligible, ineligibleReason } = - getParticipantState(participant, draftedParticipantIds, queueMap, eligibility); - - return ( - +
+
-
- - - - {hasTeam && ( - + + {hasTeam && !isDrafted && ( +
+ {!isInQueue ? ( + + ) : ( + + )} + +
)} - - ); - }) - )} - -
OVRSPRParticipantSportQueue
- {emptyMessage} -
- {participantRanks.get(participant.id)?.overallRank} - - {participantRanks.get(participant.id)?.sportRank} - -
- - {participant.name} +
+ + {participant.name} + +
+ + {participant.sport.name} + + + OVR {rank?.overallRank} · SPR {rank?.sportRank} {isDrafted && ( @@ -582,71 +486,162 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS )}
-
- {participant.sport.name} - -
- {!isDrafted && ( - <> - {/* Queue icon button */} - {!isInQueue ? ( - - ) : ( - - )} - {/* Draft button - always visible, disabled when not your turn */} - - - )} -
-
-
+
+
+ +
+ + {rank?.overallRank} + + + {rank?.sportRank} + +
+
+ + {participant.name} + + + {participant.sport.name} + + {isDrafted && ( + + Drafted + + )} + {!isDrafted && !isEligible && ( + + Ineligible + + )} + {isInQueue && !isDrafted && isEligible && ( + + Queued + + )} +
+
+ {hasTeam && ( +
+
+ {!isDrafted && ( + <> + {!isInQueue ? ( + + ) : ( + + )} + + + )} +
+
+ )} +
+
+ ); + })} +
+ )}
); diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index c828a9a..925382c 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -1395,6 +1395,7 @@ export default function DraftRoom() { const draftGrid = useMemo(() => { const teamCount = draftSlots.length; const rounds = season.draftRounds; + const pickMap = new Map(picks.map((p) => [p.pickNumber, p])); const grid: Array< Array<{ pickNumber: number; @@ -1415,7 +1416,7 @@ export default function DraftRoom() { const pickNumber = (round - 1) * teamCount + pickInRound; const teamId = draftSlots[teamIndex]?.team.id; - const pick = picks.find((p) => p.pickNumber === pickNumber); + const pick = pickMap.get(pickNumber); roundPicks.push({ pickNumber, round, pickInRound, teamId, pick }); } @@ -1502,20 +1503,22 @@ export default function DraftRoom() { } }, [userDraftedSportNames]); - const availableParticipantsSectionProps = { + const miniDraftGrid = useMemo(() => ({ + draftSlots, + draftGrid, + currentPick, + currentRound, + ownerMap, + onForceAutopick: isCommissioner ? handleForceAutopick : undefined, + onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined, + onReplacePick: isCommissioner ? handleReplacePickOpen : undefined, + onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined, + }), [draftSlots, draftGrid, currentPick, currentRound, ownerMap, isCommissioner, handleForceAutopick, handleForceManualPickOpen, handleReplacePickOpen, handleRollbackToPick, isDraftComplete]); + + const availableParticipantsSectionProps = useMemo(() => ({ participants: filteredParticipants, participantRanks, - miniDraftGrid: { - draftSlots, - draftGrid, - currentPick, - currentRound, - ownerMap, - onForceAutopick: isCommissioner ? handleForceAutopick : undefined, - onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined, - onReplacePick: isCommissioner ? handleReplacePickOpen : undefined, - onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined, - }, + miniDraftGrid, searchQuery, sportFilters, hideDrafted, @@ -1536,7 +1539,7 @@ export default function DraftRoom() { onMakePick: handleMakePick, onAddToQueue: handleAddToQueue, onRemoveFromQueue: handleRemoveFromQueue, - }; + }), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue]); const draftGridSectionProps = { draftSlots, diff --git a/package-lock.json b/package-lock.json index 23d4290..f005f78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@react-router/express": "^7.7.1", "@react-router/node": "^7.7.1", "@sentry/react-router": "^10.43.0", + "@tanstack/react-virtual": "^3.13.24", "@types/nprogress": "^0.2.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -6491,6 +6492,33 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", + "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/cypress": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-10.1.0.tgz", diff --git a/package.json b/package.json index 7bbd154..61be075 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@react-router/express": "^7.7.1", "@react-router/node": "^7.7.1", "@sentry/react-router": "^10.43.0", + "@tanstack/react-virtual": "^3.13.24", "@types/nprogress": "^0.2.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1",