From 90b654b72ea44ffa172ddf16dd504b1fbf73be9c Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Fri, 15 May 2026 12:51:03 -0700 Subject: [PATCH] fix(web): make contest cards open in a new tab on cmd/middle-click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ContestCard was a `
` with `onClick` + `useLinkClickHandler`, which short-circuits SPA navigation for modifier-clicks but doesn't give the browser an anchor to open in a new tab — so cmd-click and middle-click on a card did nothing instead of opening the contest in a new tab like every other link in the app. Switch to the stretched-link pattern: a real `` absolutely positioned over the whole card. UserLink (already an ``) and the horizontally-scrolling entries row are elevated via z-index so the artist link stays independently clickable and pill-row scrolling doesn't accidentally trigger navigation. Nesting two anchors stays invalid HTML — the stretched link is a sibling of UserLink, not an ancestor. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/contest-card/ContestCard.tsx | 69 +++++++++++++------ 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/packages/web/src/components/contest-card/ContestCard.tsx b/packages/web/src/components/contest-card/ContestCard.tsx index 5bfcc82ff99..8f137b685ac 100644 --- a/packages/web/src/components/contest-card/ContestCard.tsx +++ b/packages/web/src/components/contest-card/ContestCard.tsx @@ -3,7 +3,6 @@ import { ReactNode, Ref, forwardRef, - useCallback, useEffect, useState } from 'react' @@ -24,7 +23,7 @@ import { Text, useTheme } from '@audius/harmony' -import { useLinkClickHandler } from 'react-router' +import { Link } from 'react-router' import { Avatar } from 'components/avatar/Avatar' import { UserLink } from 'components/link' @@ -59,7 +58,7 @@ export type ContestCardProps = Omit & { */ trackId: ID variant?: ContestCardVariant - onClick?: (e: MouseEvent) => void + onClick?: (e: MouseEvent) => void } const COVER_HEIGHT = 96 @@ -271,15 +270,6 @@ export const ContestCard = forwardRef( const contestDestination = track?.permalink ? contestPage(track.permalink) : '' - const handleNavigate = - useLinkClickHandler(contestDestination) - const handleClick = useCallback( - (e: MouseEvent) => { - onClick?.(e) - if (contestDestination) handleNavigate(e) - }, - [handleNavigate, onClick, contestDestination] - ) if (!track || !user || !remixContest) { return @@ -297,18 +287,26 @@ export const ContestCard = forwardRef( } const status = formatStatus(remixContest.endDate) + const contestTitle = + (remixContest.eventData as any)?.title?.trim() || track.title return ( overlay + // below, which is what lets cmd+click / middle-click open the + // contest page in a new tab the way the browser does for any + // ordinary anchor. + position: 'relative' + }} {...other} > {/* Cover banner */} @@ -337,7 +335,16 @@ export const ContestCard = forwardRef( > {messages.hostedBy} - + {/* UserLink is its own ; lift it above the stretched link + so the artist's profile remains independently clickable + (and nesting two anchors stays invalid HTML — they're + siblings in the stacking context, not parent/child). */} + @@ -364,26 +371,48 @@ export const ContestCard = forwardRef( wordBreak: 'break-word' }} > - {(remixContest.eventData as any)?.title?.trim() || track.title} + {contestTitle} e.stopPropagation()} css={{ overflowX: 'auto', overflowY: 'hidden', minWidth: 0, scrollbarWidth: 'none', msOverflowStyle: 'none', - '&::-webkit-scrollbar': { display: 'none' } + '&::-webkit-scrollbar': { display: 'none' }, + // Elevate the horizontally-scrolling entries row above the + // stretched link so horizontal scroll / pill clicks don't + // accidentally navigate to the contest page. + position: 'relative', + zIndex: 2 }} > {messages.entries(entriesCount)} + + {/* Stretched-link overlay: a real that fills the card so the + browser handles cmd+click and middle-click natively (opening + the contest in a new tab). Interactive descendants + (UserLink, the entries row) opt out via a higher z-index. */} + {contestDestination ? ( + + ) : null} ) }