From 7b11c23088e9887608d28b8198f3370425f4cd8a Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Wed, 11 Feb 2026 16:17:16 -0800 Subject: [PATCH 1/3] tooltip --- .changeset/chatty-paws-attend.md | 5 + packages/react/src/ActionList/Description.tsx | 27 +++- packages/react/src/ActionList/Item.tsx | 144 +++++++++++------- packages/react/src/ActionList/shared.ts | 1 + 4 files changed, 119 insertions(+), 58 deletions(-) create mode 100644 .changeset/chatty-paws-attend.md diff --git a/.changeset/chatty-paws-attend.md b/.changeset/chatty-paws-attend.md new file mode 100644 index 00000000000..2deee72b523 --- /dev/null +++ b/.changeset/chatty-paws-attend.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Add keyboard-accessible tooltip for truncated ActionList.Description diff --git a/packages/react/src/ActionList/Description.tsx b/packages/react/src/ActionList/Description.tsx index dc21f933387..20c37100e0b 100644 --- a/packages/react/src/ActionList/Description.tsx +++ b/packages/react/src/ActionList/Description.tsx @@ -29,7 +29,7 @@ export const Description: FCWithSlotMarker { - const {blockDescriptionId, inlineDescriptionId} = React.useContext(ItemContext) + const {blockDescriptionId, inlineDescriptionId, setTruncatedText} = React.useContext(ItemContext) const containerRef = React.useRef(null) const [computedTitle, setComputedTitle] = React.useState('') @@ -43,6 +43,29 @@ export const Description: FCWithSlotMarker { + 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 ( + {children} + + ) +} + export const SubItem: React.FC = ({children}) => { return <>{children} } @@ -185,6 +210,9 @@ const UnwrappedItem = ( const trailingVisualId = `${itemId}--trailing-visual` const inactiveWarningId = inactive && !showInactiveIndicator ? `${itemId}--warning-message` : undefined + const [truncatedText, setTruncatedText] = React.useState(undefined) + const nativeTitle = truncatedText && !buttonSemantics ? truncatedText : undefined + const DefaultItemWrapper = listSemantics ? DivItemContainerNoBox : ButtonItemContainerNoBox const ItemWrapper = _PrivateItemWrapper || DefaultItemWrapper @@ -248,74 +276,78 @@ const UnwrappedItem = ( inlineDescriptionId, blockDescriptionId, trailingVisualId, + setTruncatedText, }} > -
  • - +
  • - - - - {slots.leadingVisual} - - - - - {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 && Loading} - - {slots.description} - + + - {trailingVisual} + {slots.leadingVisual} - - { - // 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 ? ( - - {inactiveText} + + + + {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 && Loading} - ) : null - } - - - {!inactive && !loading && !menuContext && Boolean(slots.trailingAction) && slots.trailingAction} - {slots.subItem} -
  • + {slots.description} + + + {trailingVisual} + + + { + // 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 ? ( + + {inactiveText} + + ) : null + } +
    + + {!inactive && !loading && !menuContext && Boolean(slots.trailingAction) && slots.trailingAction} + {slots.subItem} + + ) } diff --git a/packages/react/src/ActionList/shared.ts b/packages/react/src/ActionList/shared.ts index f652cf1b19c..af98c9cd30a 100644 --- a/packages/react/src/ActionList/shared.ts +++ b/packages/react/src/ActionList/shared.ts @@ -85,6 +85,7 @@ export type ItemContext = Pick, 'variant' blockDescriptionId?: string trailingVisualId?: string inactive?: boolean + setTruncatedText?: (text: string | undefined) => void } export const ItemContext = React.createContext({}) From 77c2e16370f2b57b1df1248c3f4fb9c86075b783 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Wed, 11 Feb 2026 16:56:20 -0800 Subject: [PATCH 2/3] tests fix --- .../react/src/ActionList/ActionList.test.tsx | 6 +- .../react/src/ActionList/Description.test.tsx | 4 +- packages/react/src/ActionList/Item.tsx | 101 +++++++++--------- 3 files changed, 59 insertions(+), 52 deletions(-) diff --git a/packages/react/src/ActionList/ActionList.test.tsx b/packages/react/src/ActionList/ActionList.test.tsx index c7d58780488..c3afff40a1f 100644 --- a/packages/react/src/ActionList/ActionList.test.tsx +++ b/packages/react/src/ActionList/ActionList.test.tsx @@ -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') }) diff --git a/packages/react/src/ActionList/Description.test.tsx b/packages/react/src/ActionList/Description.test.tsx index a0aabe3bcd5..5312364912e 100644 --- a/packages/react/src/ActionList/Description.test.tsx +++ b/packages/react/src/ActionList/Description.test.tsx @@ -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') diff --git a/packages/react/src/ActionList/Item.tsx b/packages/react/src/ActionList/Item.tsx index d0352ad514f..257ffce2ee4 100644 --- a/packages/react/src/ActionList/Item.tsx +++ b/packages/react/src/ActionList/Item.tsx @@ -17,6 +17,7 @@ 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 @@ -211,7 +212,6 @@ const UnwrappedItem = ( const inactiveWarningId = inactive && !showInactiveIndicator ? `${itemId}--warning-message` : undefined const [truncatedText, setTruncatedText] = React.useState(undefined) - const nativeTitle = truncatedText && !buttonSemantics ? truncatedText : undefined const DefaultItemWrapper = listSemantics ? DivItemContainerNoBox : ButtonItemContainerNoBox @@ -276,7 +276,7 @@ const UnwrappedItem = ( inlineDescriptionId, blockDescriptionId, trailingVisualId, - setTruncatedText, + setTruncatedText: buttonSemantics ? setTruncatedText : undefined, }} > @@ -289,63 +289,66 @@ const UnwrappedItem = ( data-has-subitem={slots.subItem ? true : undefined} data-has-description={slots.description ? true : false} className={clsx(classes.ActionListItem, className)} - title={nativeTitle} > - - - - + - {slots.leadingVisual} - - - - - {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 && Loading} - - {slots.description} - + + - {trailingVisual} + {slots.leadingVisual} - - { - // 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 ? ( - - {inactiveText} + + + + {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 && Loading} - ) : null - } - - - {!inactive && !loading && !menuContext && Boolean(slots.trailingAction) && slots.trailingAction} - {slots.subItem} + {slots.description} + + + {trailingVisual} + + + { + // 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 ? ( + + {inactiveText} + + ) : null + } + + + {!inactive && !loading && !menuContext && Boolean(slots.trailingAction) && slots.trailingAction} + {slots.subItem} + From 73302eb1693ea78636a7c3dcce8e1a8540305c25 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Thu, 12 Feb 2026 10:50:48 -0800 Subject: [PATCH 3/3] delay=medium --- packages/react/src/ActionList/Item.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/ActionList/Item.tsx b/packages/react/src/ActionList/Item.tsx index 257ffce2ee4..9b44317a9d4 100644 --- a/packages/react/src/ActionList/Item.tsx +++ b/packages/react/src/ActionList/Item.tsx @@ -41,7 +41,7 @@ function ConditionalTooltip({ return children } return ( - + {children} )