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/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
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
28 changes: 26 additions & 2 deletions packages/react/src/ActionList/Description.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {ItemContext} from './shared'
import classes from './ActionList.module.css'
import {clsx} from 'clsx'
import type {FCWithSlotMarker} from '../utils/types/Slots'
import {useResizeObserver} from '../hooks/useResizeObserver'

export type ActionListDescriptionProps = {
/**
Expand All @@ -29,7 +30,7 @@ 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>('')

Expand All @@ -43,6 +44,29 @@ export const Description: FCWithSlotMarker<React.PropsWithChildren<ActionListDes

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

// Detect truncation and signal to parent Item for Tooltip
const truncateEnabled = truncate && !!setTruncatedText
useResizeObserver(
() => {
const el = containerRef.current
if (!el || !setTruncatedText) return
setTruncatedText(el.scrollWidth > el.clientWidth ? effectiveTitle : undefined)
},
containerRef,
[truncateEnabled, effectiveTitle],
)

// check on initial render
React.useEffect(() => {
if (!truncateEnabled || !containerRef.current) return
const el = containerRef.current
setTruncatedText(el.scrollWidth > el.clientWidth ? effectiveTitle : undefined)

return () => {
setTruncatedText(undefined)
}
}, [truncateEnabled, effectiveTitle, setTruncatedText])

if (variant === 'block' || !truncate) {
return (
<span
Expand All @@ -61,7 +85,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
142 changes: 88 additions & 54 deletions packages/react/src/ActionList/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,37 @@ 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
}

/**
* Stable wrapper that keeps Tooltip in the tree for button-semantic items
* to avoid remount cycles when truncation state changes.
* For non-button-semantic items, renders children directly.
*/
function ConditionalTooltip({
text,
enabled,
children,
}: {
text: string | undefined
enabled: boolean
children: React.ReactElement
}) {
if (!enabled) {
return children
}
return (
<Tooltip text={text || ''} direction="e" delay="medium" _privateDisableTooltip={!text} _privateRenderBeforeTrigger>
{children}
</Tooltip>
)
}

export const SubItem: React.FC<ActionListSubItemProps> = ({children}) => {
return <>{children}</>
}
Expand Down Expand Up @@ -185,6 +211,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 +237,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 +275,7 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
inlineDescriptionId,
blockDescriptionId,
trailingVisualId,
setTruncatedText: buttonSemantics ? setTruncatedText : undefined,
}}
>
<li
Expand All @@ -261,59 +289,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 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
Loading
Loading