diff --git a/app/app.css b/app/app.css index 2af3b25..531d075 100644 --- a/app/app.css +++ b/app/app.css @@ -162,6 +162,41 @@ html { animation: ticker-scroll 28s linear infinite; } +@keyframes pick-flash-out { + 0% { background-color: transparent; opacity: 1; } + 35% { background-color: rgb(44 225 193 / 0.25); opacity: 1; } + 60% { background-color: rgb(44 225 193 / 0.06); opacity: 1; } + 75% { background-color: transparent; opacity: 1; } + 100% { background-color: transparent; opacity: 0; } +} +@keyframes pick-flash-out-reduced { + 0% { opacity: 1; } + 100% { opacity: 0; } +} +.animate-pick-flash-out { + animation: pick-flash-out 750ms ease-in forwards; +} +@media (prefers-reduced-motion: reduce) { + .animate-pick-flash-out { + animation: pick-flash-out-reduced 750ms ease-in forwards; + } +} + +@keyframes pick-flash { + 0% { background-color: transparent; } + 35% { background-color: rgb(44 225 193 / 0.25); } + 75% { background-color: rgb(44 225 193 / 0.06); } + 100% { background-color: transparent; } +} +.animate-pick-flash { + animation: pick-flash 750ms ease-in forwards; +} +@media (prefers-reduced-motion: reduce) { + .animate-pick-flash { + animation: none; + } +} + /* NProgress theme override */ #nprogress .bar { background: var(--electric) !important; diff --git a/app/components/__tests__/AvailableParticipantsSection.test.tsx b/app/components/__tests__/AvailableParticipantsSection.test.tsx index 8230fb9..bcf5534 100644 --- a/app/components/__tests__/AvailableParticipantsSection.test.tsx +++ b/app/components/__tests__/AvailableParticipantsSection.test.tsx @@ -57,6 +57,7 @@ const defaultProps = { onToggleWatchlist: vi.fn(), showOnlyWatched: false, onShowOnlyWatchedChange: vi.fn(), + pickAnimationSignal: null, participantRanks: new Map([ ["1", { overallRank: 1, sportRank: 1 }], ["2", { overallRank: 2, sportRank: 1 }], diff --git a/app/components/draft/AvailableParticipantsSection.tsx b/app/components/draft/AvailableParticipantsSection.tsx index 9280a25..dbe8d78 100644 --- a/app/components/draft/AvailableParticipantsSection.tsx +++ b/app/components/draft/AvailableParticipantsSection.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo, useRef, useState } from "react"; +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"; @@ -169,6 +169,7 @@ interface AvailableParticipantsSectionProps { onToggleWatchlist: (participantId: string) => void; showOnlyWatched: boolean; onShowOnlyWatchedChange: (show: boolean) => void; + pickAnimationSignal: { id: string; seq: number } | null; } export const AvailableParticipantsSection = memo(function AvailableParticipantsSection({ @@ -200,7 +201,28 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS onToggleWatchlist, showOnlyWatched, onShowOnlyWatchedChange, + pickAnimationSignal, }: AvailableParticipantsSectionProps) { + const [animatingOutParticipantIds, setAnimatingOutParticipantIds] = useState>(new Set()); + const animationTimersRef = useRef>>(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] @@ -501,6 +523,12 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS 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 (
-
-
+
+
{participant.name} @@ -539,12 +568,12 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS OVR {rank?.overallRank} · SPR {rank?.sportRank} - {isDrafted && ( + {displayDrafted && ( Drafted )} - {!isDrafted && !isEligible && ( + {!displayDrafted && !isEligible && ( )} - {!isDrafted && !isEligible && ineligibleReason && ( + {!displayDrafted && !isEligible && ineligibleReason && (

{ineligibleReason.message}

)} - {isInQueue && !isDrafted && isEligible && ( + {isInQueue && !displayDrafted && isEligible && ( Queued )}
- {hasTeam && !isDrafted && ( + {hasTeam && !displayDrafted && (
-
+
{/* md:hidden */} -
@@ -649,19 +678,19 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
{participant.name} {participant.sport.name} - {isDrafted && ( + {displayDrafted && ( Drafted )} - {!isDrafted && !isEligible && ( + {!displayDrafted && !isEligible && ( )} - {isInQueue && !isDrafted && isEligible && ( + {isInQueue && !displayDrafted && isEligible && ( Queued )} - {!isDrafted && !isEligible && ineligibleReason && ( + {!displayDrafted && !isEligible && ineligibleReason && (

{ineligibleReason.message}

@@ -685,7 +714,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS {hasTeam && (
- {!isDrafted && ( + {!displayDrafted && ( <>
)} -
+
{/* hidden md:grid */} +
{/* animationClass wrapper */}
); })} diff --git a/app/components/draft/DraftGridSection.tsx b/app/components/draft/DraftGridSection.tsx index 0a74120..9c990f0 100644 --- a/app/components/draft/DraftGridSection.tsx +++ b/app/components/draft/DraftGridSection.tsx @@ -100,7 +100,6 @@ export const DraftGridSection = memo(function DraftGridSection({ return (
-

Draft Grid

{/* Team Headers */} diff --git a/app/hooks/useDraftSocketEvents.ts b/app/hooks/useDraftSocketEvents.ts index 489ef93..af94f4a 100644 --- a/app/hooks/useDraftSocketEvents.ts +++ b/app/hooks/useDraftSocketEvents.ts @@ -42,6 +42,7 @@ interface UseDraftSocketEventsParams { setRoomClosed: (value: boolean) => void; setIsSyncing: (value: boolean) => void; setBracktVorps: (fn: (prev: Map) => Map) => void; + setAnimatingOutParticipantId: (id: string) => void; } export function useDraftSocketEvents({ @@ -72,6 +73,7 @@ export function useDraftSocketEvents({ setRoomClosed, setIsSyncing, setBracktVorps, + setAnimatingOutParticipantId, }: UseDraftSocketEventsParams) { useEffect(() => { type PickMadePayload = { @@ -85,6 +87,9 @@ export function useDraftSocketEvents({ } else { setPicks((prev) => [...prev, data.pick]); setCurrentPick(data.nextPickNumber); + if (data.pick?.participant?.id) { + setAnimatingOutParticipantId(data.pick.participant.id); + } } if (data.isDraftComplete) return; diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 0ff2c92..9ca27aa 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -468,6 +468,26 @@ export default function DraftRoom() { ); }, [replacePickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]); + const [animatingOutParticipantIds, setAnimatingOutParticipantIds] = useState>(new Set()); + const [pickAnimationSignal, setPickAnimationSignal] = useState<{ id: string; seq: number } | null>(null); + const pickSeqRef = useRef(0); + const animationTimersRef = useRef>>(new Set()); + const addAnimatingOut = useCallback((id: string) => { + setAnimatingOutParticipantIds((prev) => new Set([...prev, id])); + pickSeqRef.current += 1; + setPickAnimationSignal({ id, seq: pickSeqRef.current }); + const timer = setTimeout(() => { + setAnimatingOutParticipantIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + animationTimersRef.current.delete(timer); + }, 650); + animationTimersRef.current.add(timer); + }, []); + useEffect(() => () => { animationTimersRef.current.forEach(clearTimeout); }, []); + useDraftSocketEvents({ on, off, @@ -496,6 +516,7 @@ export default function DraftRoom() { setRoomClosed, setIsSyncing, setBracktVorps, + setAnimatingOutParticipantId: addAnimatingOut, }); // Persist sidebar collapsed state @@ -1088,8 +1109,8 @@ export default function DraftRoom() { const filteredParticipants = useMemo(() => { const sportFilterSet = new Set(sportFilters); return sortedAvailableParticipants.filter((participant) => { - // Drafted filter - hide drafted participants by default - if (hideDrafted && draftedParticipantIds.has(participant.id)) { + // Drafted filter - hide drafted participants by default, but keep animating-out rows visible + if (hideDrafted && draftedParticipantIds.has(participant.id) && !animatingOutParticipantIds.has(participant.id)) { return false; } // Eligibility filter - hide ineligible participants by default (if user has a team) @@ -1120,7 +1141,7 @@ export default function DraftRoom() { } return true; }); - }, [sortedAvailableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, userDraftedSportIds, searchQuery, sportFilters, showOnlyWatched, watchedParticipantIds]); + }, [sortedAvailableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, animatingOutParticipantIds, userDraftedSportIds, searchQuery, sportFilters, showOnlyWatched, watchedParticipantIds]); // Shared component props — defined once to avoid duplication between desktop and mobile layouts const handleHideCompletedSportsChange = useCallback((hide: boolean) => { @@ -1179,7 +1200,8 @@ export default function DraftRoom() { onToggleWatchlist: handleToggleWatchlist, showOnlyWatched, onShowOnlyWatchedChange: setShowOnlyWatched, - }), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue, setSearchQuery, setSportFilters, setHideDrafted, setHideIneligible, projectedPicks, watchedParticipantIds, handleToggleWatchlist, showOnlyWatched, setShowOnlyWatched]); + pickAnimationSignal, + }), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue, setSearchQuery, setSportFilters, setHideDrafted, setHideIneligible, projectedPicks, watchedParticipantIds, handleToggleWatchlist, showOnlyWatched, setShowOnlyWatched, pickAnimationSignal]); const draftGridSectionProps = { draftSlots,