Skip to content

Commit f7e4092

Browse files
committed
feat(react-headless-components-preview): add Tooltip component
1 parent d44cd64 commit f7e4092

23 files changed

Lines changed: 988 additions & 156 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "feat: add Tooltip component",
4+
"packageName": "@fluentui/react-headless-components-preview",
5+
"email": "dmytrokirpa@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@
264264
"postcss-modules": "4.1.3",
265265
"prettier": "2.8.8",
266266
"progress": "2.0.3",
267-
"puppeteer": "19.6.3",
267+
"puppeteer": "24.42.0",
268268
"raw-loader": "4.0.2",
269269
"react": "19.2.0",
270270
"react-app-polyfill": "2.0.0",
@@ -352,7 +352,7 @@
352352
"esbuild": "0.25.0",
353353
"swc-loader": "^0.2.6",
354354
"prettier": "2.8.8",
355-
"puppeteer": "19.6.3",
355+
"puppeteer": "24.42.0",
356356
"ws": "8.17.1",
357357
"playwright": "1.55.1",
358358
"**/prismjs": "^1.30.0",

packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import * as TabList from '@fluentui/react-headless-components-preview/tab-list';
2727
import * as Textarea from '@fluentui/react-headless-components-preview/textarea';
2828
import * as ToggleButton from '@fluentui/react-headless-components-preview/toggle-button';
2929
import * as Toolbar from '@fluentui/react-headless-components-preview/toolbar';
30+
import * as Tooltip from '@fluentui/react-headless-components-preview/tooltip';
3031

3132
console.log({
3233
Accordion,
@@ -58,6 +59,7 @@ console.log({
5859
Textarea,
5960
ToggleButton,
6061
Toolbar,
62+
Tooltip,
6163
});
6264

6365
export default {

packages/react-components/react-headless-components-preview/library/config/tests.js

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/** Jest test setup file. */
22

33
require('@testing-library/jest-dom');
4+
require('@oddbird/popover-polyfill');
45

56
global.ResizeObserver = class ResizeObserver {
67
observe() {
@@ -36,21 +37,3 @@ if (typeof HTMLDialogElement !== 'undefined') {
3637
};
3738
}
3839
}
39-
40-
// JSDOM does not implement the Popover API yet.
41-
// Provide a minimal test shim so components using showPopover/hidePopover can run in Jest.
42-
if (typeof HTMLElement !== 'undefined') {
43-
const proto = HTMLElement.prototype;
44-
45-
if (!proto.showPopover) {
46-
proto.showPopover = function showPopover() {
47-
/* no-op */
48-
};
49-
}
50-
51-
if (!proto.hidePopover) {
52-
proto.hidePopover = function hidePopover() {
53-
/* no-op */
54-
};
55-
}
56-
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
## API Report File for "@fluentui/react-headless-components-preview"
2+
3+
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
4+
5+
```ts
6+
7+
import type { JSXElement } from '@fluentui/react-utilities';
8+
import { OnVisibleChangeData } from '@fluentui/react-tooltip';
9+
import type { TooltipBaseProps } from '@fluentui/react-tooltip';
10+
import type { TooltipBaseState } from '@fluentui/react-tooltip';
11+
import { TooltipSlots } from '@fluentui/react-tooltip';
12+
import { TooltipTriggerProps } from '@fluentui/react-tooltip';
13+
14+
export { OnVisibleChangeData }
15+
16+
// @public
17+
export const renderTooltip: (state: TooltipState) => JSXElement;
18+
19+
// @public
20+
export const Tooltip: {
21+
(props: TooltipProps): JSXElement;
22+
displayName: string;
23+
};
24+
25+
// @public
26+
export type TooltipProps = Omit<TooltipBaseProps, 'mountNode'>;
27+
28+
export { TooltipSlots }
29+
30+
// @public
31+
export type TooltipState = Omit<TooltipBaseState, 'mountNode'>;
32+
33+
export { TooltipTriggerProps }
34+
35+
// @public
36+
export const useTooltip: (props: TooltipProps) => TooltipState;
37+
38+
// (No @packageDocumentation comment for this package)
39+
40+
```

packages/react-components/react-headless-components-preview/library/package.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,11 @@
5454
"@fluentui/react-spinner": "^9.8.2",
5555
"@fluentui/react-switch": "^9.7.2",
5656
"@fluentui/react-tabs": "^9.12.1",
57+
"@fluentui/react-tabster": "^9.26.14",
5758
"@fluentui/react-tags": "^9.8.1",
5859
"@fluentui/react-textarea": "^9.7.2",
5960
"@fluentui/react-toolbar": "^9.8.0",
61+
"@fluentui/react-tooltip": "^9.10.1",
6062
"@fluentui/react-utilities": "^9.26.3",
6163
"@swc/helpers": "^0.5.1"
6264
},
@@ -259,6 +261,12 @@
259261
"import": "./lib/toolbar.js",
260262
"require": "./lib-commonjs/toolbar.js"
261263
},
264+
"./tooltip": {
265+
"types": "./dist/tooltip.d.ts",
266+
"node": "./lib-commonjs/tooltip.js",
267+
"import": "./lib/tooltip.js",
268+
"require": "./lib-commonjs/tooltip.js"
269+
},
262270
"./package.json": "./package.json"
263271
},
264272
"beachball": {
@@ -268,6 +276,7 @@
268276
]
269277
},
270278
"devDependencies": {
271-
"@fluentui/scripts-cypress": "*"
279+
"@fluentui/scripts-cypress": "*",
280+
"@oddbird/popover-polyfill": "^0.6.1"
272281
}
273282
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import * as React from 'react';
2+
import { render } from '@testing-library/react';
3+
import { resetIdsForTests } from '@fluentui/react-utilities';
4+
import { isConformant } from '../../testing/isConformant';
5+
import type { IsConformantOptions } from '@fluentui/react-conformance';
6+
import type { RenderResult } from '@testing-library/react';
7+
import { Tooltip } from './Tooltip';
8+
9+
function queryByRoleTooltip(result: RenderResult) {
10+
const tooltips = result.baseElement.querySelectorAll('*[role="tooltip"]');
11+
if (!tooltips?.length) {
12+
return null;
13+
} else {
14+
expect(tooltips.length).toBe(1);
15+
return tooltips.item(0) as HTMLElement;
16+
}
17+
}
18+
19+
function getByRoleTooltip(result: RenderResult) {
20+
const tooltip = queryByRoleTooltip(result);
21+
expect(tooltip).not.toBeNull();
22+
return tooltip!;
23+
}
24+
25+
export const getTooltipElement: IsConformantOptions['getTargetElement'] = result => {
26+
return queryByRoleTooltip(result)!;
27+
};
28+
29+
describe('Tooltip', () => {
30+
isConformant({
31+
Component: Tooltip,
32+
displayName: 'Tooltip',
33+
requiredProps: {
34+
content: 'Example tooltip',
35+
relationship: 'label',
36+
children: <button aria-label="trigger" />,
37+
visible: true,
38+
},
39+
getTargetElement: getTooltipElement,
40+
disabledTests: [
41+
// Tooltip is a wrapper with no root DOM element — ref/className tests don't apply
42+
'component-handles-ref',
43+
'component-has-root-ref',
44+
'component-handles-classname',
45+
],
46+
testOptions: {
47+
'consistent-callback-args': {
48+
legacyCallbacks: ['onVisibleChange'],
49+
},
50+
},
51+
});
52+
53+
afterEach(() => {
54+
resetIdsForTests();
55+
});
56+
57+
it('renders trigger and tooltip content with correct positioning attributes', async () => {
58+
const result = render(
59+
<Tooltip
60+
content="Default Tooltip"
61+
relationship="label"
62+
visible
63+
positioning={{ position: 'above', align: 'center' }}
64+
>
65+
<button>Trigger</button>
66+
</Tooltip>,
67+
);
68+
69+
const trigger = result.getByRole('button');
70+
const tooltip = getByRoleTooltip(result);
71+
72+
// Trigger gets aria-label from label relationship.
73+
expect(trigger).toHaveAttribute('aria-label', 'Default Tooltip');
74+
75+
// Content renders with popover API attribute.
76+
expect(tooltip).toHaveAttribute('popover', 'manual');
77+
});
78+
79+
it('renders only aria-label for a simple label tooltip', () => {
80+
const tooltipText = 'The tooltip text';
81+
const result = render(
82+
<Tooltip content={tooltipText} relationship="label">
83+
<button data-testid="the-target">Trigger</button>
84+
</Tooltip>,
85+
);
86+
87+
const tooltip = queryByRoleTooltip(result);
88+
const target = result.getByRole('button');
89+
expect(tooltip).toBeNull();
90+
expect(target.getAttribute('aria-label')).toBe(tooltipText);
91+
});
92+
93+
it('renders the content of a nontrivial label tooltip', () => {
94+
const result = render(
95+
<Tooltip
96+
relationship="label"
97+
content={{
98+
children: (
99+
<span>
100+
This is a <strong>formatted</strong> tooltip
101+
</span>
102+
),
103+
id: 'the-tooltip-id',
104+
}}
105+
>
106+
<button>Trigger</button>
107+
</Tooltip>,
108+
);
109+
110+
const tooltip = getByRoleTooltip(result);
111+
const target = result.getByRole('button');
112+
expect(tooltip.id).toBe('the-tooltip-id');
113+
expect(target.getAttribute('aria-labelledby')).toBe('the-tooltip-id');
114+
});
115+
116+
it('renders a description tooltip content always', () => {
117+
const result = render(
118+
<Tooltip content="Description tooltip" relationship="description">
119+
<button>Trigger</button>
120+
</Tooltip>,
121+
);
122+
123+
const tooltip = getByRoleTooltip(result);
124+
const target = result.getByRole('button');
125+
expect(target.getAttribute('aria-describedby')).toBe(tooltip.id);
126+
});
127+
128+
it('renders arrow element when withArrow is true', () => {
129+
const result = render(
130+
<Tooltip content="Arrow tooltip" relationship="label" visible withArrow>
131+
<button>Trigger</button>
132+
</Tooltip>,
133+
);
134+
135+
const tooltip = getByRoleTooltip(result);
136+
expect(tooltip.querySelector('[data-arrow]')).not.toBeNull();
137+
});
138+
139+
it('does not render arrow element when withArrow is false', () => {
140+
const result = render(
141+
<Tooltip content="No arrow tooltip" relationship="label" visible>
142+
<button>Trigger</button>
143+
</Tooltip>,
144+
);
145+
146+
const tooltip = getByRoleTooltip(result);
147+
expect(tooltip.querySelector('[data-arrow]')).toBeNull();
148+
});
149+
150+
it("doesn't set any aria attributes for relationship='inaccessible'", () => {
151+
const result = render(
152+
<Tooltip content="Inaccessible tooltip" relationship="inaccessible">
153+
<button>Trigger</button>
154+
</Tooltip>,
155+
);
156+
157+
const target = result.getByRole('button');
158+
expect(target.hasAttribute('aria-label')).toBe(false);
159+
expect(target.hasAttribute('aria-labelledby')).toBe(false);
160+
expect(target.hasAttribute('aria-description')).toBe(false);
161+
expect(target.hasAttribute('aria-describedby')).toBe(false);
162+
});
163+
164+
it("doesn't override trigger's aria-label", () => {
165+
const result = render(
166+
<Tooltip content="Label tooltip" relationship="label">
167+
<button aria-label="test-label" />
168+
</Tooltip>,
169+
);
170+
171+
const target = result.getByRole('button');
172+
expect(target.getAttribute('aria-label')).toBe('test-label');
173+
expect(target.getAttribute('aria-labelledby')).toBe(null);
174+
});
175+
176+
it("doesn't override trigger's aria-labelledby", () => {
177+
const result = render(
178+
<Tooltip content="Label tooltip" relationship="label">
179+
<button aria-labelledby="test-labelledby">Trigger</button>
180+
</Tooltip>,
181+
);
182+
183+
const target = result.getByRole('button');
184+
expect(target.getAttribute('aria-labelledby')).toBe('test-labelledby');
185+
});
186+
187+
it("doesn't override trigger's aria-describedby", () => {
188+
const result = render(
189+
<Tooltip content="Description tooltip" relationship="description">
190+
<button aria-describedby="test-describedby">Trigger</button>
191+
</Tooltip>,
192+
);
193+
194+
const target = result.getByRole('button');
195+
expect(target.getAttribute('aria-description')).toBe(null);
196+
expect(target.getAttribute('aria-describedby')).toBe('test-describedby');
197+
});
198+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
'use client';
2+
3+
import type { JSXElement } from '@fluentui/react-utilities';
4+
import { useTooltip } from './useTooltip';
5+
import { renderTooltip } from './renderTooltip';
6+
import type { TooltipProps } from './Tooltip.types';
7+
8+
/**
9+
* Tooltip renders a non-modal floating label or description anchored to a trigger element.
10+
*/
11+
export const Tooltip = (props: TooltipProps): JSXElement => {
12+
const state = useTooltip(props);
13+
return renderTooltip(state);
14+
};
15+
16+
Tooltip.displayName = 'Tooltip';
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { TooltipBaseProps, TooltipBaseState } from '@fluentui/react-tooltip';
2+
3+
export type { OnVisibleChangeData, TooltipSlots, TooltipTriggerProps } from '@fluentui/react-tooltip';
4+
5+
/**
6+
* Props for the Tooltip component.
7+
*
8+
* Reuses Tooltip base props while omitting `mountNode` for the headless preview API surface.
9+
* Positioning is handled by the Tooltip base implementation via `usePositioning` from
10+
* `@fluentui/react-positioning`.
11+
*/
12+
export type TooltipProps = Omit<TooltipBaseProps, 'mountNode'>;
13+
14+
/**
15+
* State used in rendering Tooltip.
16+
*
17+
* Extends Tooltip base state with headless-specific data attributes used for styling hooks.
18+
*/
19+
export type TooltipState = Omit<TooltipBaseState, 'mountNode'>;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export { Tooltip } from './Tooltip';
2+
export type {
3+
OnVisibleChangeData,
4+
TooltipTriggerProps,
5+
TooltipProps,
6+
TooltipSlots,
7+
TooltipState,
8+
} from './Tooltip.types';
9+
export { renderTooltip } from './renderTooltip';
10+
export { useTooltip } from './useTooltip';

0 commit comments

Comments
 (0)