Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
7b11c23
tooltip
liuliu-dev Feb 12, 2026
77c2e16
tests fix
liuliu-dev Feb 12, 2026
73302eb
delay=medium
liuliu-dev Feb 12, 2026
202cf36
use aria-describedby
liuliu-dev Feb 17, 2026
65940fe
Merge branch 'main' into liuliu/add-keyboard-accessible-tooltip-for-a…
liuliu-dev Feb 18, 2026
c409898
conflicts
liuliu-dev Feb 18, 2026
2ddd8a0
Merge branch 'liuliu/add-keyboard-accessible-tooltip-for-actionlist' …
liuliu-dev Feb 18, 2026
5b4e4ff
Merge branch 'main' into liuliu/add-keyboard-accessible-tooltip-for-a…
liuliu-dev Feb 19, 2026
17016c9
useResizeObserver
liuliu-dev Feb 20, 2026
e865090
Merge remote-tracking branch 'origin/main' into liuliu/add-keyboard-a…
liuliu-dev Feb 20, 2026
ea048c3
move tooltip inside <li>, return setTruncatedText undefined
liuliu-dev Feb 20, 2026
2459410
add _privateRenderBeforeTrigger
liuliu-dev Feb 23, 2026
616fe7a
fix aria-labelledby tests
liuliu-dev Feb 23, 2026
0a41460
add tests for button-semantics items and list-semantics items
liuliu-dev Feb 24, 2026
f129c8b
ref fix?
liuliu-dev Feb 24, 2026
39dfd96
format
liuliu-dev Feb 25, 2026
8afa73c
lint fix
liuliu-dev Feb 25, 2026
a0774e2
assignref
liuliu-dev Feb 25, 2026
d56ab27
fix overlay test
liuliu-dev Feb 25, 2026
32d4ffa
lint and format
liuliu-dev Feb 25, 2026
cebfc66
props.ref
liuliu-dev Feb 25, 2026
2bffa4e
Merge branch 'main' into liuliu/add-keyboard-accessible-tooltip-for-a…
liuliu-dev Feb 25, 2026
a174e54
use css instead of changing the elements order, use useMergedRefs
liuliu-dev Feb 26, 2026
8c8faf5
only add tooltip when createListOverlayOpen is false?
liuliu-dev Mar 2, 2026
663aa4b
add handler back
liuliu-dev Mar 2, 2026
eafe84b
remove merge ref and add story
liuliu-dev Mar 2, 2026
aac1a90
fix test
francinelucca Mar 3, 2026
e73079b
remove conditional
francinelucca Mar 3, 2026
de24e06
with a truncated description
liuliu-dev Mar 3, 2026
d609d7c
fix
francinelucca Mar 3, 2026
b17c8fb
remove useResizeObserver
liuliu-dev Mar 3, 2026
3488872
Merge branch 'liuliu/repro-the-memex-failure' into liuliu/add-keyboar…
liuliu-dev Mar 3, 2026
ac08109
add overlay as test instead of story
liuliu-dev Mar 3, 2026
6ba3a74
clean up
liuliu-dev Mar 3, 2026
9b972d0
Merge remote-tracking branch 'origin/main' into liuliu/add-keyboard-a…
liuliu-dev Mar 3, 2026
cd19f03
fix the rerender loop on complex child
liuliu-dev Mar 4, 2026
f0fdba9
add vrt tests
liuliu-dev Mar 5, 2026
7318b64
Merge branch 'main' into liuliu/add-keyboard-accessible-tooltip-for-a…
liuliu-dev Mar 5, 2026
55047cd
use _privateDisableTooltip
liuliu-dev Mar 5, 2026
ee5ec58
test(vrt): update snapshots
liuliu-dev Mar 5, 2026
d364b64
revert to use text because of the aria-describedby issue
liuliu-dev Mar 5, 2026
b3dbbad
Merge branch 'liuliu/add-keyboard-accessible-tooltip-for-actionlist' …
liuliu-dev Mar 5, 2026
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/chatty-paws-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Add keyboard-accessible tooltip for truncated ActionList.Description
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 41 additions & 0 deletions e2e/components/ActionList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ const stories = [
title: 'All combinations',
id: 'components-actionlist-examples--all-combinations',
},
{
title: 'Text Wrap And Truncation',
id: 'components-actionlist-features--text-wrap-and-truncation',
},
] as const

test.describe('ActionList', () => {
Expand All @@ -160,4 +164,41 @@ test.describe('ActionList', () => {
}
})
}

test.describe('Truncated Description Tooltip', () => {
for (const theme of themes) {
test(`inline truncated tooltip on focus @vrt ${theme}`, async ({page}) => {
await visit(page, {
id: 'components-actionlist-features--text-wrap-and-truncation',
globals: {
colorScheme: theme,
},
})

// Focus the item with a simple truncated inline description
const inlineItem = page.getByRole('button', {name: /Inline Description/}).first()
await inlineItem.focus()

// Tooltip uses popover attribute; wait for it to become visible
await expect(page.locator('[popover]:popover-open')).toBeVisible()
await expect(page).toHaveScreenshot(`ActionList.Truncated Inline Tooltip.${theme}.png`)
})

test(`complex truncated tooltip on focus @vrt ${theme}`, async ({page}) => {
await visit(page, {
id: 'components-actionlist-features--text-wrap-and-truncation',
globals: {
colorScheme: theme,
},
})

// Focus the item with truncated description containing complex children (bold/italic)
const complexItem = page.getByRole('button', {name: /Description with truncation and complex children/})
await complexItem.focus()

await expect(page.locator('[popover]:popover-open')).toBeVisible()
await expect(page).toHaveScreenshot(`ActionList.Truncated Complex Tooltip.${theme}.png`)
})
}
})
})
4 changes: 2 additions & 2 deletions packages/react/src/ActionList/ActionList.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -541,12 +541,12 @@
transform: scaleY(1);
}

& + .SubGroup {
& ~ .SubGroup {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious about this change/why its needed 👀

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change fixes a NavList SubGroup collapse failure(https://github.com/primer/react/actions/runs/22242503448/job/64350808521?pr=7529).
The collapse styling used + to find .SubGroup right after .ActionListContent. When Tooltip wraps the trigger, it adds an extra element between them, so .SubGroup is no longer the immediate next sibling and the + selector does not match.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense! My assumption is that there will only be one .SubGroup within the container, so there's no risk of it applying to other sub-groups that are also children of the same container.

display: none;
}

/* show active indicator on parent collapse if child is active */
&:has(+ .SubGroup [data-active='true']) {
&:has(~ .SubGroup [data-active='true']) {
background: var(--control-transparent-bgColor-selected);

& .ItemLabel {
Expand Down
32 changes: 31 additions & 1 deletion packages/react/src/ActionList/ActionList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ describe('ActionList', () => {
expect(container.querySelector('li[aria-disabled="true"]')?.nextElementSibling).toHaveAttribute('tabindex', '0')
})

it('sets title correctly for Description component', () => {
it('sets Description title for button-semantics items (tooltip path)', () => {
const {container} = HTMLRender(
<ActionList>
<ActionList.Item>
Expand All @@ -168,6 +168,36 @@ describe('ActionList', () => {

const descriptions = container.querySelectorAll('[data-component="ActionList.Description"]')

// For button-semantic items, the native title is suppressed in favor of
// a keyboard-accessible Tooltip rendered by the parent Item.
expect(descriptions[0]).toHaveAttribute('title', '')
expect(descriptions[1]).toHaveAttribute('title', '')
expect(descriptions[2]).not.toHaveAttribute('title')
})

it('sets Description title for list-semantics items (no truncation tooltip path)', () => {
const {container} = HTMLRender(
<ActionList role="listbox" selectionVariant="single">
<ActionList.Item>
Option 1<ActionList.Description truncate>Simple string description</ActionList.Description>
</ActionList.Item>
<ActionList.Item>
Option 2
<ActionList.Description truncate>
<span>Complex</span> content
</ActionList.Description>
</ActionList.Item>
<ActionList.Item>
Option 3
<ActionList.Description>
<span>Non-truncated</span> content
</ActionList.Description>
</ActionList.Item>
</ActionList>,
)

const descriptions = container.querySelectorAll('[data-component="ActionList.Description"]')

expect(descriptions[0]).toHaveAttribute('title', 'Simple string description')
expect(descriptions[1]).toHaveAttribute('title', 'Complex content')
expect(descriptions[2]).not.toHaveAttribute('title')
Expand Down
4 changes: 3 additions & 1 deletion packages/react/src/ActionList/Description.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ describe('ActionList.Description', () => {

const description = getByText('Item 1 description')
expect(description.tagName).toBe('DIV')
expect(description).toHaveAttribute('title', 'Item 1 description')
// For button-semantic items, the native title is suppressed in favor of
// a keyboard-accessible Tooltip rendered by the parent Item.
expect(description).toHaveAttribute('title', '')
expect(description).toHaveStyle('flex-basis: auto')
expect(description).toHaveStyle('text-overflow: ellipsis')
expect(description).toHaveStyle('overflow: hidden')
Expand Down
18 changes: 14 additions & 4 deletions packages/react/src/ActionList/Description.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,27 @@ export const Description: FCWithSlotMarker<React.PropsWithChildren<ActionListDes
style,
...props
}) => {
const {blockDescriptionId, inlineDescriptionId} = React.useContext(ItemContext)
const {blockDescriptionId, inlineDescriptionId, setTruncatedText} = React.useContext(ItemContext)
const containerRef = React.useRef<HTMLDivElement>(null)
const [computedTitle, setComputedTitle] = React.useState<string>('')

// Extract text content from rendered DOM for tooltip
React.useEffect(() => {
if (truncate && containerRef.current) {
const textContent = containerRef.current.textContent || ''
const el = containerRef.current
const textContent = el.textContent || ''
setComputedTitle(textContent)
if (setTruncatedText) {
setTruncatedText(
el.scrollWidth > el.clientWidth
? typeof props.children === 'string'
? props.children
: textContent
: undefined,
)
}
}
}, [truncate, props.children])
}, [truncate, props.children, setTruncatedText])

const effectiveTitle = typeof props.children === 'string' ? props.children : computedTitle

Expand All @@ -61,7 +71,7 @@ export const Description: FCWithSlotMarker<React.PropsWithChildren<ActionListDes
id={inlineDescriptionId}
className={clsx(className, classes.Description)}
style={style}
title={effectiveTitle}
title={setTruncatedText ? '' : effectiveTitle}
inline={true}
maxWidth="100%"
data-component="ActionList.Description"
Expand Down
46 changes: 46 additions & 0 deletions packages/react/src/ActionList/Item.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {render as HTMLRender, waitFor, fireEvent} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import React, {type JSX} from 'react'
import {ActionList} from '.'
import {AnchoredOverlay} from '../AnchoredOverlay'
import {BookIcon} from '@primer/octicons-react'
import {implementsClassName} from '../utils/testing'
import classes from './ActionList.module.css'
Expand Down Expand Up @@ -385,4 +386,49 @@ describe('ActionList.Item', () => {
expect(tabs[0].nodeType).toBe(Node.ELEMENT_NODE)
expect(tabs).toHaveLength(3)
})

it('should preserve consumer ref when tooltip wraps trigger', async () => {
const user = userEvent.setup()

function TestComponent() {
const anchorRef = React.useRef<HTMLLIElement>(null)
const [open, setOpen] = React.useState(false)
return (
<>
<ActionList aria-label="Actions">
<ActionList.Item
ref={anchorRef}
onSelect={() => {
setOpen(!open)
}}
>
Convert to issue
<ActionList.Description truncate>
This description gets truncated because it is inline with truncation
</ActionList.Description>
</ActionList.Item>
</ActionList>
<AnchoredOverlay open={open} renderAnchor={null} anchorRef={anchorRef} onClose={() => setOpen(false)}>
<ActionList role="menu" aria-label="Convert to issue menu">
<ActionList.Item role="menuitem">Choose repository</ActionList.Item>
<ActionList.Item role="menuitem">Create issue</ActionList.Item>
</ActionList>
</AnchoredOverlay>
</>
)
}

const {getByText, queryByRole} = HTMLRender(<TestComponent />)

// Overlay should not be visible initially
expect(queryByRole('menu')).not.toBeInTheDocument()

// Click the item to open the anchored overlay
await user.click(getByText('Convert to issue'))

// The overlay should open — this fails if Tooltip overwrites the consumer ref
await waitFor(() => {
expect(queryByRole('menu')).toBeInTheDocument()
})
})
})
Loading
Loading