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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';

@github-actions github-actions Bot Aug 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/CalendarCompat 4 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/CalendarCompat.multiDayView - High Contrast.default.chromium.png 1228 Changed
vr-tests-react-components/CalendarCompat.multiDayView.default.chromium_1.png 489 Changed
vr-tests-react-components/CalendarCompat.multiDayView - Dark Mode.default.chromium.png 1103 Changed
vr-tests-react-components/CalendarCompat.multiDayView - RTL.default.chromium.png 494 Changed
vr-tests-react-components/Menu 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Menu.Nested Submenus Small Viewport Stacked.nested menu.chromium.png 841 Changed
vr-tests-react-components/Menu Converged - submenuIndicator slotted content 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Menu Converged - submenuIndicator slotted content.default.submenus open.chromium.png 413 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.chromium.png 957 Changed
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 744 Changed
vr-tests-react-components/ProgressBar converged 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - Dark Mode.default.chromium.png 40 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness.default.chromium.png 27 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - High Contrast.default.chromium.png 35 Changed
vr-tests-react-components/TagPicker 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled - Dark Mode.chromium.png 658 Changed
vr-tests-react-components/TagPicker.disabled.chromium.png 677 Changed
vr-tests-react-components/Tooltip Converged 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Tooltip Converged.secondary content - Dark Mode.default.chromium.png 0 Added
vr-tests-react-components/Tooltip Converged.secondary content - High Contrast.default.chromium.png 0 Added
vr-tests-react-components/Tooltip Converged.secondary content.default.chromium.png 0 Added

There were 3 duplicate changes discarded. Check the build logs for more information.

import type { Meta } from '@storybook/react-webpack5';
import { Tooltip } from '@fluentui/react-tooltip';
import { TextBoldRegular } from '@fluentui/react-icons';

import { useStyles } from './utils';
import { DARK_MODE, getStoryVariant, HIGH_CONTRAST, TestWrapperDecorator } from '../../utilities';
Expand Down Expand Up @@ -29,6 +30,21 @@ export const BasicDarkMode = getStoryVariant(Basic, DARK_MODE);

export const BasicHighContrast = getStoryVariant(Basic, HIGH_CONTRAST);

export const SecondaryContent = () => (
<div className={useStyles().wrapper}>
<Tooltip visible content="Bold" secondaryContent="Ctrl+B" relationship="label">
<button>
<TextBoldRegular />
</button>
</Tooltip>
</div>
);
SecondaryContent.storyName = 'secondary content';

export const SecondaryContentDarkMode = getStoryVariant(SecondaryContent, DARK_MODE);

export const SecondaryContentHighContrast = getStoryVariant(SecondaryContent, HIGH_CONTRAST);

export const Inverted = () => (
<div className={useStyles().wrapper}>
<Tooltip visible appearance="inverted" content="Inverted tooltip" relationship="description">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: add secondary content to Tooltip",
"packageName": "@fluentui/react-tooltip",
"email": "petrduda@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { render, screen } from '@testing-library/react';
import { resetIdsForTests } from '@fluentui/react-utilities';
import { resetIdsForTests, SSRProvider } from '@fluentui/react-utilities';
import { isConformant } from '../../testing/isConformant';
import type { IsConformantOptions } from '@fluentui/react-conformance';
import { Tooltip } from './Tooltip';
Expand Down Expand Up @@ -100,6 +101,80 @@ describe('Tooltip', () => {
expect(target).toHaveAttribute('aria-describedby', tooltip.id);
});

it('renders secondary content', () => {
render(
<Tooltip content="Bold" secondaryContent="Ctrl+B" relationship="label" visible>
<button>Trigger</button>
</Tooltip>,
);

const tooltip = screen.getByRole('tooltip');
const target = screen.getByRole('button');
expect(tooltip).toHaveTextContent('BoldCtrl+B');
expect(tooltip.querySelector('span')).toHaveTextContent('Ctrl+B');
expect(target).toHaveAttribute('aria-label', 'Bold Ctrl+B');
expect(target).not.toHaveAttribute('aria-labelledby');
});

it('includes secondary content in a rich accessible label', () => {
render(
<Tooltip content={<span>Bold</span>} secondaryContent="Ctrl+B" relationship="label">
<button>Trigger</button>
</Tooltip>,
);

const tooltip = screen.getByRole('tooltip');
const target = screen.getByRole('button');
const secondaryContent = tooltip.querySelector(':scope > span');

expect(target).toHaveAttribute('aria-labelledby', tooltip.id);
expect(secondaryContent).not.toHaveAttribute('aria-hidden');
});

it('keeps a string label with secondary content when the trigger popup is expanded', () => {
render(
<Tooltip content="Bold" secondaryContent="Ctrl+B" relationship="label" visible>
<button aria-haspopup="menu" aria-expanded="true">
Trigger
</button>
</Tooltip>,
);

expect(screen.queryByRole('tooltip')).toBeNull();
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Bold Ctrl+B');
});

it('keeps a generic secondary content label when the trigger popup is expanded', () => {
render(
<Tooltip content="Bold" secondaryContent={<span>Ctrl+B</span>} relationship="label" visible>
<button aria-haspopup="menu" aria-expanded="true">
Trigger
</button>
</Tooltip>,
);

const tooltip = document.querySelector('[role="tooltip"]');
const target = screen.getByRole('button');

expect(tooltip).not.toBeNull();
expect(target).not.toHaveAttribute('aria-label');
expect(target).toHaveAttribute('aria-labelledby', tooltip?.id);
expect(tooltip).toHaveTextContent('BoldCtrl+B');
});

it('keeps the primary string label during SSR with generic secondary content', () => {
const html = renderToStaticMarkup(
<SSRProvider>
<Tooltip content="Bold" secondaryContent={<span>Ctrl+B</span>} relationship="label">
<button />
</Tooltip>
</SSRProvider>,
);

expect(html).toContain('aria-label="Bold"');
expect(html).not.toContain('aria-labelledby');
});

it('renders arrow element when withArrow is true', () => {
render(
<Tooltip content="Arrow tooltip" relationship="label" visible withArrow>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@ export const renderTooltip = (state: TooltipState): JSXElement => {
{state.shouldRenderTooltip && (
<state.content>
{state.withArrow && <div ref={state.arrowRef} data-arrow="" />}
{state.content.children}
{state.secondaryContent ? (
<>
<div>{state.content.children}</div>
<state.secondaryContent />
</>
) : (
state.content.children
)}
</state.content>
)}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const useTooltip = (props: TooltipProps): TooltipState => {
const {
children,
content,
secondaryContent,
positioning = 'above',
withArrow = false,
onVisibleChange,
Expand All @@ -44,17 +45,24 @@ export const useTooltip = (props: TooltipProps): TooltipState => {
hideDelay = 250,
} = props;

const child = getTriggerChild(children);
const isPopupExpanded =
child?.props?.['aria-haspopup'] &&
(child?.props?.['aria-expanded'] === true || child?.props?.['aria-expanded'] === 'true');
const renderedVisible = visible && !isPopupExpanded;

const state: TooltipState = {
positioning,
showDelay,
hideDelay,
relationship,
visible,
shouldRenderTooltip: visible,
visible: renderedVisible,
shouldRenderTooltip: renderedVisible,
withArrow,
// Slots
components: {
content: 'div',
secondaryContent: 'span',
},
content: slot.always(content, {
defaultProps: {
Expand All @@ -63,11 +71,13 @@ export const useTooltip = (props: TooltipProps): TooltipState => {
},
elementType: 'div',
}),
secondaryContent: slot.optional(secondaryContent, {
elementType: 'span',
}),
};

const positioningOptions = resolvePositioningShorthand(positioning);
const { targetRef, containerRef } = usePositioning(positioningOptions);

state.content.id = useId('tooltip-', state.content.id);

const contentRef = useMergedRefs(state.content.ref, containerRef);
Expand Down Expand Up @@ -105,7 +115,7 @@ export const useTooltip = (props: TooltipProps): TooltipState => {
el.addEventListener('toggle', onToggle);

try {
if (visible) {
if (state.visible) {
el.showPopover();
} else if (el.matches(':popover-open')) {
el.hidePopover();
Expand All @@ -126,7 +136,7 @@ export const useTooltip = (props: TooltipProps): TooltipState => {
return () => {
el.removeEventListener('toggle', onToggle);
};
}, [contentRef, visible, setVisible, onToggle]);
}, [contentRef, state.visible, setVisible, onToggle]);

// Used to skip showing the tooltip in certain situations when the trigger is focused.
// See comments where this is set for more info.
Expand Down Expand Up @@ -213,16 +223,18 @@ export const useTooltip = (props: TooltipProps): TooltipState => {
// eslint-disable-next-line react-hooks/immutability, react-hooks/refs
state.content.onBlur = mergeCallbacks(state.content.onBlur, onLeaveTrigger);

const child = getTriggerChild(children);

const triggerAriaProps: Pick<TooltipTriggerProps, 'aria-label' | 'aria-labelledby' | 'aria-describedby'> = {};
const isPopupExpanded =
child?.props?.['aria-haspopup'] &&
(child?.props?.['aria-expanded'] === true || child?.props?.['aria-expanded'] === 'true');

if (relationship === 'label') {
// aria-label only works if the content is a string. Otherwise, need to use aria-labelledby.
if (typeof state.content.children === 'string') {
if (
typeof state.content.children === 'string' &&
(!state.secondaryContent || typeof state.secondaryContent.children === 'string')
) {
triggerAriaProps['aria-label'] = state.secondaryContent
? `${state.content.children} ${state.secondaryContent.children}`
: state.content.children;
} else if (isServerSideRender && typeof state.content.children === 'string') {
triggerAriaProps['aria-label'] = state.content.children;
} else {
triggerAriaProps['aria-labelledby'] = state.content.id;
Expand All @@ -237,9 +249,8 @@ export const useTooltip = (props: TooltipProps): TooltipState => {
state.shouldRenderTooltip = true;
}

// Case 1: Don't render the Tooltip in SSR to avoid hydration errors
// Case 2: Don't render the Tooltip, if it triggers Menu or another popup and it's already opened
if (isServerSideRender || isPopupExpanded) {
// Don't render the Tooltip in SSR to avoid hydration errors.
if (isServerSideRender) {
// eslint-disable-next-line react-hooks/immutability
state.shouldRenderTooltip = false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export type TooltipProps = ComponentProps<TooltipSlots> & TriggerProps<TooltipTr
// @public
export type TooltipSlots = {
content: NonNullable<Slot<'div'>>;
secondaryContent?: Slot<'span'>;
};

// @public
Expand All @@ -59,6 +60,8 @@ export type TooltipState = ComponentState<TooltipSlots> & Pick<TooltipProps, 'mo
shouldRenderTooltip?: boolean;
arrowRef?: React_2.Ref<HTMLDivElement>;
arrowClassName?: string;
contentLayoutClassName?: string;
primaryContentClassName?: string;
};

// @public
Expand Down
Loading
Loading