Skip to content
Draft
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
6 changes: 4 additions & 2 deletions packages/react/src/ActionList/ActionList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,10 @@ describe('ActionList', () => {

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

expect(descriptions[0]).toHaveAttribute('title', 'Simple string description')
expect(descriptions[1]).toHaveAttribute('title', 'Complex content')
// 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')
})

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
27 changes: 25 additions & 2 deletions packages/react/src/ActionList/Description.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,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 +43,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
React.useEffect(() => {
if (!truncate || !containerRef.current || !setTruncatedText) return

function isContentTruncated() {
const el = containerRef.current
if (!el) return false
return el.scrollWidth > el.clientWidth
}

// Check initially
setTruncatedText(isContentTruncated() ? effectiveTitle : undefined)

const observer = new ResizeObserver(() => {
setTruncatedText(isContentTruncated() ? effectiveTitle : undefined)
})
observer.observe(containerRef.current)

return () => {
observer.disconnect()
}
}, [truncate, effectiveTitle, setTruncatedText])

if (variant === 'block' || !truncate) {
return (
<span
Expand All @@ -61,7 +84,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
161 changes: 98 additions & 63 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}>
{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 Down Expand Up @@ -248,74 +276,81 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
inlineDescriptionId,
blockDescriptionId,
trailingVisualId,
setTruncatedText: buttonSemantics ? setTruncatedText : undefined,
}}
>
<li
{...containerProps}
ref={listSemantics ? forwardedRef : null}
data-variant={variant === 'danger' ? variant : undefined}
data-active={active ? true : undefined}
data-inactive={inactiveText ? true : undefined}
data-has-subitem={slots.subItem ? true : undefined}
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}
<ConditionalTooltip text={truncatedText} enabled={buttonSemantics}>
<li
{...containerProps}
ref={listSemantics ? forwardedRef : null}
data-variant={variant === 'danger' ? variant : undefined}
data-active={active ? true : undefined}
data-inactive={inactiveText ? true : undefined}
data-has-subitem={slots.subItem ? true : undefined}
data-has-description={slots.description ? true : false}
className={clsx(classes.ActionListItem, className)}
>
<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}
{/* Reset TooltipContext so that child components (e.g. IconButton inside TrailingAction)
don't detect the outer ConditionalTooltip and suppress their own internal tooltips. */}
<TooltipContext.Provider value={{}}>
<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 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 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>
{!inactive && !loading && !menuContext && Boolean(slots.trailingAction) && slots.trailingAction}
{slots.subItem}
</li>
</ItemWrapper>
{!inactive && !loading && !menuContext && Boolean(slots.trailingAction) && slots.trailingAction}
{slots.subItem}
</TooltipContext.Provider>
</li>
</ConditionalTooltip>
</ItemContext.Provider>
)
}
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