diff --git a/.changeset/warm-tools-smile.md b/.changeset/warm-tools-smile.md new file mode 100644 index 00000000000..20609cf1020 --- /dev/null +++ b/.changeset/warm-tools-smile.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +NavList: Add a `tooltipText` prop to items \ No newline at end of file diff --git a/packages/react/src/ActionList/Item.tsx b/packages/react/src/ActionList/Item.tsx index 355dd463494..1606952e3e1 100644 --- a/packages/react/src/ActionList/Item.tsx +++ b/packages/react/src/ActionList/Item.tsx @@ -96,6 +96,7 @@ const UnwrappedItem = ( role, loading, _PrivateItemWrapper, + _PrivateTooltipText, className, groupId: _groupId, renderItem: _renderItem, @@ -333,7 +334,7 @@ const UnwrappedItem = ( data-trailing-action-loading={trailingActionRendered && slots.trailingAction?.props.loading ? true : undefined} className={clsx(classes.ActionListItem, className)} > - + { expect(link.tagName).toBe('A') }) + it('forwards its ref to the link when wrapped in a tooltip', () => { + const ref = React.createRef() + + render( + + + Home + + , + ) + + expect(ref.current).toBe(screen.getByRole('link', {name: 'Home'})) + }) + it('calls onClick handler', () => { const onClick = vi.fn() diff --git a/packages/react/src/ActionList/LinkItem.tsx b/packages/react/src/ActionList/LinkItem.tsx index e1687bbdc22..02cd952ea34 100644 --- a/packages/react/src/ActionList/LinkItem.tsx +++ b/packages/react/src/ActionList/LinkItem.tsx @@ -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 = { @@ -27,11 +28,13 @@ export type ActionListLinkItemProps = Pick< > & LinkProps -type LinkItemProps = PolymorphicProps +type LinkItemProps = PolymorphicProps & { + _PrivateTooltipText?: string +} const LinkItemComponent = fixedForwardRef( ( - {active, inactiveText, variant, size, as: Component, className, ...props}: LinkItemProps, + {active, inactiveText, variant, size, as: Component, className, _PrivateTooltipText, ...props}: LinkItemProps, forwardedRef: ForwardedRef, ) => { return ( @@ -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 ( - + const link = ( + {children} ) + + return _PrivateTooltipText ? ( + } + text={_PrivateTooltipText} + direction="e" + delay="medium" + > + {link} + + ) : ( + link + ) }} > {props.children} diff --git a/packages/react/src/ActionList/shared.ts b/packages/react/src/ActionList/shared.ts index af98c9cd30a..0d8a1b99866 100644 --- a/packages/react/src/ActionList/shared.ts +++ b/packages/react/src/ActionList/shared.ts @@ -59,6 +59,10 @@ export type ActionListItemProps = ExcludeSe * Private API for use internally only. Used by LinkItem to wrap contents in an anchor */ _PrivateItemWrapper?: React.FC> + /** + * Private API for use internally only. Adds a tooltip to the interactive item. + */ + _PrivateTooltipText?: string className?: string groupId?: string renderItem?: (item: React.FC>) => React.ReactNode diff --git a/packages/react/src/NavList/NavList.test.tsx b/packages/react/src/NavList/NavList.test.tsx index f65e20151d9..6fd762ea3f4 100644 --- a/packages/react/src/NavList/NavList.test.tsx +++ b/packages/react/src/NavList/NavList.test.tsx @@ -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() + const {container, getByRole} = render( + + + Item 1 + + Item 2 + , + ) + + 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( + + + Parent item + + Child item + + + , + ) + + 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( diff --git a/packages/react/src/NavList/NavList.tsx b/packages/react/src/NavList/NavList.tsx index 6ca385849ef..db954644f45 100644 --- a/packages/react/src/NavList/NavList.tsx +++ b/packages/react/src/NavList/NavList.tsx @@ -137,12 +137,13 @@ export type NavListItemProps = href?: string 'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false' | boolean inactiveText?: string + tooltipText?: string } > const ItemComponent = fixedForwardRef( ( - {'aria-current': ariaCurrent, children, defaultOpen, as: Component, ...props}: NavListItemProps, + {'aria-current': ariaCurrent, children, defaultOpen, tooltipText, as: Component, ...props}: NavListItemProps, ref: React.ForwardedRef, ) => { const {depth} = React.useContext(SubNavContext) @@ -165,6 +166,7 @@ const ItemComponent = fixedForwardRef( subNav={subNav} depth={depth} defaultOpen={defaultOpen} + tooltipText={tooltipText} style={{'--subitem-depth': depth} as React.CSSProperties} > {childrenWithoutSubNavOrTrailingAction} @@ -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} @@ -203,6 +206,7 @@ type ItemWithSubNavProps = { subNav: React.ReactNode depth: number defaultOpen?: boolean + tooltipText?: string style: React.CSSProperties } @@ -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() @@ -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? */}