fix(ListItem): render linked items as real anchors - #3084
Merged
Conversation
Contributor
Coverage Report for ./packages/components/
File CoverageNo changed files found. |
Contributor
🚀 Preview DeploymentPreview environments are ready:
Images:
|
Contributor
✅ Visual Regression Tests PassedAll visual snapshots match the committed baselines. |
React Aria's `GridListItem` always renders a `div` — unlike `ListBoxItem`, it
has no `href ? 'a' : 'div'` switch. `useGridListItem` turns `href` into
`data-href`/`data-target` and navigates on press by creating a throwaway `<a>`,
clicking it and removing it again. The DOM therefore never held a link, so the
browser had nothing to offer in its context menu. Cmd+Click only worked because
`usePress` reads the modifier keys and forwards them to that throwaway anchor.
Overlay a real `<a href>` inside the row. The row keeps owning activation:
react-aria already installs `onClick = e => { if (!openLink.isOpening)
e.preventDefault() }` for link items, which cancels the anchor's default action
on a user click and lets `router.open` navigate — so client-side routing and the
single-navigation guarantee are unchanged. Context menu, middle-click and
modifier-click are handled by the browser on the anchor itself.
The anchor is `tabIndex={-1}` and `aria-hidden`. Untabbable is load-bearing:
`useSelectableItem` treats a tabbable descendant as interactive content
(`isTabbable`) and stops the row's own press. Hidden from the accessibility tree
keeps the row's announcement unchanged.
CSS lifts interactive content and the bottom slot back above the overlay, so
context menus, buttons and checkboxes keep receiving their own clicks. This is
what broke #2420 (reverted): switching to `ListBox` also ran the item action
when interactive content inside an item was pressed.
No prop changed, so the remote contract and the generated artifacts stay as they
are.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mfal
force-pushed
the
fix/list-item-link-new-tab-1250
branch
from
September 2, 2026 11:53
2d52a45 to
bb84e48
Compare
Contributor
✅ Cross-Version Tests PassedThe current host still renders old published remote versions correctly (iframe) and in the same DOM shape (in-process). Iframe harness (attribute-accurate): ✅ passed In-process harness (structure-only): ✅ passed |
mfal
marked this pull request as ready for review
September 3, 2026 12:33
mfal
enabled auto-merge (squash)
September 3, 2026 12:41
Lisa18289
approved these changes
Sep 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Linked list items now render a real
<a href>, so the browser's own linkaffordances work on them again.
Root cause
Read off the DOM: the row is a
divwithdata-hrefand no anchor anywhereinside it.
React Aria's
GridListItemrendersdom.divunconditionally(
react-aria-components@1.20.0,GridList.mjs).ListBoxItemhas aprops.href ? dom.a : dom.divswitch —GridListItemand tableRowdo not,because a grid row is meant to hold nested interactive content and interactive
content may not nest inside
<a>.Instead,
useGridListItempasseshrefthroughuseSyntheticLinkProps, whichputs
data-href/data-targeton the row. On press,openLink'sgetSyntheticLinkcreates an<a>, appends it to the row, dispatches a click onit and removes it again. So:
usePressreads the modifier keys and forwardsthem to that throwaway anchor
What changed
GridListItemoverlays a real anchor when the item has ahref(
useLinkPropsresolves it through theRouterProvider, so abasePathisapplied the same way react-aria would):
onClick = e => { if (!openLink.isOpening) e.preventDefault() }on link items— the contract for "the item is an anchor", which
ListBoxItemrelies on.A user click on the overlay bubbles to the row, gets its default cancelled,
and
router.opennavigates. Client-side routing and exactly-one-navigationare unchanged.
tabIndex={-1}is load-bearing, not cosmetic.useSelectableItemguardsthe row's press with
isTabbable(target)— a tabbable descendant is treated asinteractive content and stops the row's press. A tabbable overlay would make a
left click do nothing at all.
aria-hiddenkeeps the accessibility tree exactly as it is: the row keepsannouncing itself, no second link, no interactive content inside a link.
z-index: 1and lifts interactive content(
a, button, input, label, …, [tabindex]) plus the whole bottom slot toz-index: 2. The bottom slot carries arbitrary consumer content and theaccordion's expanded content, so it stays clickable and selectable.
This is where the first attempt (#2420, reverted) went wrong: replacing
GridListwithListBoxalso ran the item action when interactive contentinside an item was pressed.
ListBoxoptions are not built for nestedinteractive content;
GridListis, and it stays.Compatibility for remote consumers
No prop changed — not on
List/ListItem, not on the@flr-generateGridListItem.hrefandtargetwere already the API; only the host-siderendering of them changed.
pnpm buildproduces no diff undersrc/auto-generated/**, no newview.ts, nosrc/views/*change. Nothing todeprecate, so no
useWarnDeprecationpath is needed.Verification
Tests in
packages/components/src/components/List/List.browser.test.tsx(
describe("Linked items")), all run in webkit:<a href>exists in the row, anddocument.elementFromPointover the item's content returns that anchor — what the browser's context menu acts ontargetattribute; the browser handlesauxclicknatively and react-aria never sees aclickto cancel{Enter}→navigatecalled onceonActionandnavigateare not called (regression guard for #2420). Choosing a menu item runs only the menu's actionelementFromPointover the Options button returns the button, not the overlayhrefrenders no anchorThe layering tests were verified to actually guard: raising the overlay above
everything makes three of them fail, two with Playwright's
<a …> intercepts pointer events.Also run:
pnpm nx test:browser components --browser.name=webkit(38 files, 271tests),
pnpm nx test:unit components(253),pnpm nx test:compile components,pnpm lint(0 errors),pnpm build(no generated diff).Visual: no pixel change — the overlay is transparent and layout-neutral. The
18
Listand 2FileCardListvisual scenarios pass unchanged against the local-darwinbaselines, in both theLocalandRemoteenvironments. No newscenario was added: a new one would have no committed baseline and would fail
the verify-only run.
run-visual-testsis set so CI confirms this on Linux.Manual:
apps/remote-dom-demo/remote/listnow sets ahref, so thewhole remote path can be right-clicked / middle-clicked over the real iframe
connection. Storybook has a
ListItem/WithLinkstory for the local path.Left out
Item.module.scsscontains a dead selector next to the new rules::global(.flow--list--items--item--view--bottom-content)— the rendered class isflow--list--list-item-view--bottom-content, so the intended "no item hoverwhile hovering the bottom content" carve-out never applied. Pre-existing, a
visual change to fix, and unrelated to this bug — filed separately rather than
folded in here.
fixes #1250
🤖 Generated with Claude Code