Create draft cell component.

This commit is contained in:
Chris Parsons 2026-04-16 20:46:00 -07:00
parent 266502eeec
commit f17d2a3250
5 changed files with 279 additions and 81 deletions

View file

@ -1,10 +1,10 @@
import { Card } from "~/components/ui/card";
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
ContextMenuItem, ContextMenuItem,
ContextMenuTrigger, ContextMenuTrigger,
} from "~/components/ui/context-menu"; } from "~/components/ui/context-menu";
import { DraftPickCell } from "~/components/draft/DraftPickCell";
interface DraftCell { interface DraftCell {
pickNumber: number; pickNumber: number;
@ -52,7 +52,7 @@ export function DraftGrid({
const totalTeams = draftSlots.length; const totalTeams = draftSlots.length;
return ( return (
<Card className="p-4"> <div className="p-4">
{title && <h2 className="text-xl font-semibold mb-4">{title}</h2>} {title && <h2 className="text-xl font-semibold mb-4">{title}</h2>}
<div className="w-full overflow-x-auto"> <div className="w-full overflow-x-auto">
<div className="min-w-max"> <div className="min-w-max">
@ -108,43 +108,30 @@ export function DraftGrid({
const actualIndex = isEvenRound const actualIndex = isEvenRound
? roundPicks.length - 1 - index ? roundPicks.length - 1 - index
: index; : index;
const slot = draftSlots[actualIndex];
const pickNumber = roundIndex * totalTeams + actualIndex + 1; const pickNumber = roundIndex * totalTeams + actualIndex + 1;
const isCurrent = pickNumber === currentPick; const isCurrent = pickNumber === currentPick;
const isPicked = !!cell; const isPicked = !!cell;
const cellContent = ( const cellContent = (
<div <DraftPickCell
className={`flex-1 min-w-0 h-20 border-2 rounded-lg p-2 transition-all ${ key={pickNumber}
isPicked pickNumber={pickNumber}
? "bg-emerald-500/10 border-emerald-500/30" round={round}
: isCurrent pickInRound={actualIndex + 1}
? "border-electric bg-electric/15 shadow-lg shadow-electric/10" state={
: "border-border bg-card" isPicked ? "picked" : isCurrent ? "current" : "upcoming"
}`} }
title={`Overall Pick #${pickNumber}`} pick={
> cell
<div className="text-xs font-mono text-muted-foreground mb-1"> ? {
{round}.{String(actualIndex + 1).padStart(2, "0")} participant: { name: cell.participant.name },
</div> sport: { name: cell.sport.name },
{isPicked ? ( }
<div className="text-xs"> : undefined
<div className="font-semibold truncate"> }
{cell.participant.name} />
</div>
<div className="text-muted-foreground truncate">
{cell.sport.name}
</div>
</div>
) : isCurrent ? (
<div className="text-xs font-semibold text-electric">
On Clock
</div>
) : null}
</div>
); );
// Wrap with context menu if commissioner and current unpicked cell
if ( if (
isCommissioner && isCommissioner &&
!isPicked && !isPicked &&
@ -152,6 +139,7 @@ export function DraftGrid({
onForceAutopick && onForceAutopick &&
onForceManualPick onForceManualPick
) { ) {
const slot = draftSlots[actualIndex];
return ( return (
<ContextMenu key={pickNumber}> <ContextMenu key={pickNumber}>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
@ -185,6 +173,6 @@ export function DraftGrid({
</div> </div>
</div> </div>
</div> </div>
</Card> </div>
); );
} }

View file

@ -90,7 +90,7 @@ describe('DraftGrid Component', () => {
const currentPickCell = screen.getByText('On Clock').parentElement; const currentPickCell = screen.getByText('On Clock').parentElement;
expect(currentPickCell).toHaveClass('border-electric'); expect(currentPickCell).toHaveClass('border-electric');
expect(currentPickCell).toHaveClass('bg-electric/15'); expect(currentPickCell).toHaveClass('bg-electric/20');
}); });
}); });

View file

@ -14,6 +14,7 @@ import {
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { MoreVertical } from "lucide-react"; import { MoreVertical } from "lucide-react";
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer"; import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
import { DraftPickCell } from "~/components/draft/DraftPickCell";
type MobileSheetData = type MobileSheetData =
| { type: "team"; teamId: string } | { type: "team"; teamId: string }
@ -180,37 +181,14 @@ export const DraftGridSection = memo(function DraftGridSection({
{displayPicks.map((cell) => { {displayPicks.map((cell) => {
const isCurrent = cell.pickNumber === currentPick; const isCurrent = cell.pickNumber === currentPick;
const isPicked = !!cell.pick; const isPicked = !!cell.pick;
const cellState = isPicked ? "picked" : isCurrent ? "current" : "upcoming";
const pickData = cell.pick
? { participant: { name: cell.pick.participant.name }, sport: { name: cell.pick.sport.name } }
: undefined;
const cellClassName = `relative flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${ const mobileButtons = isCommissioner && (
isCurrent
? "border-electric bg-electric/20 shadow-lg shadow-electric/25 ring-1 ring-electric/40"
: isPicked
? "bg-emerald-500/10 border-emerald-500/30"
: "border-border bg-card"
}`;
const cellInner = (
<> <>
<div className="text-xs font-mono text-muted-foreground mb-1"> {!isPicked && isCurrent && (
{cell.round}.
{String(cell.pickInRound).padStart(2, "0")}
</div>
{isPicked ? (
<div className="text-xs">
<div className="font-semibold truncate">
{cell.pick?.participant.name}
</div>
<div className="text-muted-foreground truncate">
{cell.pick?.sport.name}
</div>
</div>
) : isCurrent ? (
<div className="text-sm font-bold text-electric animate-pulse">
On Clock
</div>
) : null}
{/* Mobile commissioner button */}
{isCommissioner && !isPicked && isCurrent && (
<button <button
className="absolute top-1 right-1 md:hidden p-1 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-muted" className="absolute top-1 right-1 md:hidden p-1 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-muted"
onClick={() => setMobileSheet({ type: "current-cell", pickNumber: cell.pickNumber, teamId: cell.teamId })} onClick={() => setMobileSheet({ type: "current-cell", pickNumber: cell.pickNumber, teamId: cell.teamId })}
@ -218,7 +196,7 @@ export const DraftGridSection = memo(function DraftGridSection({
<MoreVertical className="h-3.5 w-3.5" /> <MoreVertical className="h-3.5 w-3.5" />
</button> </button>
)} )}
{isCommissioner && isPicked && (onReplacePick || onRollbackToPick) && ( {isPicked && (onReplacePick || onRollbackToPick) && (
<button <button
className="absolute top-1 right-1 md:hidden p-1 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-muted" className="absolute top-1 right-1 md:hidden p-1 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-muted"
onClick={() => setMobileSheet({ type: "picked-cell", pickNumber: cell.pickNumber, teamId: cell.teamId })} onClick={() => setMobileSheet({ type: "picked-cell", pickNumber: cell.pickNumber, teamId: cell.teamId })}
@ -229,17 +207,19 @@ export const DraftGridSection = memo(function DraftGridSection({
</> </>
); );
// Commissioner context menu on the current unpicked cell
if (isCommissioner && !isPicked && isCurrent) { if (isCommissioner && !isPicked && isCurrent) {
return ( return (
<ContextMenu key={cell.pickNumber}> <ContextMenu key={cell.pickNumber}>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div <DraftPickCell
className={cellClassName} pickNumber={cell.pickNumber}
title={`Overall Pick #${cell.pickNumber}`} round={cell.round}
pickInRound={cell.pickInRound}
state={cellState}
pick={pickData}
> >
{cellInner} {mobileButtons}
</div> </DraftPickCell>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<ContextMenuItem <ContextMenuItem
@ -264,17 +244,19 @@ export const DraftGridSection = memo(function DraftGridSection({
); );
} }
// Commissioner context menu on already-picked cells
if (isCommissioner && isPicked && (onReplacePick || onRollbackToPick)) { if (isCommissioner && isPicked && (onReplacePick || onRollbackToPick)) {
return ( return (
<ContextMenu key={cell.pickNumber}> <ContextMenu key={cell.pickNumber}>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div <DraftPickCell
className={cellClassName} pickNumber={cell.pickNumber}
title={`Overall Pick #${cell.pickNumber}`} round={cell.round}
pickInRound={cell.pickInRound}
state={cellState}
pick={pickData}
> >
{cellInner} {mobileButtons}
</div> </DraftPickCell>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
{onReplacePick && ( {onReplacePick && (
@ -300,13 +282,16 @@ export const DraftGridSection = memo(function DraftGridSection({
} }
return ( return (
<div <DraftPickCell
key={cell.pickNumber} key={cell.pickNumber}
className={cellClassName} pickNumber={cell.pickNumber}
title={`Overall Pick #${cell.pickNumber}`} round={cell.round}
pickInRound={cell.pickInRound}
state={cellState}
pick={pickData}
> >
{cellInner} {mobileButtons}
</div> </DraftPickCell>
); );
})} })}
</div> </div>

View file

@ -0,0 +1,136 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DraftPickCell } from "./DraftPickCell";
const meta: Meta<typeof DraftPickCell> = {
title: "Draft/DraftPickCell",
component: DraftPickCell,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof DraftPickCell>;
const basePickArgs = {
pickNumber: 1,
round: 1,
pickInRound: 1,
state: "picked" as const,
pick: {
participant: { name: "LeBron James" },
sport: { name: "Basketball" },
},
};
export const Upcoming: Story = {
args: {
pickNumber: 5,
round: 1,
pickInRound: 5,
state: "upcoming",
},
};
export const Current: Story = {
args: {
pickNumber: 5,
round: 1,
pickInRound: 5,
state: "current",
},
};
export const Picked: Story = {
args: basePickArgs,
};
export const PickedElectricCorona: Story = {
args: {
...basePickArgs,
corona: "electric",
},
};
export const PickedEmeraldCorona: Story = {
args: {
...basePickArgs,
corona: "emerald",
},
};
export const PickedAmberCorona: Story = {
args: {
...basePickArgs,
corona: "amber",
},
};
export const PickedCoralCorona: Story = {
args: {
...basePickArgs,
corona: "coral",
},
};
export const PickedLongNames: Story = {
name: "Picked — Long Names (truncation)",
args: {
pickNumber: 12,
round: 2,
pickInRound: 6,
state: "picked",
pick: {
participant: { name: "Alexander Ovechkin-Washington" },
sport: { name: "Ice Hockey — NHL 2025/26" },
},
},
};
export const AllStatesSideBySide: Story = {
name: "All States — Side by Side",
render: () => (
<div className="flex gap-2 items-stretch">
<DraftPickCell
pickNumber={1}
round={1}
pickInRound={1}
state="picked"
pick={basePickArgs.pick}
/>
<DraftPickCell
pickNumber={2}
round={1}
pickInRound={2}
state="current"
/>
<DraftPickCell
pickNumber={3}
round={1}
pickInRound={3}
state="upcoming"
/>
</div>
),
};
export const CoronaVariantsRow: Story = {
name: "Corona Variants — Side by Side",
render: () => (
<div className="flex gap-2 items-stretch">
{(
["gradient", "electric", "emerald", "amber", "coral"] as const
).map((variant, i) => (
<DraftPickCell
key={variant}
pickNumber={i + 1}
round={1}
pickInRound={i + 1}
state="picked"
pick={basePickArgs.pick}
corona={variant}
/>
))}
</div>
),
};

View file

@ -0,0 +1,89 @@
import type { ReactNode } from "react";
import { cn } from "~/lib/utils";
type CoronaVariant =
| "gradient"
| "electric"
| "emerald"
| "amber"
| "coral";
interface DraftPickCellProps {
pickNumber: number;
round: number;
pickInRound: number;
state: "picked" | "current" | "upcoming";
pick?: {
participant: { name: string };
sport: { name: string };
};
corona?: CoronaVariant;
className?: string;
children?: ReactNode;
}
const coronaClasses: Record<CoronaVariant, string> = {
gradient: "[background:linear-gradient(to_bottom,#adf661,#2ce1c1)]",
electric: "bg-electric",
emerald: "bg-emerald-500",
amber: "bg-amber-accent",
coral: "bg-coral-accent",
};
export function DraftPickCell({
pickNumber,
round,
pickInRound,
state,
pick,
corona = "gradient",
className,
children,
}: DraftPickCellProps) {
const showCorona = state === "picked";
return (
<div
className={cn(
"relative flex-1 min-w-32 overflow-hidden",
className,
)}
>
{showCorona && (
<div
className={cn(
"absolute top-0.5 bottom-0.5 left-0 right-0 rounded-lg",
coronaClasses[corona],
)}
/>
)}
<div
className={cn(
"relative z-10 h-20 border-2 rounded-lg p-2 transition-all",
showCorona && "mr-1",
state === "current"
? "border-electric bg-electric/20 shadow-lg shadow-electric/30 ring-2 ring-electric/50 animate-pulse"
: state === "picked"
? "bg-muted border-transparent"
: "border-border bg-card",
)}
title={`Overall Pick #${pickNumber}`}
>
<div className="text-xs font-mono text-muted-foreground mb-1">
{round}.{String(pickInRound).padStart(2, "0")}
</div>
{state === "picked" && pick ? (
<div className="text-xs">
<div className="font-semibold truncate">{pick.participant.name}</div>
<div className="text-muted-foreground truncate">
{pick.sport.name}
</div>
</div>
) : state === "current" ? (
<div className="text-sm font-bold text-electric">On Clock</div>
) : null}
{children && <div className="absolute inset-0 pointer-events-none [&_*]:pointer-events-auto">{children}</div>}
</div>
</div>
);
}