Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/warm-tools-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

NavList: Add a `tooltipText` prop to items
3 changes: 2 additions & 1 deletion packages/react/src/ActionList/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
role,
loading,
_PrivateItemWrapper,
_PrivateTooltipText,
className,
groupId: _groupId,
renderItem: _renderItem,
Expand Down Expand Up @@ -333,7 +334,7 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
data-trailing-action-loading={trailingActionRendered && slots.trailingAction?.props.loading ? true : undefined}
className={clsx(classes.ActionListItem, className)}
>
<ConditionalTooltip ref={forwardedRef} text={truncatedText} enabled={buttonSemantics}>
<ConditionalTooltip ref={forwardedRef} text={_PrivateTooltipText ?? truncatedText} enabled={buttonSemantics}>
<ItemWrapper
{...wrapperProps}
className={classes.ActionListContent}
Expand Down
14 changes: 14 additions & 0 deletions packages/react/src/ActionList/LinkItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ describe('ActionList.LinkItem', () => {
expect(link.tagName).toBe('A')
})

it('forwards its ref to the link when wrapped in a tooltip', () => {
const ref = React.createRef<HTMLAnchorElement>()

render(
<ActionList>
<ActionList.LinkItem ref={ref} href="#home" _PrivateTooltipText="Go home">
Home
</ActionList.LinkItem>
</ActionList>,
)

expect(ref.current).toBe(screen.getByRole('link', {name: 'Home'}))
})

it('calls onClick handler', () => {
const onClick = vi.fn()

Expand Down
30 changes: 26 additions & 4 deletions packages/react/src/ActionList/LinkItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Link from '../Link'
import {Item} from './Item'
import type {ActionListItemProps} from './shared'
import {type PolymorphicProps, fixedForwardRef} from '../utils/modern-polymorphic'
import {Tooltip} from '../TooltipV2'

// adopted from React.AnchorHTMLAttributes
type LinkProps = {
Expand All @@ -27,11 +28,13 @@ export type ActionListLinkItemProps = Pick<
> &
LinkProps

type LinkItemProps<As extends React.ElementType = 'a'> = PolymorphicProps<As, 'a', ActionListLinkItemProps>
type LinkItemProps<As extends React.ElementType = 'a'> = PolymorphicProps<As, 'a', ActionListLinkItemProps> & {
_PrivateTooltipText?: string
}

const LinkItemComponent = fixedForwardRef(
<As extends React.ElementType = 'a'>(
{active, inactiveText, variant, size, as: Component, className, ...props}: LinkItemProps<As>,
{active, inactiveText, variant, size, as: Component, className, _PrivateTooltipText, ...props}: LinkItemProps<As>,
forwardedRef: ForwardedRef<unknown>,
) => {
return (
Expand All @@ -56,11 +59,30 @@ const LinkItemComponent = fixedForwardRef(
// Link's type so TypeScript doesn't re-check the generic
// constraint across two polymorphic layers.
const InternalLink: React.ElementType = Link
return (
<InternalLink as={Component} {...rest} {...props} onClick={clickHandler} ref={forwardedRef}>
const link = (
<InternalLink
as={Component}
{...rest}
{...props}
onClick={clickHandler}
ref={_PrivateTooltipText ? undefined : forwardedRef}
>
{children}
</InternalLink>
)

return _PrivateTooltipText ? (
<Tooltip
ref={forwardedRef as React.ForwardedRef<HTMLElement>}
text={_PrivateTooltipText}
direction="e"
delay="medium"
>
{link}
</Tooltip>
) : (
Comment thread
TylerJDev marked this conversation as resolved.
link
)
}}
>
{props.children}
Expand Down
4 changes: 4 additions & 0 deletions packages/react/src/ActionList/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ export type ActionListItemProps<As extends React.ElementType = 'li'> = ExcludeSe
* Private API for use internally only. Used by LinkItem to wrap contents in an anchor
*/
_PrivateItemWrapper?: React.FC<React.PropsWithChildren<MenuItemProps>>
/**
* Private API for use internally only. Adds a tooltip to the interactive item.
*/
_PrivateTooltipText?: string
className?: string
groupId?: string
renderItem?: (item: React.FC<React.PropsWithChildren<MenuItemProps>>) => React.ReactNode
Expand Down
46 changes: 46 additions & 0 deletions packages/react/src/NavList/NavList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,52 @@ describe('NavList', () => {

describe('NavList.Item', () => {
implementsClassName(NavList.Item)

it('renders a tooltip inside a link item when tooltipText is provided', () => {
const ref = React.createRef<HTMLAnchorElement>()
const {container, getByRole} = render(
<NavList>
<NavList.Item ref={ref} href="#" tooltipText="Tooltip for item 1">
Item 1
</NavList.Item>
<NavList.Item href="#">Item 2</NavList.Item>
</NavList>,
)

const link = getByRole('link', {name: 'Item 1'})
const tooltip = container.querySelector('[data-component="Tooltip"]')
const list = container.querySelector('[data-component="ActionList"]')

expect(tooltip).not.toBeNull()
expect(ref.current).toBe(link)
expect(link).toHaveAttribute('aria-describedby', (tooltip as HTMLElement).id)
expect(tooltip).toHaveTextContent('Tooltip for item 1')
expect(tooltip?.closest('li')).toBe(link.closest('li'))
expect(list?.children).toHaveLength(2)
expect(container.querySelectorAll('[data-component="Tooltip"]')).toHaveLength(1)
})

it('renders a tooltip inside an expandable item when tooltipText is provided', () => {
const {container, getByRole} = render(
<NavList>
<NavList.Item tooltipText="Tooltip for parent item">
Parent item
<NavList.SubNav>
<NavList.Item href="#">Child item</NavList.Item>
</NavList.SubNav>
</NavList.Item>
</NavList>,
)

const button = getByRole('button', {name: 'Parent item'})
const tooltip = container.querySelector('[data-component="Tooltip"]')

expect(tooltip).not.toBeNull()
expect(button).toHaveAttribute('aria-describedby', (tooltip as HTMLElement).id)
expect(tooltip).toHaveTextContent('Tooltip for parent item')
expect(tooltip?.closest('li')).toBe(button.closest('li'))
})

it('passes aria-current prop to the underlying link', () => {
const {getByRole} = render(
<NavList>
Expand Down
9 changes: 7 additions & 2 deletions packages/react/src/NavList/NavList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,13 @@ export type NavListItemProps<As extends React.ElementType = React.ElementType> =
href?: string
'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false' | boolean
inactiveText?: string
tooltipText?: string
}
Comment thread
TylerJDev marked this conversation as resolved.
>

const ItemComponent = fixedForwardRef(
<As extends React.ElementType = 'a'>(
{'aria-current': ariaCurrent, children, defaultOpen, as: Component, ...props}: NavListItemProps<As>,
{'aria-current': ariaCurrent, children, defaultOpen, tooltipText, as: Component, ...props}: NavListItemProps<As>,
ref: React.ForwardedRef<unknown>,
) => {
const {depth} = React.useContext(SubNavContext)
Expand All @@ -165,6 +166,7 @@ const ItemComponent = fixedForwardRef(
subNav={subNav}
depth={depth}
defaultOpen={defaultOpen}
tooltipText={tooltipText}
style={{'--subitem-depth': depth} as React.CSSProperties}
>
{childrenWithoutSubNavOrTrailingAction}
Expand All @@ -185,6 +187,7 @@ const ItemComponent = fixedForwardRef(
active={Boolean(ariaCurrent) && ariaCurrent !== 'false'}
style={{'--subitem-depth': depth} as React.CSSProperties}
data-component="NavList.Item"
_PrivateTooltipText={tooltipText}
{...props}
>
{children}
Expand All @@ -203,6 +206,7 @@ type ItemWithSubNavProps = {
subNav: React.ReactNode
depth: number
defaultOpen?: boolean
tooltipText?: string
style: React.CSSProperties
}

Expand Down Expand Up @@ -234,7 +238,7 @@ function hasCurrentNavItem(node: React.ReactNode): boolean {
return React.Children.toArray(node.props.children).some(hasCurrentNavItem)
}

function ItemWithSubNav({children, subNav, depth: _depth, defaultOpen, style}: ItemWithSubNavProps) {
function ItemWithSubNav({children, subNav, depth: _depth, defaultOpen, tooltipText, style}: ItemWithSubNavProps) {
const buttonId = useId()
const subNavId = useId()

Expand Down Expand Up @@ -268,6 +272,7 @@ function ItemWithSubNav({children, subNav, depth: _depth, defaultOpen, style}: I
onSelect={() => setIsOpen(open => !open)}
style={style}
data-component="NavList.Item"
_PrivateTooltipText={tooltipText}
>
{children}
{/* What happens if the user provides a TrailingVisual? */}
Expand Down
Loading