Skip to content
Open
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/stable-link-item-subtree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

ActionList.LinkItem, NavList.Item: Preserve keyboard focus and running child animations when links rerender.
5 changes: 5 additions & 0 deletions .changeset/tooltip-reposition-on-rerender.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Tooltip: Keep an open tooltip anchored to its trigger when a rerender moves the trigger.
250 changes: 249 additions & 1 deletion packages/react/src/ActionList/LinkItem.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,261 @@
import {describe, it, expect, vi} from 'vitest'
import {render, screen, fireEvent} from '@testing-library/react'
import {render, screen, fireEvent, waitFor} from '@testing-library/react'
import React from 'react'
import {ActionList} from '.'
import Link from '../Link'
import {implementsClassName} from '../utils/testing'
import spinnerClasses from '../Spinner/Spinner.module.css'
import {ActionListContainerContext} from './ActionListContainerContext'

describe('ActionList.LinkItem', () => {
implementsClassName(ActionList.LinkItem)

it.each([undefined, 'Go home'])('preserves the link, animation, and focus with tooltip %s', async tooltipText => {
const content = (
<ActionList>
<ActionList.LinkItem href="#home" _PrivateTooltipText={tooltipText}>
<span data-testid="animated-child" className={spinnerClasses.SpinnerAnimation}>
Home
</span>
</ActionList.LinkItem>
</ActionList>
)
const {rerender} = render(content)
const link = screen.getByRole('link', {name: 'Home'})
const child = screen.getByTestId('animated-child')
link.focus()
const [animation] = child.getAnimations()
expect(animation).toBeDefined()
await animation.ready
const startTime = animation.startTime

// Use a fresh element tree so React cannot skip the parent render.
rerender(
<ActionList>
<ActionList.LinkItem href="#home" _PrivateTooltipText={tooltipText}>
<span data-testid="animated-child" className={spinnerClasses.SpinnerAnimation}>
Home
</span>
</ActionList.LinkItem>
</ActionList>,
)

expect(screen.getByRole('link', {name: 'Home'})).toBe(link)
expect(screen.getByTestId('animated-child')).toBe(child)
expect(link).toHaveFocus()
expect(child.getAnimations()[0]).toBe(animation)
expect(animation.startTime).toBe(startTime)
})

it('updates link props and handlers without replacing the link', () => {
const originalClick = vi.fn()
const updatedClick = vi.fn()
const {rerender} = render(
<ActionList>
<ActionList.LinkItem href="#home" onClick={originalClick}>
Home
</ActionList.LinkItem>
</ActionList>,
)
const link = screen.getByRole('link', {name: 'Home'})
fireEvent.click(link)

rerender(
<ActionList>
<ActionList.LinkItem href="#docs" onClick={updatedClick}>
Docs
</ActionList.LinkItem>
</ActionList>,
)

expect(screen.getByRole('link', {name: 'Docs'})).toBe(link)
expect(link).toHaveAttribute('href', '#docs')
fireEvent.click(link)
expect(originalClick).toHaveBeenCalledTimes(1)
expect(updatedClick).toHaveBeenCalledTimes(1)
})

it('keeps multiple items isolated and updates their children', () => {
const firstClick = vi.fn()
const secondClick = vi.fn()
const renderItems = (working: boolean) => (
<ActionList>
<ActionList.LinkItem href="#first" onClick={firstClick}>
First
</ActionList.LinkItem>
<ActionList.LinkItem href="#second" onClick={secondClick}>
Second {working ? 'working' : 'completed'}
</ActionList.LinkItem>
</ActionList>
)
const {rerender} = render(renderItems(false))
const first = screen.getByRole('link', {name: 'First'})
const second = screen.getByRole('link', {name: 'Second completed'})
first.focus()
rerender(renderItems(true))

expect(screen.getByRole('link', {name: 'First'})).toBe(first)
expect(screen.getByRole('link', {name: 'Second working'})).toBe(second)
expect(first).toHaveFocus()
expect(first).toHaveAttribute('href', '#first')
expect(second).toHaveAttribute('href', '#second')
fireEvent.click(first)
expect(firstClick).toHaveBeenCalledTimes(1)
expect(secondClick).not.toHaveBeenCalled()
fireEvent.click(second)
expect(secondClick).toHaveBeenCalledTimes(1)
})

it('calls the item handler before the consumer click handler', () => {
const calls: string[] = []
render(
<ActionListContainerContext.Provider value={{afterSelect: () => calls.push('item')}}>
<ActionList>
<ActionList.LinkItem href="#home" onClick={() => calls.push('consumer')}>
Home
</ActionList.LinkItem>
</ActionList>
</ActionListContainerContext.Provider>,
)

fireEvent.click(screen.getByRole('link', {name: 'Home'}))
expect(calls).toEqual(['item', 'consumer'])
})

it('updates custom link props, callback refs, and tooltip text without remounting', () => {
const CustomLink = React.forwardRef<
HTMLAnchorElement,
React.AnchorHTMLAttributes<HTMLAnchorElement> & {destination: string}
>(({destination, children, ...props}, ref) => (
<a {...props} href={destination} ref={ref}>
{children}
</a>
))
CustomLink.displayName = 'CustomLink'
const originalRef = vi.fn()
const updatedRef = vi.fn()
const {rerender} = render(
<ActionList>
<ActionList.LinkItem as={CustomLink} destination="#home" ref={originalRef} _PrivateTooltipText="Go home">
Home
</ActionList.LinkItem>
</ActionList>,
)
const link = screen.getByRole('link', {name: 'Home'})
expect(originalRef).toHaveBeenLastCalledWith(link)

rerender(
<ActionList>
<ActionList.LinkItem as={CustomLink} destination="#docs" ref={updatedRef} _PrivateTooltipText="Read docs">
Home
</ActionList.LinkItem>
</ActionList>,
)

expect(screen.getByRole('link', {name: 'Home'})).toBe(link)
expect(link).toHaveAttribute('href', '#docs')
expect(link).toHaveAccessibleDescription('Read docs')
expect(originalRef).toHaveBeenLastCalledWith(null)
expect(updatedRef).toHaveBeenLastCalledWith(link)
})

it('keeps an open tooltip anchored across prop and layout updates', async () => {
const renderItem = (tooltipText: string, offset: number) => (
<div style={{paddingTop: offset}}>
<ActionList>
<ActionList.LinkItem href="#home" _PrivateTooltipText={tooltipText}>
Home
</ActionList.LinkItem>
</ActionList>
</div>
)
const {rerender} = render(renderItem('Go home', 0))
const link = screen.getByRole('link', {name: 'Home'})
const tooltip = screen.getByText('Go home')
fireEvent.mouseOver(link)
await waitFor(() => expect(tooltip.matches(':popover-open')).toBe(true))
const originalLinkTop = link.getBoundingClientRect().top
const originalTooltipTop = tooltip.getBoundingClientRect().top

rerender(renderItem('Go docs', 0))

expect(screen.getByText('Go docs')).toBe(tooltip)
expect(tooltip.matches(':popover-open')).toBe(true)
expect(link).toHaveAccessibleDescription('Go docs')
expect(link).toHaveAttribute('aria-describedby', tooltip.id)

rerender(renderItem('Go docs', 80))

expect(screen.getByRole('link', {name: 'Home'})).toBe(link)
expect(link.getBoundingClientRect().top - originalLinkTop).toBeCloseTo(80)
expect(tooltip.matches(':popover-open')).toBe(true)
expect(tooltip.getBoundingClientRect().top - originalTooltipTop).toBeCloseTo(80)
fireEvent.mouseLeave(link)
await waitFor(() => expect(tooltip.matches(':popover-open')).toBe(false))
fireEvent.mouseOver(link)
await waitFor(() => expect(tooltip.matches(':popover-open')).toBe(true))
expect(tooltip.getBoundingClientRect().top - originalTooltipTop).toBeCloseTo(80)
})

it('preserves child state and mount effects while allowing child props to update', () => {
const onMount = vi.fn()
const onUnmount = vi.fn()
function StatefulChild({label}: {label: string}) {
const [initialLabel] = React.useState(label)
React.useEffect(() => {
onMount()
return onUnmount
}, [])

return (
<span>
{label}, initially {initialLabel}
</span>
)
}
const renderItem = (label: string) => (
<ActionList>
<ActionList.LinkItem href="#home" onClick={event => event.preventDefault()}>
<StatefulChild label={label} />
</ActionList.LinkItem>
</ActionList>
)
const {rerender, unmount} = render(renderItem('Home'))
const child = screen.getByText('Home, initially Home')
expect(onMount).toHaveBeenCalledTimes(1)

rerender(renderItem('Docs'))

expect(screen.getByText('Docs, initially Home')).toBe(child)
expect(onMount).toHaveBeenCalledTimes(1)
expect(onUnmount).not.toHaveBeenCalled()
unmount()
expect(onUnmount).toHaveBeenCalledTimes(1)
})

it('updates inactive state and restores an active link', () => {
const onClick = vi.fn()
const renderItem = (inactiveText?: string) => (
<ActionList>
<ActionList.LinkItem href="#home" inactiveText={inactiveText} onClick={onClick}>
Home
</ActionList.LinkItem>
</ActionList>
)
const {rerender} = render(renderItem())
rerender(renderItem('Unavailable'))

expect(screen.queryByRole('link')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Home'))
expect(onClick).not.toHaveBeenCalled()

rerender(renderItem())
const link = screen.getByRole('link', {name: 'Home'})
expect(link).toHaveAttribute('href', '#home')
fireEvent.click(link)
expect(onClick).toHaveBeenCalledTimes(1)
})

it('renders as an anchor by default', () => {
render(
<ActionList>
Expand Down
Loading
Loading