Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 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
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
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
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
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
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()
})
})
})
141 changes: 86 additions & 55 deletions packages/react/src/ActionList/Item.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import React, {type JSX} from 'react'

import {useId} from '../hooks/useId'
import {useSlots} from '../hooks/useSlots'
import {ActionListContainerContext} from './ActionListContainerContext'
Expand All @@ -16,11 +15,35 @@ import VisuallyHidden from '../_VisuallyHidden'
import classes from './ActionList.module.css'
import {clsx} from 'clsx'
import {fixedForwardRef} from '../utils/modern-polymorphic'
import {Tooltip} from '../TooltipV2'
import {TooltipContext} from '../TooltipV2/Tooltip'

type ActionListSubItemProps = {
children?: React.ReactNode
}

/**
* Wraps button-semantic items with Tooltip, disabled when not truncated
* For non-button-semantic items, renders children directly.
*/
const ConditionalTooltip = React.forwardRef<
HTMLElement,
{
text: string | undefined
enabled: boolean
children: React.ReactElement
}
>(function ConditionalTooltip({text, enabled, children}, forwardedRef) {
if (!enabled || !text) {
return children
}
return (
<Tooltip ref={forwardedRef} text={text || ''} direction="e" delay="medium">
{children}
</Tooltip>
)
})

export const SubItem: React.FC<ActionListSubItemProps> = ({children}) => {
return <>{children}</>
}
Expand Down Expand Up @@ -185,6 +208,8 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
const trailingVisualId = `${itemId}--trailing-visual`
const inactiveWarningId = inactive && !showInactiveIndicator ? `${itemId}--warning-message` : undefined

const [truncatedText, setTruncatedText] = React.useState<string | undefined>(undefined)

const DefaultItemWrapper = listSemantics ? DivItemContainerNoBox : ButtonItemContainerNoBox

const ItemWrapper = _PrivateItemWrapper || DefaultItemWrapper
Expand All @@ -209,12 +234,11 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
'data-inactive': inactive ? true : undefined,
'data-loading': loading && !inactive ? true : undefined,
tabIndex: focusable ? undefined : 0,
'aria-labelledby': `${labelId} ${slots.trailingVisual ? trailingVisualId : ''} ${
slots.description && descriptionVariant === 'inline' ? inlineDescriptionId : ''
}`,
'aria-labelledby': `${labelId} ${slots.trailingVisual ? trailingVisualId : ''}`,
'aria-describedby':
[
slots.description && descriptionVariant === 'block' ? blockDescriptionId : undefined,
slots.description && descriptionVariant === 'inline' ? inlineDescriptionId : undefined,
inactiveWarningId ?? undefined,
]
.filter(String)
Expand Down Expand Up @@ -248,6 +272,7 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
inlineDescriptionId,
blockDescriptionId,
trailingVisualId,
setTruncatedText: buttonSemantics ? setTruncatedText : undefined,
}}
>
<li
Expand All @@ -261,59 +286,65 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
data-has-description={slots.description ? true : false}
className={clsx(classes.ActionListItem, className)}
>
<ItemWrapper
{...wrapperProps}
className={classes.ActionListContent}
data-size={size}
// @ts-ignore: ItemWrapper is polymorphic and the ref type depends on the rendered element ('button' or 'li')
ref={forwardedRef}
>
<span className={classes.Spacer} />
<Selection selected={selected} className={classes.LeadingAction} />
<VisualOrIndicator
inactiveText={showInactiveIndicator ? inactiveText : undefined}
itemHasLeadingVisual={Boolean(slots.leadingVisual)}
labelId={labelId}
loading={loading}
position="leading"
<ConditionalTooltip ref={forwardedRef} text={truncatedText} enabled={buttonSemantics}>
<ItemWrapper
{...wrapperProps}
className={classes.ActionListContent}
data-size={size}
// @ts-ignore: ItemWrapper is polymorphic and the ref type depends on the rendered element ('button' or 'li')
ref={forwardedRef}
>
{slots.leadingVisual}
</VisualOrIndicator>
<span className={classes.ActionListSubContent} data-component="ActionList.Item--DividerContainer">
<ConditionalWrapper
if={!!slots.description}
className={classes.ItemDescriptionWrap}
data-description-variant={descriptionVariant}
>
<span id={labelId} className={classes.ItemLabel}>
{childrenWithoutSlots}
{/* Loading message needs to be in here so it is read with the label */}
{/* If the item is inactive, we do not simultaneously announce that it is loading */}
{loading === true && !inactive && <VisuallyHidden>Loading</VisuallyHidden>}
{/* Reset TooltipContext so that child components don't detect
the ConditionalTooltip and suppress their own internal tooltips. */}
<TooltipContext.Provider value={{}}>
<span className={classes.Spacer} />
<Selection selected={selected} className={classes.LeadingAction} />
<VisualOrIndicator
inactiveText={showInactiveIndicator ? inactiveText : undefined}
itemHasLeadingVisual={Boolean(slots.leadingVisual)}
labelId={labelId}
loading={loading}
position="leading"
>
{slots.leadingVisual}
</VisualOrIndicator>
<span className={classes.ActionListSubContent} data-component="ActionList.Item--DividerContainer">
<ConditionalWrapper
if={!!slots.description}
className={classes.ItemDescriptionWrap}
data-description-variant={descriptionVariant}
>
<span id={labelId} className={classes.ItemLabel}>
{childrenWithoutSlots}
{/* Loading message needs to be in here so it is read with the label */}
{/* If the item is inactive, we do not simultaneously announce that it is loading */}
{loading === true && !inactive && <VisuallyHidden>Loading</VisuallyHidden>}
</span>
{slots.description}
</ConditionalWrapper>
<VisualOrIndicator
inactiveText={showInactiveIndicator ? inactiveText : undefined}
itemHasLeadingVisual={Boolean(slots.leadingVisual)}
labelId={labelId}
loading={loading}
position="trailing"
>
{trailingVisual}
</VisualOrIndicator>

{
// If the item is inactive, but it's not in an overlay (e.g. ActionMenu, SelectPanel),
// render the inactive warning message directly in the item.
!showInactiveIndicator && inactiveText ? (
<span className={classes.InactiveWarning} id={inactiveWarningId}>
{inactiveText}
</span>
) : null
}
</span>
{slots.description}
</ConditionalWrapper>
<VisualOrIndicator
inactiveText={showInactiveIndicator ? inactiveText : undefined}
itemHasLeadingVisual={Boolean(slots.leadingVisual)}
labelId={labelId}
loading={loading}
position="trailing"
>
{trailingVisual}
</VisualOrIndicator>

{
// If the item is inactive, but it's not in an overlay (e.g. ActionMenu, SelectPanel),
// render the inactive warning message directly in the item.
!showInactiveIndicator && inactiveText ? (
<span className={classes.InactiveWarning} id={inactiveWarningId}>
{inactiveText}
</span>
) : null
}
</span>
</ItemWrapper>
</TooltipContext.Provider>
</ItemWrapper>
</ConditionalTooltip>
{!inactive && !loading && !menuContext && Boolean(slots.trailingAction) && slots.trailingAction}
{slots.subItem}
</li>
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/ActionList/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export type ItemContext = Pick<ActionListItemProps<React.ElementType>, 'variant'
blockDescriptionId?: string
trailingVisualId?: string
inactive?: boolean
setTruncatedText?: (text: string | undefined) => void
}

export const ItemContext = React.createContext<ItemContext>({})
Expand Down
8 changes: 4 additions & 4 deletions packages/react/src/TooltipV2/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -360,10 +360,10 @@ export const Tooltip: ForwardRefExoticComponent<
<span id={hasAriaLabel ? undefined : tooltipId}>
{text}
{/* There is a bug in Chrome browsers where `aria-hidden` text inside the target of an `aria-labelledby`
still gets included in the accessible label. `KeybindingHint` renders the symbols as `aria-hidden` text
and renders full key names as `VisuallyHidden` text. Due to the browser bug this causes the label text
to duplicate the symbols and key names. To work around this, we exclude the hint from being part of the
label and instead render the plain keybinding description string. */}
still gets included in the accessible label. `KeybindingHint` renders the symbols as `aria-hidden` text
and renders full key names as `VisuallyHidden` text. Due to the browser bug this causes the label text
to duplicate the symbols and key names. To work around this, we exclude the hint from being part of the
label and instead render the plain keybinding description string. */}
<VisuallyHidden>({getAccessibleKeybindingHintString(keybindingHint, isMacOS)})</VisuallyHidden>
</span>
<span className={clsx(classes.KeybindingHintContainer, text && classes.HasTextBefore)} aria-hidden>
Expand Down
Loading