Skip to content

Commit a9bb2bf

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

22 files changed

Lines changed: 826 additions & 19 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": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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 { ForwardRefComponent } from '@fluentui/react-utilities';
8+
import type { JSXElement } from '@fluentui/react-utilities';
9+
import { OnVisibleChangeData } from '@fluentui/react-tooltip';
10+
import type { TooltipBaseProps } from '@fluentui/react-tooltip';
11+
import type { TooltipBaseState } from '@fluentui/react-tooltip';
12+
import { TooltipSlots } from '@fluentui/react-tooltip';
13+
import { TooltipTriggerProps } from '@fluentui/react-tooltip';
14+
15+
export { OnVisibleChangeData }
16+
17+
// @public
18+
export const renderTooltip: (state: TooltipState) => JSXElement;
19+
20+
// @public
21+
export const Tooltip: ForwardRefComponent<TooltipProps>;
22+
23+
// @public
24+
export type TooltipProps = Omit<TooltipBaseProps, 'mountNode'>;
25+
26+
export { TooltipSlots }
27+
28+
// @public
29+
export type TooltipState = Omit<TooltipBaseState, 'mountNode'>;
30+
31+
export { TooltipTriggerProps }
32+
33+
// @public
34+
export const useTooltip: (props: TooltipProps) => TooltipState;
35+
36+
// (No @packageDocumentation comment for this package)
37+
38+
```

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: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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', () => {
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("doesn't set any aria attributes for relationship='inaccessible'", () => {
129+
const result = render(
130+
<Tooltip content="Inaccessible tooltip" relationship="inaccessible">
131+
<button>Trigger</button>
132+
</Tooltip>,
133+
);
134+
135+
const target = result.getByRole('button');
136+
expect(target.hasAttribute('aria-label')).toBe(false);
137+
expect(target.hasAttribute('aria-labelledby')).toBe(false);
138+
expect(target.hasAttribute('aria-description')).toBe(false);
139+
expect(target.hasAttribute('aria-describedby')).toBe(false);
140+
});
141+
142+
it("doesn't override trigger's aria-label", () => {
143+
const result = render(
144+
<Tooltip content="Label tooltip" relationship="label">
145+
<button aria-label="test-label" />
146+
</Tooltip>,
147+
);
148+
149+
const target = result.getByRole('button');
150+
expect(target.getAttribute('aria-label')).toBe('test-label');
151+
expect(target.getAttribute('aria-labelledby')).toBe(null);
152+
});
153+
154+
it("doesn't override trigger's aria-labelledby", () => {
155+
const result = render(
156+
<Tooltip content="Label tooltip" relationship="label">
157+
<button aria-labelledby="test-labelledby">Trigger</button>
158+
</Tooltip>,
159+
);
160+
161+
const target = result.getByRole('button');
162+
expect(target.getAttribute('aria-labelledby')).toBe('test-labelledby');
163+
});
164+
165+
it("doesn't override trigger's aria-describedby", () => {
166+
const result = render(
167+
<Tooltip content="Description tooltip" relationship="description">
168+
<button aria-describedby="test-describedby">Trigger</button>
169+
</Tooltip>,
170+
);
171+
172+
const target = result.getByRole('button');
173+
expect(target.getAttribute('aria-description')).toBe(null);
174+
expect(target.getAttribute('aria-describedby')).toBe('test-describedby');
175+
});
176+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
import type { ForwardRefComponent } from '@fluentui/react-utilities';
5+
import { useTooltip } from './useTooltip';
6+
import { renderTooltip } from './renderTooltip';
7+
import type { TooltipProps } from './Tooltip.types';
8+
9+
/**
10+
* Tooltip renders a non-modal floating label or description anchored to a trigger element.
11+
*/
12+
export const Tooltip: ForwardRefComponent<TooltipProps> = React.forwardRef((props, _ref) => {
13+
const state = useTooltip(props);
14+
return renderTooltip(state);
15+
});
16+
17+
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';
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/** @jsxRuntime automatic */
2+
/** @jsxImportSource @fluentui/react-jsx-runtime */
3+
4+
import { assertSlots } from '@fluentui/react-utilities';
5+
import type { JSXElement } from '@fluentui/react-utilities';
6+
import type { TooltipState, TooltipSlots } from './Tooltip.types';
7+
8+
/**
9+
* Render the final JSX of Tooltip.
10+
*/
11+
export const renderTooltip = (state: TooltipState): JSXElement => {
12+
assertSlots<TooltipSlots>(state);
13+
14+
return (
15+
<>
16+
{state.children}
17+
{state.shouldRenderTooltip && (
18+
<state.content>
19+
{state.withArrow && <div ref={state.arrowRef} data-arrow="" />}
20+
{state.content.children}
21+
</state.content>
22+
)}
23+
</>
24+
);
25+
};

0 commit comments

Comments
 (0)