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/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/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 +211,8 @@ const UnwrappedItem = ( const trailingVisualId = `${itemId}--trailing-visual` const inactiveWarningId = inactive && !showInactiveIndicator ? `${itemId}--warning-message` : undefined + const [truncatedText, setTruncatedText] = React.useState(undefined) + const DefaultItemWrapper = listSemantics ? DivItemContainerNoBox : ButtonItemContainerNoBox const ItemWrapper = _PrivateItemWrapper || DefaultItemWrapper @@ -248,74 +276,81 @@ const UnwrappedItem = ( inlineDescriptionId, blockDescriptionId, trailingVisualId, + setTruncatedText: buttonSemantics ? setTruncatedText : undefined, }} > -
  • - +
  • - - - - {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.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} + + + { + // 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 + } - {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} -
  • + + {!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({})