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>
557 lines
18 KiB
TypeScript
557 lines
18 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
|
|
import type { DraftIneligibilityReason } from "~/lib/draft-eligibility";
|
|
|
|
vi.mock("@tanstack/react-virtual", () => ({
|
|
useVirtualizer: ({
|
|
count,
|
|
estimateSize,
|
|
}: {
|
|
count: number;
|
|
estimateSize: (index: number) => number;
|
|
}) => {
|
|
const size = estimateSize(0);
|
|
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" } },
|
|
{ id: "2", name: "Player B", sport: { id: "s2", name: "NBA" } },
|
|
{ id: "3", name: "Player C", sport: { id: "s1", name: "NFL" } },
|
|
],
|
|
searchQuery: "",
|
|
sportFilters: [] as string[],
|
|
hideDrafted: false,
|
|
hideIneligible: false,
|
|
hideCompletedSports: false,
|
|
uniqueSports: ["NBA", "NFL"],
|
|
userDraftedSportNames: new Set<string>(),
|
|
draftedParticipantIds: new Set<string>(),
|
|
queue: [] as Array<{ id: string; participantId: string }>,
|
|
eligibility: null,
|
|
canPick: false,
|
|
hasTeam: false,
|
|
onSearchChange: vi.fn(),
|
|
onSportFiltersChange: vi.fn(),
|
|
onHideDraftedChange: vi.fn(),
|
|
onHideIneligibleChange: vi.fn(),
|
|
onHideCompletedSportsChange: vi.fn(),
|
|
onMakePick: vi.fn(),
|
|
onAddToQueue: vi.fn(),
|
|
onRemoveFromQueue: vi.fn(),
|
|
watchedParticipantIds: new Set<string>(),
|
|
onToggleWatchlist: vi.fn(),
|
|
showOnlyWatched: false,
|
|
onShowOnlyWatchedChange: vi.fn(),
|
|
pickAnimationSignal: null,
|
|
participantRanks: new Map([
|
|
["1", { overallRank: 1, sportRank: 1 }],
|
|
["2", { overallRank: 2, sportRank: 1 }],
|
|
["3", { overallRank: 3, sportRank: 2 }],
|
|
]),
|
|
};
|
|
|
|
// Both the mobile Sheet trigger and desktop Popover trigger are rendered in jsdom
|
|
// (CSS breakpoints are not applied). Click the first one to open the Sheet.
|
|
let user: ReturnType<typeof userEvent.setup>;
|
|
|
|
function createReason(
|
|
code: DraftIneligibilityReason["code"],
|
|
message: string
|
|
): DraftIneligibilityReason {
|
|
return { code, message };
|
|
}
|
|
|
|
function clickFirstSportFilterTrigger(name: string | RegExp) {
|
|
return user.click(screen.getAllByRole("button", { name })[0]);
|
|
}
|
|
|
|
describe("AvailableParticipantsSection", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
user = userEvent.setup();
|
|
});
|
|
|
|
describe("Rendering", () => {
|
|
it("renders all participants by name", () => {
|
|
render(<AvailableParticipantsSection {...defaultProps} />);
|
|
expect(screen.getAllByText("Player A").length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText("Player B").length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText("Player C").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("renders search input", () => {
|
|
render(<AvailableParticipantsSection {...defaultProps} />);
|
|
expect(
|
|
screen.getByPlaceholderText("Search participants...")
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the Show Drafted toggle", () => {
|
|
render(<AvailableParticipantsSection {...defaultProps} />);
|
|
expect(screen.getByText("Show Drafted")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders Show Ineligible and Show drafted sports toggles when hasTeam=true", async () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
hasTeam={true}
|
|
eligibility={{ eligibleSportIds: new Set(["s1"]), ineligibleReasons: {} }}
|
|
/>
|
|
);
|
|
expect(screen.getByText("Show Ineligible")).toBeInTheDocument();
|
|
// Show drafted sports lives inside the sport filter dropdown
|
|
await clickFirstSportFilterTrigger("Filter by sport: All Sports");
|
|
const dialog = screen.getByRole("dialog");
|
|
expect(within(dialog).getByText("Show drafted sports")).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not render Show Ineligible when hasTeam=false", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
hasTeam={false}
|
|
eligibility={{ eligibleSportIds: new Set(), ineligibleReasons: {} }}
|
|
/>
|
|
);
|
|
expect(screen.queryByText("Show Ineligible")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders empty state when no participants", () => {
|
|
render(
|
|
<AvailableParticipantsSection {...defaultProps} participants={[]} />
|
|
);
|
|
expect(screen.getAllByText("No participants found.").length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("Sport Filter - trigger label", () => {
|
|
it('shows "All Sports" in aria-label when no filters selected', () => {
|
|
render(<AvailableParticipantsSection {...defaultProps} />);
|
|
// Both mobile and desktop triggers share the same label
|
|
const triggers = screen.getAllByRole("button", {
|
|
name: "Filter by sport: All Sports",
|
|
});
|
|
expect(triggers.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("shows sport name in aria-label when exactly one filter selected", () => {
|
|
render(
|
|
<AvailableParticipantsSection {...defaultProps} sportFilters={["NFL"]} />
|
|
);
|
|
const triggers = screen.getAllByRole("button", {
|
|
name: "Filter by sport: NFL",
|
|
});
|
|
expect(triggers.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("shows count in aria-label when multiple filters selected", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
sportFilters={["NFL", "NBA"]}
|
|
/>
|
|
);
|
|
const triggers = screen.getAllByRole("button", {
|
|
name: "Filter by sport: 2 sports selected",
|
|
});
|
|
expect(triggers.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("does not render active-filter badges", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
sportFilters={["NFL", "NBA"]}
|
|
/>
|
|
);
|
|
expect(screen.queryByLabelText(/Remove .* filter/)).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("Projected Pick Dividers", () => {
|
|
const manyParticipants = Array.from({ length: 30 }, (_, i) => ({
|
|
id: String(i + 1),
|
|
name: `Player ${i + 1}`,
|
|
sport: { id: "s1", name: "NFL" },
|
|
}));
|
|
|
|
const manyRanks = new Map(
|
|
Array.from({ length: 30 }, (_, i) => [
|
|
String(i + 1),
|
|
{ overallRank: i + 1, sportRank: i + 1 },
|
|
])
|
|
);
|
|
|
|
it("renders projected pick divider at correct position", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
participants={manyParticipants}
|
|
participantRanks={manyRanks}
|
|
projectedPicks={[
|
|
{ round: 3, picksFromNow: 10 },
|
|
{ round: 4, picksFromNow: 20 },
|
|
]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText("Projected Round 3 Pick")).toBeInTheDocument();
|
|
expect(screen.getByText("Projected Round 4 Pick")).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not render dividers when projectedPicks is undefined", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
participants={manyParticipants}
|
|
participantRanks={manyRanks}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("does not render dividers when projectedPicks is empty", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
participants={manyParticipants}
|
|
participantRanks={manyRanks}
|
|
projectedPicks={[]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("skips dividers where picksFromNow exceeds list length", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
participants={manyParticipants}
|
|
participantRanks={manyRanks}
|
|
projectedPicks={[
|
|
{ round: 3, picksFromNow: 10 },
|
|
{ round: 10, picksFromNow: 100 },
|
|
]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText("Projected Round 3 Pick")).toBeInTheDocument();
|
|
expect(screen.queryByText("Projected Round 10 Pick")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("skips dividers where picksFromNow is zero or negative", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
participants={manyParticipants}
|
|
participantRanks={manyRanks}
|
|
projectedPicks={[
|
|
{ round: 1, picksFromNow: 0 },
|
|
{ round: 2, picksFromNow: 5 },
|
|
]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByText("Projected Round 1 Pick")).not.toBeInTheDocument();
|
|
expect(screen.getByText("Projected Round 2 Pick")).toBeInTheDocument();
|
|
});
|
|
|
|
it("still renders all participants alongside dividers", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
participants={manyParticipants}
|
|
participantRanks={manyRanks}
|
|
projectedPicks={[{ round: 3, picksFromNow: 10 }]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getAllByText("Player 1").length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText("Player 10").length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText("Player 30").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("suppresses dividers when search query is active", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
participants={manyParticipants}
|
|
participantRanks={manyRanks}
|
|
searchQuery="Player"
|
|
projectedPicks={[{ round: 3, picksFromNow: 10 }]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("suppresses dividers when sport filter is active", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
participants={manyParticipants}
|
|
participantRanks={manyRanks}
|
|
sportFilters={["NFL"]}
|
|
projectedPicks={[{ round: 3, picksFromNow: 10 }]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("suppresses dividers when hideDrafted is active", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
participants={manyParticipants}
|
|
participantRanks={manyRanks}
|
|
hideDrafted
|
|
projectedPicks={[{ round: 3, picksFromNow: 10 }]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("Sport Filter - sheet/popover", () => {
|
|
it("opens and shows checkboxes for each sport", async () => {
|
|
render(<AvailableParticipantsSection {...defaultProps} />);
|
|
await clickFirstSportFilterTrigger("Filter by sport: All Sports");
|
|
const dialog = screen.getByRole("dialog");
|
|
expect(within(dialog).getByText("NBA")).toBeInTheDocument();
|
|
expect(within(dialog).getByText("NFL")).toBeInTheDocument();
|
|
});
|
|
|
|
it("calls onSportFiltersChange with sport added when checkbox clicked", async () => {
|
|
render(<AvailableParticipantsSection {...defaultProps} />);
|
|
await clickFirstSportFilterTrigger("Filter by sport: All Sports");
|
|
const dialog = screen.getByRole("dialog");
|
|
await user.click(within(dialog).getByLabelText("NFL"));
|
|
expect(defaultProps.onSportFiltersChange).toHaveBeenCalledWith(["NFL"]);
|
|
});
|
|
|
|
it("calls onSportFiltersChange with sport removed when unchecked", async () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
sportFilters={["NFL", "NBA"]}
|
|
/>
|
|
);
|
|
await clickFirstSportFilterTrigger("Filter by sport: 2 sports selected");
|
|
const dialog = screen.getByRole("dialog");
|
|
await user.click(within(dialog).getByLabelText("NFL"));
|
|
expect(defaultProps.onSportFiltersChange).toHaveBeenCalledWith(["NBA"]);
|
|
});
|
|
|
|
it('does not show "Reset" when no filters active', async () => {
|
|
render(<AvailableParticipantsSection {...defaultProps} />);
|
|
await clickFirstSportFilterTrigger("Filter by sport: All Sports");
|
|
const dialog = screen.getByRole("dialog");
|
|
expect(within(dialog).queryByText("Reset")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('shows "Reset" button when filters exist', async () => {
|
|
render(
|
|
<AvailableParticipantsSection {...defaultProps} sportFilters={["NFL"]} />
|
|
);
|
|
await clickFirstSportFilterTrigger("Filter by sport: NFL");
|
|
const dialog = screen.getByRole("dialog");
|
|
expect(within(dialog).getByText("Reset")).toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onSportFiltersChange([]) and onHideCompletedSportsChange(false) when "Reset" clicked', async () => {
|
|
render(
|
|
<AvailableParticipantsSection {...defaultProps} sportFilters={["NFL"]} />
|
|
);
|
|
await clickFirstSportFilterTrigger("Filter by sport: NFL");
|
|
const dialog = screen.getByRole("dialog");
|
|
await user.click(within(dialog).getByText("Reset"));
|
|
expect(defaultProps.onSportFiltersChange).toHaveBeenCalledWith([]);
|
|
expect(defaultProps.onHideCompletedSportsChange).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("Popover trigger opens and can be closed", async () => {
|
|
render(<AvailableParticipantsSection {...defaultProps} />);
|
|
await clickFirstSportFilterTrigger("Filter by sport: All Sports");
|
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
|
await user.keyboard("{Escape}");
|
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("Watchlist", () => {
|
|
it("does not render watchlist toggle when hasTeam is false", () => {
|
|
render(
|
|
<AvailableParticipantsSection {...defaultProps} hasTeam={false} />
|
|
);
|
|
expect(screen.queryByTitle("Add to watchlist")).not.toBeInTheDocument();
|
|
expect(screen.queryByTitle("Remove from watchlist")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders EyeOff icon for unwatched participants when hasTeam is true", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
hasTeam={true}
|
|
watchedParticipantIds={new Set<string>()}
|
|
/>
|
|
);
|
|
const buttons = screen.getAllByTitle("Add to watchlist");
|
|
expect(buttons.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("renders EyeOff icon for watched participants", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
hasTeam={true}
|
|
watchedParticipantIds={new Set(["1"])}
|
|
/>
|
|
);
|
|
const buttons = screen.getAllByTitle("Remove from watchlist");
|
|
expect(buttons.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("calls onToggleWatchlist when eye button clicked", async () => {
|
|
const onToggleWatchlist = vi.fn();
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
hasTeam={true}
|
|
onToggleWatchlist={onToggleWatchlist}
|
|
watchedParticipantIds={new Set<string>()}
|
|
/>
|
|
);
|
|
const buttons = screen.getAllByTitle("Add to watchlist");
|
|
await user.click(buttons[0]);
|
|
expect(onToggleWatchlist).toHaveBeenCalledWith("1");
|
|
});
|
|
|
|
it("applies emerald highlight to watched participants", () => {
|
|
const { container } = render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
hasTeam={true}
|
|
watchedParticipantIds={new Set(["1"])}
|
|
/>
|
|
);
|
|
const highlighted = container.querySelector(".bg-emerald-500\\/10");
|
|
expect(highlighted).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not highlight unwatched participants", () => {
|
|
const { container } = render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
hasTeam={true}
|
|
watchedParticipantIds={new Set<string>()}
|
|
/>
|
|
);
|
|
const highlighted = container.querySelector(".bg-emerald-500\\/10");
|
|
expect(highlighted).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows Watched Only checkbox when hasTeam", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
hasTeam={true}
|
|
/>
|
|
);
|
|
expect(screen.getByText("Watched Only")).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not show Watched Only checkbox when hasTeam is false", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
hasTeam={false}
|
|
/>
|
|
);
|
|
expect(screen.queryByText("Watched Only")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("disables Watched Only checkbox when no participants are watched", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
hasTeam={true}
|
|
watchedParticipantIds={new Set<string>()}
|
|
/>
|
|
);
|
|
expect(screen.getByLabelText("Watched Only")).toBeDisabled();
|
|
});
|
|
|
|
it("calls onShowOnlyWatchedChange when Watched Only checkbox clicked", async () => {
|
|
const onShowOnlyWatchedChange = vi.fn();
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
hasTeam={true}
|
|
watchedParticipantIds={new Set(["1"])}
|
|
onShowOnlyWatchedChange={onShowOnlyWatchedChange}
|
|
/>
|
|
);
|
|
await user.click(screen.getByLabelText("Watched Only"));
|
|
expect(onShowOnlyWatchedChange).toHaveBeenCalledWith(true);
|
|
});
|
|
});
|
|
|
|
describe("Ineligible reasons", () => {
|
|
it("renders inline out-of-flex-spots copy for ineligible participants", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
hasTeam={true}
|
|
canPick={true}
|
|
eligibility={{
|
|
eligibleSportIds: new Set(["s2"]),
|
|
ineligibleReasons: {
|
|
s1: createReason("no_flex_spots", "Out of flex spots (2/2)"),
|
|
},
|
|
}}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getAllByText("Out of flex spots (2/2)").length).toBeGreaterThan(0);
|
|
expect(screen.getAllByTitle("Out of flex spots (2/2)").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("renders inline no-extras copy for ineligible participants", () => {
|
|
render(
|
|
<AvailableParticipantsSection
|
|
{...defaultProps}
|
|
hasTeam={true}
|
|
canPick={true}
|
|
eligibility={{
|
|
eligibleSportIds: new Set(["s2"]),
|
|
ineligibleReasons: {
|
|
s1: createReason("no_extra_participants", "Not enough NFL participants for remaining teams"),
|
|
},
|
|
}}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getAllByText("Not enough NFL participants for remaining teams").length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|