diff --git a/app/routes/$orgSlug/+components/pr-block.tsx b/app/routes/$orgSlug/+components/pr-block.tsx index 5310b371..2c168100 100644 --- a/app/routes/$orgSlug/+components/pr-block.tsx +++ b/app/routes/$orgSlug/+components/pr-block.tsx @@ -183,6 +183,7 @@ export function PRBlock({ showAuthor, onMouseEnter, onMouseLeave, + onClick, dataPrKey, }: { pr: PRBlockData @@ -190,6 +191,7 @@ export function PRBlock({ showAuthor?: boolean onMouseEnter?: (e: React.MouseEvent) => void onMouseLeave?: () => void + onClick?: (e: React.MouseEvent) => void dataPrKey?: string }) { const { bg, ring, bgFaint } = getBlockColor(pr, colorMode) @@ -204,6 +206,7 @@ export function PRBlock({ aria-label={`${pr.repo}#${pr.number}`} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} + onClick={onClick} /> diff --git a/app/routes/$orgSlug/workload/+components/team-stacks-chart.tsx b/app/routes/$orgSlug/workload/+components/team-stacks-chart.tsx index 204d00df..b6894934 100644 --- a/app/routes/$orgSlug/workload/+components/team-stacks-chart.tsx +++ b/app/routes/$orgSlug/workload/+components/team-stacks-chart.tsx @@ -54,13 +54,26 @@ function sortByAge(prs: StackPR[]): StackPR[] { interface HoveredInfo { prKey: string author: string - sourceEl: HTMLElement } const HoveredContext = createContext(null) const SetHoveredContext = createContext<(info: HoveredInfo | null) => void>( () => {}, ) +interface SelectedInfo { + prKey: string + author: string + // Incremented on every click so re-clicking the same PR re-triggers scroll. + tick: number +} +const SelectedContext = createContext(null) +const SetSelectedContext = createContext< + ( + e: React.MouseEvent, + prKey: string, + author: string, + ) => void +>(() => {}) const ColorModeContext = createContext('age') function sortPRs(prs: StackPR[], mode: ColorMode): StackPR[] { @@ -75,22 +88,24 @@ function sortPRs(prs: StackPR[], mode: ColorMode): StackPR[] { // --- Scroll helper --- // Scrolls only the column's overflow-y-auto container, never parent/page. -// Skips scrolling if the hovered element is in the same column (already visible). -const HoverSourceColumnContext = createContext(null) +// Skips scrolling if the click originated in the same column (already visible). +const SelectedSourceColumnContext = createContext(null) function useScrollIntoColumn( ref: React.RefObject, active: boolean, + tick: number, ) { - const hoverSourceColumn = useContext(HoverSourceColumnContext) + const sourceColumn = useContext(SelectedSourceColumnContext) + // biome-ignore lint/correctness/useExhaustiveDependencies: tick is intentional — re-clicking the same PR must re-run the effect. useEffect(() => { const row = ref.current if (!active || !row) return const container = row.closest('.overflow-y-auto') as HTMLElement | null if (!container) return - // Don't scroll if the hover originated from the same column - if (hoverSourceColumn === container) return + // Don't scroll if the click originated from the same column + if (sourceColumn === container) return const rafId = requestAnimationFrame(() => { const cRect = container.getBoundingClientRect() const rRect = row.getBoundingClientRect() @@ -107,7 +122,7 @@ function useScrollIntoColumn( } }) return () => cancelAnimationFrame(rafId) - }, [ref, active, hoverSourceColumn]) + }, [ref, active, sourceColumn, tick]) } // --- Components --- @@ -152,6 +167,7 @@ function StackRow({ }) { const colorMode = useContext(ColorModeContext) const hovered = useContext(HoveredContext) + const selected = useContext(SelectedContext) const isOver = stack.prs.length > personalLimit const sortedPRs = useMemo( () => sortPRs(stack.prs, colorMode), @@ -165,7 +181,12 @@ function StackRow({ (stack.login === hovered.author || stack.prs.some((p) => `${p.repo}:${p.number}` === hovered.prKey)) - useScrollIntoColumn(rowRef, isRelated) + const isSelectedRelated = + selected !== null && + (stack.login === selected.author || + stack.prs.some((p) => `${p.repo}:${p.number}` === selected.prKey)) + + useScrollIntoColumn(rowRef, isSelectedRelated, selected?.tick ?? 0) return (
- setHovered({ - prKey, - author: pr.author, - sourceEl: e.currentTarget, - }) - } + onMouseEnter={() => setHovered({ prKey, author: pr.author })} onMouseLeave={() => setHovered(null)} + onClick={(e) => setSelected(e, prKey, pr.author)} /> ) @@ -294,6 +311,7 @@ function StackColumn({ function UnassignedRows({ prs }: { prs: StackPR[] }) { const colorMode = useContext(ColorModeContext) const hovered = useContext(HoveredContext) + const selected = useContext(SelectedContext) const sortedPRs = useMemo(() => sortPRs(prs, colorMode), [prs, colorMode]) const rowRef = useRef(null) @@ -301,7 +319,11 @@ function UnassignedRows({ prs }: { prs: StackPR[] }) { hovered !== null && prs.some((p) => `${p.repo}:${p.number}` === hovered.prKey) - useScrollIntoColumn(rowRef, isRelated) + const isSelectedRelated = + selected !== null && + prs.some((p) => `${p.repo}:${p.number}` === selected.prKey) + + useScrollIntoColumn(rowRef, isSelectedRelated, selected?.tick ?? 0) return (
(null) - const [hoverSourceColumn, setHoverSourceColumn] = + const [selected, setSelected] = useState(null) + const [selectedSourceColumn, setSelectedSourceColumn] = useState(null) // DOM-based dimming: toggle classes directly to avoid re-rendering ~170 PRBlocks. @@ -415,82 +438,91 @@ export function TeamStacksChart({ data }: { data: TeamStacksData }) { el.classList.add('pr-match') } prevMatches.current = Array.from(matches) - - // Track which column the hover originated from - const sourceCol = info.sourceEl.closest( - '.overflow-y-auto', - ) as HTMLElement | null - setHoverSourceColumn(sourceCol) } else { grid.classList.remove('pr-hovering') - setHoverSourceColumn(null) } }, []) + const handleSelect = useCallback( + (e: React.MouseEvent, prKey: string, author: string) => { + const sourceCol = e.currentTarget.closest( + '.overflow-y-auto', + ) as HTMLElement | null + setSelectedSourceColumn(sourceCol) + setSelected((prev) => ({ prKey, author, tick: (prev?.tick ?? 0) + 1 })) + }, + [], + ) + return ( - - -
- {/* Dimming via DOM classes: .pr-hovering dims all buttons, + + + + +
+ {/* Dimming via DOM classes: .pr-hovering dims all buttons, .pr-match + :hover exclude the matched/hovered ones */} -
- - -
-
-
- { - if (v) setColorMode(v as ColorMode) - }} - size="sm" - className="bg-muted shrink-0 rounded-lg p-0.5" +
- - Age - - - Size - - - + + +
+
+
+ { + if (v) setColorMode(v as ColorMode) + }} + size="sm" + className="bg-muted shrink-0 rounded-lg p-0.5" + > + + Age + + + Size + + + +
+

+ 1 block = 1 PR. Dashed line = personal limit ( + {personalLimit} + ). +

+
+ {insight && ( +

+ {insight} +

+ )}
-

- 1 block = 1 PR. Dashed line = personal limit ({personalLimit} - ). -

-
- {insight && ( -

- {insight} -

- )} -
- -
+ + +
+
)