Skip to content

Commit b48fdcc

Browse files
committed
feat(home): make PR list items middle-clickable via BlockLink
Home-page PR rows were rendered as <button> elements, so users could not middle-click to open a PR in a new tab. Introduce a reusable BlockLink component: BlockLink.Root wraps the clickable card and re-dispatches click events onto the nested BlockLink.Link, while BlockLink.Link renders an actual <a> (so the browser handles middle/cmd/ctrl click natively) and stops propagation to avoid firing twice. BlockLink.Link supports an asChild prop backed by a small Slot utility that spreads its props onto its only child. The PR title on the home list is now a BlockLink.Link with the PR's canonical href, and plain left-clicks still route through the existing openPRReviewTab flow for SPA navigation. Resolves #362
1 parent 7fbc070 commit b48fdcc

2 files changed

Lines changed: 99 additions & 8 deletions

File tree

src/browser/components/home.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
DialogTitle,
4949
} from "../ui/dialog";
5050
import { Button } from "../ui/button";
51+
import { BlockLink } from "../ui/block-link";
5152
import { cn } from "../cn";
5253
import { Skeleton } from "../ui/skeleton";
5354
import { UserHoverCard } from "../ui/user-hover-card";
@@ -1962,8 +1963,17 @@ function PRListItem({ pr, onSelect }: PRListItemProps) {
19621963
return pr.updated_at ? pr.updated_at > baseline : false;
19631964
}, [repoInfo, pr.updated_at, pr.viewerLastReviewAt, pr.isReadByViewer]);
19641965

1965-
const handleClick = () => {
1966+
const href = repoInfo
1967+
? `/${repoInfo.owner}/${repoInfo.repo}/pull/${pr.number}`
1968+
: undefined;
1969+
1970+
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
1971+
// Let the browser handle modifier clicks natively (new tab/window).
1972+
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
1973+
return;
1974+
}
19661975
if (repoInfo) {
1976+
e.preventDefault();
19671977
onSelect(repoInfo.owner, repoInfo.repo, pr.number, pr.title);
19681978
}
19691979
};
@@ -2294,10 +2304,7 @@ function PRListItem({ pr, onSelect }: PRListItemProps) {
22942304
};
22952305

22962306
return (
2297-
<button
2298-
onClick={handleClick}
2299-
className="w-full flex items-start gap-2 sm:gap-3 px-2 sm:px-4 py-3 hover:bg-muted/50 transition-colors text-left"
2300-
>
2307+
<BlockLink.Root className="w-full flex items-start gap-2 sm:gap-3 px-2 sm:px-4 py-3 hover:bg-muted/50 transition-colors text-left cursor-pointer">
23012308
{/* PR Icon */}
23022309
{isMerged ? (
23032310
<GitMerge className="w-4 h-4 mt-0.5 shrink-0 text-purple-500" />
@@ -2320,9 +2327,13 @@ function PRListItem({ pr, onSelect }: PRListItemProps) {
23202327
{/* Content */}
23212328
<div className="flex-1 min-w-0">
23222329
<div className="flex items-center gap-2 flex-wrap">
2323-
<span className="font-medium hover:text-blue-400 break-words">
2330+
<BlockLink.Link
2331+
href={href}
2332+
onClick={handleLinkClick}
2333+
className="font-medium hover:text-blue-400 break-words no-underline text-inherit"
2334+
>
23242335
{pr.title}
2325-
</span>
2336+
</BlockLink.Link>
23262337
<CIStatusBadge />
23272338
<ReviewStatusBadge />
23282339
{pr.hasNewChanges && (
@@ -2408,7 +2419,7 @@ function PRListItem({ pr, onSelect }: PRListItemProps) {
24082419
)}
24092420
</div>
24102421
</div>
2411-
</button>
2422+
</BlockLink.Root>
24122423
);
24132424
}
24142425

src/browser/ui/block-link.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import * as React from "react";
2+
import { Slot as SlotPrimitive } from "radix-ui";
3+
4+
// A ref that BlockLink.Root uses to know where its BlockLink.Link is. Root
5+
// clicks are re-dispatched onto this element so the browser handles them as
6+
// native <a> activation.
7+
type BlockLinkContextValue = {
8+
linkRef: React.RefObject<HTMLElement | null>;
9+
};
10+
11+
const BlockLinkContext = React.createContext<BlockLinkContextValue | null>(
12+
null
13+
);
14+
15+
type RootProps = Omit<React.HTMLAttributes<HTMLDivElement>, "onClick"> & {
16+
onClick?: React.MouseEventHandler<HTMLDivElement>;
17+
children: React.ReactNode;
18+
asChild?: boolean;
19+
};
20+
21+
function Root({ onClick, children, asChild, ...props }: RootProps) {
22+
const linkRef = React.useRef<HTMLElement | null>(null);
23+
24+
const handleClick = React.useCallback(
25+
(e: React.MouseEvent<HTMLDivElement>) => {
26+
onClick?.(e);
27+
if (e.defaultPrevented) return;
28+
const link = linkRef.current;
29+
if (link && !link.contains(e.target as Node)) {
30+
link.click();
31+
}
32+
},
33+
[onClick]
34+
);
35+
36+
const Comp = asChild ? SlotPrimitive.Slot : "div";
37+
38+
return (
39+
<BlockLinkContext.Provider value={{ linkRef }}>
40+
<Comp onClick={handleClick} {...props}>
41+
{children}
42+
</Comp>
43+
</BlockLinkContext.Provider>
44+
);
45+
}
46+
47+
type LinkOwnProps = {
48+
asChild?: boolean;
49+
children: React.ReactNode;
50+
};
51+
52+
type LinkProps = LinkOwnProps &
53+
Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkOwnProps>;
54+
55+
function Link({ asChild, children, onClick, ...props }: LinkProps) {
56+
const context = React.useContext(BlockLinkContext);
57+
const localRef = React.useRef<HTMLElement | null>(null);
58+
59+
const setRef = React.useCallback(
60+
(el: HTMLElement | null) => {
61+
localRef.current = el;
62+
if (context) context.linkRef.current = el;
63+
},
64+
[context]
65+
);
66+
67+
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
68+
e.stopPropagation();
69+
onClick?.(e as React.MouseEvent<HTMLAnchorElement>);
70+
};
71+
72+
const Comp = asChild ? SlotPrimitive.Slot : "a";
73+
return (
74+
<Comp ref={setRef} onClick={handleClick} {...props}>
75+
{children}
76+
</Comp>
77+
);
78+
}
79+
80+
export const BlockLink = { Root, Link };

0 commit comments

Comments
 (0)