Skip to content

Commit 259fdff

Browse files
hussam-i-amCopilot
andauthored
fix(polymorphic): Improve prop passthrough for ActionList.LinkItem and Breadcrumbs.Item (#7658)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 205d34a commit 259fdff

8 files changed

Lines changed: 294 additions & 64 deletions

File tree

.changeset/curly-moments-show.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
fix(polymorphic): Improve prop passthrough for ActionList.LinkItem and Breadcrumbs.Item
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import {describe, it, expect, vi} from 'vitest'
2+
import {render, screen, fireEvent} from '@testing-library/react'
3+
import React from 'react'
4+
import {ActionList} from '.'
5+
import Link from '../Link'
6+
import {implementsClassName} from '../utils/testing'
7+
8+
describe('ActionList.LinkItem', () => {
9+
implementsClassName(ActionList.LinkItem)
10+
11+
it('renders as an anchor by default', () => {
12+
render(
13+
<ActionList>
14+
<ActionList.LinkItem href="#home">Home</ActionList.LinkItem>
15+
</ActionList>,
16+
)
17+
18+
const link = screen.getByRole('link', {name: 'Home'})
19+
expect(link).toHaveAttribute('href', '#home')
20+
expect(link.tagName).toBe('A')
21+
})
22+
23+
it('calls onClick handler', () => {
24+
const onClick = vi.fn()
25+
26+
render(
27+
<ActionList>
28+
<ActionList.LinkItem role="link" onClick={onClick}>
29+
Home
30+
</ActionList.LinkItem>
31+
</ActionList>,
32+
)
33+
34+
fireEvent.click(screen.getByRole('link', {name: 'Home'}))
35+
expect(onClick).toHaveBeenCalledTimes(1)
36+
})
37+
38+
describe('as prop', () => {
39+
it('supports polymorphic LinkItem with as prop', () => {
40+
const CustomLink = React.forwardRef<
41+
HTMLAnchorElement,
42+
React.AnchorHTMLAttributes<HTMLAnchorElement> & {custom: boolean}
43+
>(({children, custom, ...props}, ref) => (
44+
<a ref={ref} data-custom-link={custom} {...props}>
45+
{children}
46+
</a>
47+
))
48+
CustomLink.displayName = 'CustomLink'
49+
50+
render(
51+
<ActionList>
52+
<ActionList.LinkItem as={CustomLink} href="#docs" custom={true}>
53+
Docs
54+
</ActionList.LinkItem>
55+
</ActionList>,
56+
)
57+
58+
const link = screen.getByRole('link', {name: 'Docs'})
59+
expect(link).toHaveAttribute('data-custom-link', 'true')
60+
expect(link).toHaveAttribute('href', '#docs')
61+
})
62+
63+
it('passes through additional props to the element specified by as', () => {
64+
render(
65+
<ActionList>
66+
<ActionList.LinkItem as={Link} href="#home" data-testid="home-link" inline>
67+
Home
68+
</ActionList.LinkItem>
69+
</ActionList>,
70+
)
71+
72+
const homeLink = screen.getByTestId('home-link')
73+
expect(homeLink).toHaveAttribute('href', '#home')
74+
expect(homeLink).toHaveAttribute('data-inline', 'true')
75+
})
76+
})
77+
78+
it('renders inactive text when inactiveText is provided', () => {
79+
render(
80+
<ActionList>
81+
<ActionList.LinkItem href="#home" inactiveText="Unavailable due to an outage">
82+
Home
83+
</ActionList.LinkItem>
84+
</ActionList>,
85+
)
86+
87+
expect(screen.getByText('Unavailable due to an outage')).toBeInTheDocument()
88+
})
89+
})

packages/react/src/ActionList/LinkItem.tsx

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import React from 'react'
2-
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
1+
import type React from 'react'
2+
import type {ForwardedRef} from 'react'
3+
import type {WithSlotMarker} from '../utils/types/Slots'
34
import Link from '../Link'
45
import {Item} from './Item'
56
import type {ActionListItemProps} from './shared'
7+
import {type PolymorphicProps, fixedForwardRef} from '../utils/modern-polymorphic'
68

79
// adopted from React.AnchorHTMLAttributes
810
type LinkProps = {
@@ -25,8 +27,13 @@ export type ActionListLinkItemProps = Pick<
2527
> &
2628
LinkProps
2729

28-
export const LinkItem = React.forwardRef(
29-
({active, inactiveText, variant, size, as: Component, className, ...props}, forwardedRef) => {
30+
type LinkItemProps<As extends React.ElementType = 'a'> = PolymorphicProps<As, 'a', ActionListLinkItemProps>
31+
32+
const LinkItemComponent = fixedForwardRef(
33+
<As extends React.ElementType = 'a'>(
34+
{active, inactiveText, variant, size, as: Component, className, ...props}: LinkItemProps<As>,
35+
forwardedRef: ForwardedRef<unknown>,
36+
) => {
3037
return (
3138
<Item
3239
className={className}
@@ -40,21 +47,29 @@ export const LinkItem = React.forwardRef(
4047
onClick && onClick(event)
4148
props.onClick && props.onClick(event as React.MouseEvent<HTMLAnchorElement>)
4249
}
43-
return inactiveText ? (
44-
<span {...rest}>{children}</span>
45-
) : (
46-
<Link as={Component} {...rest} {...props} onClick={clickHandler} ref={forwardedRef}>
50+
if (inactiveText) {
51+
return <span {...rest}>{children}</span>
52+
}
53+
54+
// Type safety for the polymorphic `as` prop is enforced at the
55+
// LinkItem boundary via fixedForwardRef. Internally we widen
56+
// Link's type so TypeScript doesn't re-check the generic
57+
// constraint across two polymorphic layers.
58+
const InternalLink: React.ElementType = Link
59+
return (
60+
<InternalLink as={Component} {...rest} {...props} onClick={clickHandler} ref={forwardedRef}>
4761
{children}
48-
</Link>
62+
</InternalLink>
4963
)
5064
}}
5165
>
5266
{props.children}
5367
</Item>
5468
)
5569
},
56-
) as PolymorphicForwardRefComponent<'a', ActionListLinkItemProps>
57-
58-
LinkItem.displayName = 'ActionList.LinkItem'
70+
)
5971

60-
LinkItem.__SLOT__ = Symbol('ActionList.LinkItem')
72+
export const LinkItem: WithSlotMarker<typeof LinkItemComponent> = Object.assign(LinkItemComponent, {
73+
displayName: 'ActionList.LinkItem',
74+
__SLOT__: Symbol('ActionList.LinkItem'),
75+
})

packages/react/src/Breadcrumbs/Breadcrumbs.tsx

Lines changed: 37 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {ResizeObserverEntry} from '../hooks/useResizeObserver'
1111
import {useOnEscapePress} from '../hooks/useOnEscapePress'
1212
import {useOnOutsideClick} from '../hooks/useOnOutsideClick'
1313
import {useFeatureFlag} from '../FeatureFlags'
14+
import {type PolymorphicProps, fixedForwardRef} from '../utils/modern-polymorphic'
1415

1516
export type BreadcrumbsProps = React.PropsWithChildren<{
1617
/**
@@ -118,11 +119,14 @@ const BreadcrumbsMenuItem = React.forwardRef<HTMLDetailsElement, BreadcrumbsMenu
118119
<div ref={menuContainerRef} className={classes.MenuOverlay}>
119120
<ActionList>
120121
{items.map((item, index) => {
121-
const href = item.props.href
122-
const children = item.props.children
123-
const selected = item.props.selected
122+
const {children, selected, as: Component, ...itemProps} = item.props
124123
return (
125-
<ActionList.LinkItem key={index} href={href} aria-current={selected ? 'page' : undefined}>
124+
<ActionList.LinkItem
125+
key={index}
126+
as={Component}
127+
{...itemProps}
128+
aria-current={selected ? 'page' : undefined}
129+
>
126130
{children}
127131
</ActionList.LinkItem>
128132
)
@@ -360,41 +364,39 @@ const ItemSeparator = () => {
360364
)
361365
}
362366

363-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
364-
type DistributiveOmit<T, TOmitted extends PropertyKey> = T extends any ? Omit<T, TOmitted> : never
365-
366-
type StyledBreadcrumbsItemProps<As extends React.ElementType> = {
367-
as?: As
368-
to?: To
369-
selected?: boolean
370-
className?: string
371-
style?: React.CSSProperties
372-
} & DistributiveOmit<React.ComponentPropsWithRef<React.ElementType extends As ? 'a' : As>, 'as'>
373-
374-
function BreadcrumbsItemComponent<As extends React.ElementType>(
375-
props: StyledBreadcrumbsItemProps<As>,
376-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
377-
ref: ForwardedRef<any>,
378-
) {
379-
const {as: Component = 'a', selected, className, ...rest} = props
380-
return (
381-
<Component
382-
className={clsx(className, classes.Item, selected && 'selected')}
383-
aria-current={selected ? 'page' : undefined}
384-
ref={ref}
385-
{...rest}
386-
/>
387-
)
388-
}
389-
390-
BreadcrumbsItemComponent.displayName = 'Breadcrumbs.Item'
391-
392-
const BreadcrumbsItem = React.forwardRef(BreadcrumbsItemComponent)
367+
type StyledBreadcrumbsItemProps<As extends React.ElementType = 'a'> = PolymorphicProps<
368+
As,
369+
'a',
370+
{
371+
to?: To
372+
selected?: boolean
373+
}
374+
>
375+
376+
const BreadcrumbsItem = fixedForwardRef(
377+
<As extends React.ElementType = 'a'>(
378+
props: StyledBreadcrumbsItemProps<As>,
379+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
380+
ref: ForwardedRef<any>,
381+
) => {
382+
const {as: Component = 'a', selected, className, ...rest} = props
383+
return (
384+
<Component
385+
className={clsx(className, classes.Item, selected && 'selected')}
386+
aria-current={selected ? 'page' : undefined}
387+
ref={ref}
388+
{...rest}
389+
/>
390+
)
391+
},
392+
)
393393

394394
Breadcrumbs.displayName = 'Breadcrumbs'
395395

396396
export type BreadcrumbsItemProps<As extends React.ElementType = 'a'> = StyledBreadcrumbsItemProps<As>
397-
export default Object.assign(Breadcrumbs, {Item: BreadcrumbsItem})
397+
398+
const BreadcrumbsItemWithDisplayName = Object.assign(BreadcrumbsItem, {displayName: 'Breadcrumbs.Item'})
399+
export default Object.assign(Breadcrumbs, {Item: BreadcrumbsItemWithDisplayName})
398400

399401
/**
400402
* @deprecated Use the `Breadcrumbs` component instead (i.e. `<Breadcrumb>` → `<Breadcrumbs>`)

packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'
55
import {FeatureFlags} from '../../FeatureFlags'
66
import {implementsClassName} from '../../utils/testing'
77
import classes from '../Breadcrumbs.module.css'
8+
import Link from '../../Link'
89

910
// Helper function to render with theme and feature flags
1011
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -548,4 +549,69 @@ describe('Breadcrumbs', () => {
548549
expect(container.firstChild).toHaveAttribute('data-variant', 'spacious')
549550
})
550551
})
552+
553+
describe('as prop', () => {
554+
it('supports polymorphic Breadcrumbs.Item with as prop', () => {
555+
renderWithTheme(
556+
<Breadcrumbs>
557+
<Breadcrumbs.Item as="span">Home</Breadcrumbs.Item>
558+
<Breadcrumbs.Item as="span" selected>
559+
Docs
560+
</Breadcrumbs.Item>
561+
</Breadcrumbs>,
562+
{primer_react_breadcrumbs_overflow_menu: true},
563+
)
564+
565+
const homeItem = screen.getByText('Home')
566+
const docsItem = screen.getByText('Docs')
567+
568+
expect(homeItem.nodeName).toEqual('SPAN')
569+
expect(docsItem.nodeName).toEqual('SPAN')
570+
})
571+
572+
it('passes through additional props to the element specified by as', () => {
573+
renderWithTheme(
574+
<Breadcrumbs>
575+
<Breadcrumbs.Item as={Link} href="/home" data-testid="home-link" inline>
576+
Home
577+
</Breadcrumbs.Item>
578+
</Breadcrumbs>,
579+
{primer_react_breadcrumbs_overflow_menu: true},
580+
)
581+
582+
const homeLink = screen.getByTestId('home-link')
583+
expect(homeLink).toHaveAttribute('href', '/home')
584+
expect(homeLink).toHaveAttribute('data-inline', 'true')
585+
})
586+
587+
it('passes through additional props to the element specified by as in overflow menu', async () => {
588+
const user = userEvent.setup()
589+
590+
renderWithTheme(
591+
<Breadcrumbs overflow="menu">
592+
<Breadcrumbs.Item as={Link} href="/home" data-testid="home-link" inline>
593+
Home
594+
</Breadcrumbs.Item>
595+
<Breadcrumbs.Item href="/docs">Docs</Breadcrumbs.Item>
596+
<Breadcrumbs.Item href="/components">Components</Breadcrumbs.Item>
597+
<Breadcrumbs.Item href="/breadcrumbs">Breadcrumbs</Breadcrumbs.Item>
598+
<Breadcrumbs.Item href="/examples">Examples</Breadcrumbs.Item>
599+
<Breadcrumbs.Item href="/advanced" selected>
600+
Advanced
601+
</Breadcrumbs.Item>
602+
</Breadcrumbs>,
603+
{primer_react_breadcrumbs_overflow_menu: true},
604+
)
605+
606+
// Open the overflow menu
607+
const menuButton = screen.getByRole('button', {name: /more breadcrumb items/i})
608+
await user.click(menuButton)
609+
610+
// Verify the custom link in the overflow menu has the correct href
611+
await waitFor(() => {
612+
const homeLink = screen.getByTestId('home-link')
613+
expect(homeLink).toHaveAttribute('href', '/home')
614+
})
615+
})
616+
})
551617
})

packages/react/src/NavList/NavList.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,4 +458,45 @@ describe('NavList.ShowMoreItem with pages', () => {
458458
expect(queryByRole('link', {name: 'Item 6'})).not.toBeInTheDocument()
459459
expect(queryByRole('link', {name: 'Item 7'})).not.toBeInTheDocument()
460460
})
461+
462+
it('passes through as props to the link items', () => {
463+
const CustomLink = React.forwardRef<
464+
HTMLAnchorElement,
465+
React.AnchorHTMLAttributes<HTMLAnchorElement> & {custom: boolean}
466+
>(({children, custom, ...props}, ref) => (
467+
<a ref={ref} data-custom-link={custom} {...props}>
468+
{children}
469+
</a>
470+
))
471+
CustomLink.displayName = 'CustomLink'
472+
473+
const {queryByRole} = render(
474+
<NavList>
475+
<NavList.Item as={CustomLink} href="#item1" custom={true}>
476+
Item 1
477+
</NavList.Item>
478+
<NavList.Item as={CustomLink} href="#item2" custom={false}>
479+
Item 2
480+
</NavList.Item>
481+
<NavList.GroupExpand
482+
label="More"
483+
items={[
484+
{text: 'Item 3', href: '#item3'},
485+
{text: 'Item 4', href: '#item4'},
486+
]}
487+
/>
488+
</NavList>,
489+
)
490+
491+
act(() => {
492+
queryByRole('button', {name: 'More'})?.click()
493+
})
494+
495+
expect(queryByRole('link', {name: 'Item 1'})).toHaveAttribute('href', '#item1')
496+
expect(queryByRole('link', {name: 'Item 1'})).toHaveAttribute('data-custom-link', 'true')
497+
expect(queryByRole('link', {name: 'Item 2'})).toHaveAttribute('href', '#item2')
498+
expect(queryByRole('link', {name: 'Item 2'})).toHaveAttribute('data-custom-link', 'false')
499+
expect(queryByRole('link', {name: 'Item 3'})).toHaveAttribute('href', '#item3')
500+
expect(queryByRole('link', {name: 'Item 4'})).toHaveAttribute('href', '#item4')
501+
})
461502
})

0 commit comments

Comments
 (0)