brackt/app/components/draft/MiniDraftGrid.tsx
Chris Parsons 1bf65e33f7
Add commish right-click context menus to MiniDraftGrid team headers (#327)
* Add commish right-click context menus to MiniDraftGrid team headers

Adds onAdjustTimeBankOpen and onSetAutodraftOpen props to MiniDraftGrid
so the last-two-rounds display on the Participants tab shows the same
"Adjust Time Bank…" / "Set Autodraft…" context menu on right-click that
the full Draft Board already exposes. Also passes those callbacks from
the route's miniDraftGrid useMemo so they are wired up for commissioners.

https://claude.ai/code/session_017JCShLVs9xZ6FZmyrUFaE1

* Address all code review issues for draft room context menus

Layout bug: flex-1 min-w-20 was nested inside headerInner instead of the
direct flex-child wrapper in MiniDraftGrid, breaking equal column sizing.
Fixed by moving the classes to the outermost element in both the menu and
non-menu paths (matching DraftGridSection's pattern).

MiniDraftGrid improvements:
- Hoist hasHeaderMenu constant above the draftSlots.map() loop
- Add optional connectedTeams prop; disconnected teams render italic +
  muted-foreground, consistent with DraftGridSection
- connectedTeams now wired through the route's miniDraftGrid useMemo

DraftGridSection interface:
- Make onForceAutopick and onForceManualPickOpen optional (?) to match
  every other commissioner callback; add null checks at all three call
  sites (context menu, mobile MoreVertical button, mobile Sheet)
- Gate those two callbacks behind isCommissioner at the route level

Tests (new files):
- app/components/__tests__/MiniDraftGrid.test.tsx — covers layout classes,
  connected/disconnected styling, and all four context menu interactions
- app/components/__tests__/DraftGridSection.test.tsx — covers all three
  context menu surfaces for both commissioner and non-commissioner roles

Storybook: add CommissionerView and DisconnectedTeams stories to
MiniDraftGrid.stories.tsx

https://claude.ai/code/session_017JCShLVs9xZ6FZmyrUFaE1

* Move hasHeaderMenu out of IIFE into component body

Computing it before the return statement is the right place since it only
depends on props, not loop variables. Removes the IIFE entirely and fixes
the indentation of the map callback body.

https://claude.ai/code/session_017JCShLVs9xZ6FZmyrUFaE1

* Mock HTMLElement.scrollTo in test setup

jsdom does not implement scrollTo on elements, causing any component that
calls element.scrollTo() (e.g. MiniDraftGrid's scroll-to-current-cell
effect) to throw in unit tests.

https://claude.ai/code/session_017JCShLVs9xZ6FZmyrUFaE1

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-25 07:42:43 -07:00

331 lines
13 KiB
TypeScript

import { memo, useState, useEffect, useRef } from "react";
import { DraftPickCell } from "~/components/draft/DraftPickCell";
import type { SeasonStatus } from "~/models/season";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "~/components/ui/context-menu";
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
const ROW_GAP = 6; // gap-1.5
const ANIMATION_MS = 300;
function getRoundIndices(round: number): [number, number] {
if (round <= 2) return [0, 1];
return [round - 2, round - 1];
}
export interface MiniDraftGridProps {
draftSlots: Array<{
id: string;
draftOrder: number;
team: {
id: string;
name: string;
logoUrl?: string | null;
};
}>;
draftGrid: Array<
Array<{
pickNumber: number;
round: number;
pickInRound: number;
teamId: string;
pick?: {
participant: {
name: string;
};
sport: {
name: string;
};
};
}>
>;
currentPick: number;
currentRound: number;
ownerMap?: Record<string, string>;
teamTimers?: Record<string, number | undefined>;
autodraftStatus?: Record<string, { isEnabled: boolean }>;
connectedTeams?: Set<string>;
seasonStatus?: SeasonStatus;
draftPaused?: boolean;
onAdjustTimeBankOpen?: (teamId: string) => void;
onSetAutodraftOpen?: (teamId: string) => void;
onForceAutopick?: (pickNumber: number, teamId: string) => void;
onForceManualPickOpen?: (pickNumber: number, teamId: string) => void;
onReplacePick?: (pickNumber: number, teamId: string) => void;
onRollbackToPick?: (pickNumber: number) => void;
}
export const MiniDraftGrid = memo(function MiniDraftGrid({
draftSlots,
draftGrid,
currentPick,
currentRound,
ownerMap = {},
teamTimers = {},
autodraftStatus = {},
connectedTeams,
seasonStatus,
draftPaused,
onAdjustTimeBankOpen,
onSetAutodraftOpen,
onForceAutopick,
onForceManualPickOpen,
onReplacePick,
onRollbackToPick,
}: MiniDraftGridProps) {
const [displayedIndices, setDisplayedIndices] = useState<[number, number]>(() => getRoundIndices(currentRound));
const [extraIndex, setExtraIndex] = useState<number | null>(null);
const [sliding, setSliding] = useState(false);
const animGenRef = useRef(0);
const prevRoundRef = useRef(currentRound);
const firstRowRef = useRef<HTMLDivElement>(null);
const [rowHeight, setRowHeight] = useState(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const currentCellRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (firstRowRef.current) {
setRowHeight(firstRowRef.current.getBoundingClientRect().height);
}
}, [draftGrid]);
useEffect(() => {
const container = scrollContainerRef.current;
const cell = currentCellRef.current;
if (!container || !cell) return;
const containerRect = container.getBoundingClientRect();
const cellRect = cell.getBoundingClientRect();
const scrollLeft = container.scrollLeft + cellRect.left - containerRect.left - (containerRect.width - cellRect.width) / 2;
container.scrollTo({ left: Math.max(0, scrollLeft), behavior: "smooth" });
}, [currentPick]);
useEffect(() => {
const prev = prevRoundRef.current;
prevRoundRef.current = currentRound;
const newIndices = getRoundIndices(currentRound);
const prevIndices = getRoundIndices(prev);
if (newIndices[0] === prevIndices[0]) return;
// Non-sequential jump or animation already running: snap and invalidate any in-flight animation
if (newIndices[0] !== prevIndices[0] + 1 || animGenRef.current > 0) {
animGenRef.current++; // invalidate any in-flight animation closure
setDisplayedIndices(newIndices);
setExtraIndex(null);
setSliding(false);
return;
}
const gen = ++animGenRef.current;
setExtraIndex(newIndices[1]);
// Two rAFs: first lets React flush the extra row to DOM, second starts the CSS transition
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setSliding(true);
setTimeout(() => {
if (animGenRef.current !== gen) return; // interrupted by snap
setDisplayedIndices(newIndices);
setExtraIndex(null);
setSliding(false);
animGenRef.current = 0;
}, ANIMATION_MS + 20);
});
});
}, [currentRound]);
if (draftGrid.length === 0) return null;
const hasHeaderMenu = !!(onAdjustTimeBankOpen || onSetAutodraftOpen);
// h-14 (56px) + gap-1.5 (6px) fallback until first measurement
const effectiveRowHeight = rowHeight > 0 ? rowHeight : 56;
const roundIndicesToRender: number[] = extraIndex !== null
? [displayedIndices[0], displayedIndices[1], extraIndex]
: [displayedIndices[0], displayedIndices[1]];
return (
<div ref={scrollContainerRef} className="overflow-x-auto">
<div className="inline-block min-w-full">
{/* Team header */}
<div className="flex gap-1.5 mb-1.5">
{draftSlots.map((slot) => {
const teamTime = teamTimers[slot.team.id];
const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled ?? false;
const isConnected = connectedTeams ? connectedTeams.has(slot.team.id) : true;
const headerContent = (
<>
<div className="text-xs font-medium truncate px-1 flex items-center justify-center gap-0.5">
{isAutodraft && (
<span className="inline-flex items-center justify-center h-3.5 w-3.5 text-[9px] font-bold text-white bg-black shrink-0">A</span>
)}
<span className={`truncate${!isConnected ? " italic text-muted-foreground" : ""}`}>
{ownerMap[slot.team.id] || slot.team.name}
</span>
</div>
<div className={`text-xs font-mono px-1 ${getTimerColorClass(teamTime)}`}>
{formatClockTime(teamTime)}
</div>
</>
);
if (hasHeaderMenu) {
return (
<ContextMenu key={slot.id}>
<ContextMenuTrigger asChild>
<div className="flex-1 min-w-20 text-center cursor-context-menu">
{headerContent}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{onAdjustTimeBankOpen && (
<ContextMenuItem onClick={() => onAdjustTimeBankOpen(slot.team.id)}>
Adjust Time Bank...
</ContextMenuItem>
)}
{onSetAutodraftOpen && (
<ContextMenuItem onClick={() => onSetAutodraftOpen(slot.team.id)}>
Set Autodraft...
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
}
return (
<div key={slot.id} className="flex-1 min-w-20 text-center">
{headerContent}
</div>
);
})}
</div>
{/* Rows — clipped to 2-row height, slides to reveal new round */}
<div style={{ height: `${effectiveRowHeight * 2 + ROW_GAP}px`, overflow: "hidden" }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${ROW_GAP}px`,
transform: sliding ? `translateY(-${effectiveRowHeight + ROW_GAP}px)` : "translateY(0)",
transition: sliding ? `transform ${ANIMATION_MS}ms ease-in-out` : "none",
}}
>
{roundIndicesToRender.map((roundIndex) => {
if (roundIndex >= draftGrid.length) return null;
const roundPicks = draftGrid[roundIndex];
const round = roundIndex + 1;
const isEvenRound = round % 2 === 0;
const displayPicks = isEvenRound ? [...roundPicks].toReversed() : roundPicks;
return (
<div key={round} ref={roundIndex === displayedIndices[0] ? firstRowRef : undefined} className="flex gap-1.5 items-stretch flex-shrink-0">
{displayPicks.map((cell) => {
const isCurrent = cell.pickNumber === currentPick;
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;
if (!isPicked && isCurrent && (onForceAutopick || onForceManualPickOpen)) {
return (
<ContextMenu key={cell.pickNumber}>
<ContextMenuTrigger asChild>
<DraftPickCell
ref={isCurrent ? currentCellRef : undefined}
pickNumber={cell.pickNumber}
round={cell.round}
pickInRound={cell.pickInRound}
state={cellState}
pick={pickData}
seasonStatus={seasonStatus}
draftPaused={draftPaused}
className="cursor-context-menu"
/>
</ContextMenuTrigger>
<ContextMenuContent>
{onForceAutopick && (
<ContextMenuItem onClick={() => onForceAutopick(cell.pickNumber, cell.teamId)}>
Force Auto Pick
</ContextMenuItem>
)}
{onForceManualPickOpen && (
<ContextMenuItem onClick={() => onForceManualPickOpen(cell.pickNumber, cell.teamId)}>
Force Manual Pick
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
}
if (isPicked && (onReplacePick || onRollbackToPick)) {
return (
<ContextMenu key={cell.pickNumber}>
<ContextMenuTrigger asChild>
<DraftPickCell
pickNumber={cell.pickNumber}
round={cell.round}
pickInRound={cell.pickInRound}
state={cellState}
pick={pickData}
seasonStatus={seasonStatus}
draftPaused={draftPaused}
className="cursor-context-menu"
/>
</ContextMenuTrigger>
<ContextMenuContent>
{onReplacePick && (
<ContextMenuItem onClick={() => onReplacePick(cell.pickNumber, cell.teamId)}>
Replace Pick
</ContextMenuItem>
)}
{onRollbackToPick && (
<ContextMenuItem
onClick={() => onRollbackToPick(cell.pickNumber)}
className="text-destructive focus:text-destructive"
>
Roll Back to This Pick
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
}
return (
<DraftPickCell
key={cell.pickNumber}
ref={isCurrent ? currentCellRef : undefined}
pickNumber={cell.pickNumber}
round={cell.round}
pickInRound={cell.pickInRound}
state={cellState}
pick={pickData}
seasonStatus={seasonStatus}
draftPaused={draftPaused}
/>
);
})}
</div>
);
})}
</div>
</div>
</div>
</div>
);
});