When a pick is made the picked player's row flashes teal (--electric)
then fades out over 750ms. Works correctly with the TanStack Virtual
list by keeping the row in filteredParticipants during the animation
via animatingOutParticipantIds, then removing it after 650ms.
Key design decisions:
- Animation state lives in AvailableParticipantsSection (internal
useState + timers); the draft room passes a pickAnimationSignal
{id, seq} rather than the Set, so memo only breaks on each pick,
not on the 650ms cleanup
- displayDrafted freezes row content (badges, buttons) during the
animation to prevent height shifts
- Per-ID independent timers via Map ref support rapid autodraft picks
running concurrently without cancellation
- Timers are cleared on unmount in both the draft room and component
- prefers-reduced-motion: skips the color flash, plain opacity fade only
- Removes "Draft Grid" heading from DraftGridSection (visual cleanup)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
02fa285585
commit
190a6e1fe7
6 changed files with 119 additions and 27 deletions
35
app/app.css
35
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;
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
|
|
|
|||
|
|
@ -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<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]
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
|
|
@ -514,10 +542,11 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
right: 0,
|
||||
}}
|
||||
>
|
||||
<div className="md:hidden px-4 py-1">
|
||||
<div
|
||||
<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 ${
|
||||
isDrafted
|
||||
displayDrafted
|
||||
? "bg-muted/50 opacity-60"
|
||||
: !isEligible
|
||||
? "bg-destructive/10 opacity-75"
|
||||
|
|
@ -528,7 +557,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span
|
||||
className={`font-semibold text-sm truncate ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
|
||||
className={`font-semibold text-sm truncate ${!isEligible && !displayDrafted ? "text-muted-foreground" : ""}`}
|
||||
>
|
||||
{participant.name}
|
||||
</span>
|
||||
|
|
@ -539,12 +568,12 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
OVR {rank?.overallRank} · SPR {rank?.sportRank}
|
||||
</span>
|
||||
{isDrafted && (
|
||||
{displayDrafted && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Drafted
|
||||
</Badge>
|
||||
)}
|
||||
{!isDrafted && !isEligible && (
|
||||
{!displayDrafted && !isEligible && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
|
|
@ -553,19 +582,19 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
Ineligible
|
||||
</Badge>
|
||||
)}
|
||||
{!isDrafted && !isEligible && ineligibleReason && (
|
||||
{!displayDrafted && !isEligible && ineligibleReason && (
|
||||
<p className="text-xs text-destructive/90">
|
||||
{ineligibleReason.message}
|
||||
</p>
|
||||
)}
|
||||
{isInQueue && !isDrafted && isEligible && (
|
||||
{isInQueue && !displayDrafted && isEligible && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Queued
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasTeam && !isDrafted && (
|
||||
{hasTeam && !displayDrafted && (
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -622,13 +651,13 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>{/* md:hidden */}
|
||||
|
||||
<div
|
||||
className={`hidden md:grid ${desktopGridClass} items-center px-4 transition-colors ${
|
||||
<div
|
||||
className={`hidden md:grid ${desktopGridClass} items-center px-4 transition-colors ${
|
||||
followsDivider ? "" : "border-t"
|
||||
} ${
|
||||
isDrafted
|
||||
displayDrafted
|
||||
? "bg-muted/50 opacity-60"
|
||||
: !isEligible
|
||||
? "bg-destructive/10 opacity-75"
|
||||
|
|
@ -637,7 +666,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
title={
|
||||
!isEligible && !isDrafted ? ineligibleReason?.message : undefined
|
||||
!isEligible && !displayDrafted ? ineligibleReason?.message : undefined
|
||||
}
|
||||
>
|
||||
<span className="text-center text-muted-foreground tabular-nums p-3 px-0">
|
||||
|
|
@ -649,19 +678,19 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
<div className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`font-medium ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
|
||||
className={`font-medium ${!isEligible && !displayDrafted ? "text-muted-foreground" : ""}`}
|
||||
>
|
||||
{participant.name}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{participant.sport.name}
|
||||
</Badge>
|
||||
{isDrafted && (
|
||||
{displayDrafted && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Drafted
|
||||
</Badge>
|
||||
)}
|
||||
{!isDrafted && !isEligible && (
|
||||
{!displayDrafted && !isEligible && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
|
|
@ -670,12 +699,12 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
Ineligible
|
||||
</Badge>
|
||||
)}
|
||||
{isInQueue && !isDrafted && isEligible && (
|
||||
{isInQueue && !displayDrafted && isEligible && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Queued
|
||||
</Badge>
|
||||
)}
|
||||
{!isDrafted && !isEligible && ineligibleReason && (
|
||||
{!displayDrafted && !isEligible && ineligibleReason && (
|
||||
<p className="mt-1 text-xs text-destructive/90">
|
||||
{ineligibleReason.message}
|
||||
</p>
|
||||
|
|
@ -685,7 +714,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
{hasTeam && (
|
||||
<div className="p-3">
|
||||
<div className="flex gap-2 justify-end items-center">
|
||||
{!isDrafted && (
|
||||
{!displayDrafted && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -744,7 +773,8 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>{/* hidden md:grid */}
|
||||
</div>{/* animationClass wrapper */}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,6 @@ export const DraftGridSection = memo(function DraftGridSection({
|
|||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-4">
|
||||
<h2 className="text-xl font-semibold mb-4 flex-shrink-0">Draft Grid</h2>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="inline-block min-w-full min-h-full">
|
||||
{/* Team Headers */}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ interface UseDraftSocketEventsParams {
|
|||
setRoomClosed: (value: boolean) => void;
|
||||
setIsSyncing: (value: boolean) => void;
|
||||
setBracktVorps: (fn: (prev: Map<string, number>) => Map<string, number>) => 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;
|
||||
|
|
|
|||
|
|
@ -468,6 +468,26 @@ export default function DraftRoom() {
|
|||
);
|
||||
}, [replacePickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]);
|
||||
|
||||
const [animatingOutParticipantIds, setAnimatingOutParticipantIds] = useState<Set<string>>(new Set());
|
||||
const [pickAnimationSignal, setPickAnimationSignal] = useState<{ id: string; seq: number } | null>(null);
|
||||
const pickSeqRef = useRef(0);
|
||||
const animationTimersRef = useRef<Set<ReturnType<typeof setTimeout>>>(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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue