Skip to content

Commit 6731cf6

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

19 files changed

Lines changed: 829 additions & 1 deletion
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+
}

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 {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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 { ComponentProps } from '@fluentui/react-utilities';
8+
import type { ComponentState } from '@fluentui/react-utilities';
9+
import type { ForwardRefComponent } from '@fluentui/react-utilities';
10+
import type { JSXElement } from '@fluentui/react-utilities';
11+
import { OnVisibleChangeData } from '@fluentui/react-tooltip';
12+
import type { Slot } from '@fluentui/react-utilities';
13+
import type { TooltipBaseProps } from '@fluentui/react-tooltip';
14+
import type { TooltipBaseState } from '@fluentui/react-tooltip';
15+
import type { TooltipSlots as TooltipSlots_2 } from '@fluentui/react-tooltip';
16+
import { TooltipTriggerProps } from '@fluentui/react-tooltip';
17+
18+
export { OnVisibleChangeData }
19+
20+
// @public
21+
export const renderTooltip: (state: TooltipState) => JSXElement;
22+
23+
// @public
24+
export const Tooltip: ForwardRefComponent<TooltipProps>;
25+
26+
// @public
27+
export type TooltipProps = ComponentProps<TooltipSlots> & Omit<TooltipBaseProps, 'mountNode' | 'withArrow'>;
28+
29+
// @public (undocumented)
30+
export type TooltipSlots = TooltipSlots_2 & {
31+
arrow?: Slot<'div'>;
32+
};
33+
34+
// @public
35+
export type TooltipState = ComponentState<TooltipSlots> & Omit<TooltipBaseState, 'mountNode' | 'withArrow'> & {
36+
content: {
37+
'data-position'?: string;
38+
'data-align'?: string;
39+
'data-state'?: 'open' | 'closed';
40+
};
41+
};
42+
43+
export { TooltipTriggerProps }
44+
45+
// @public
46+
export const useTooltip: (props: TooltipProps) => TooltipState;
47+
48+
// (No @packageDocumentation comment for this package)
49+
50+
```

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@
3939
"@fluentui/react-persona": "^9.7.3",
4040
"@fluentui/react-popover": "^9.14.2",
4141
"@fluentui/react-portal": "^9.8.12",
42-
"@fluentui/react-positioning": "^9.22.1",
4342
"@fluentui/react-progress": "^9.5.1",
4443
"@fluentui/react-provider": "^9.22.16",
44+
"@fluentui/react-positioning": "^9.22.1",
4545
"@fluentui/react-radio": "^9.6.2",
4646
"@fluentui/react-rating": "^9.4.1",
4747
"@fluentui/react-search": "^9.4.2",
@@ -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": {
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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.getAttribute('aria-label')).toBe('Default Tooltip');
74+
75+
// Content renders with popover API attribute.
76+
expect(tooltip.getAttribute('popover')).toBe('manual');
77+
78+
// Visibility state data attribute for consumer CSS targeting.
79+
expect(tooltip.getAttribute('data-state')).toBe('open');
80+
});
81+
82+
it('reflects data-state=closed when tooltip is hidden', () => {
83+
const result = render(
84+
<Tooltip content="Tooltip" relationship="description">
85+
<button>Trigger</button>
86+
</Tooltip>,
87+
);
88+
// description relationship always renders the element; visible defaults to false
89+
expect(getByRoleTooltip(result).getAttribute('data-state')).toBe('closed');
90+
});
91+
92+
it('renders only aria-label for a simple label tooltip', () => {
93+
const tooltipText = 'The tooltip text';
94+
const result = render(
95+
<Tooltip content={tooltipText} relationship="label">
96+
<button data-testid="the-target">Trigger</button>
97+
</Tooltip>,
98+
);
99+
100+
const tooltip = queryByRoleTooltip(result);
101+
const target = result.getByRole('button');
102+
expect(tooltip).toBeNull();
103+
expect(target.getAttribute('aria-label')).toBe(tooltipText);
104+
});
105+
106+
it('renders the content of a nontrivial label tooltip', () => {
107+
const result = render(
108+
<Tooltip
109+
relationship="label"
110+
content={{
111+
children: (
112+
<span>
113+
This is a <strong>formatted</strong> tooltip
114+
</span>
115+
),
116+
id: 'the-tooltip-id',
117+
}}
118+
>
119+
<button>Trigger</button>
120+
</Tooltip>,
121+
);
122+
123+
const tooltip = getByRoleTooltip(result);
124+
const target = result.getByRole('button');
125+
expect(tooltip.id).toBe('the-tooltip-id');
126+
expect(target.getAttribute('aria-labelledby')).toBe('the-tooltip-id');
127+
});
128+
129+
it('renders a description tooltip content always', () => {
130+
const result = render(
131+
<Tooltip content="Description tooltip" relationship="description">
132+
<button>Trigger</button>
133+
</Tooltip>,
134+
);
135+
136+
const tooltip = getByRoleTooltip(result);
137+
const target = result.getByRole('button');
138+
expect(target.getAttribute('aria-describedby')).toBe(tooltip.id);
139+
});
140+
141+
it("doesn't set any aria attributes for relationship='inaccessible'", () => {
142+
const result = render(
143+
<Tooltip content="Inaccessible tooltip" relationship="inaccessible">
144+
<button>Trigger</button>
145+
</Tooltip>,
146+
);
147+
148+
const target = result.getByRole('button');
149+
expect(target.hasAttribute('aria-label')).toBe(false);
150+
expect(target.hasAttribute('aria-labelledby')).toBe(false);
151+
expect(target.hasAttribute('aria-description')).toBe(false);
152+
expect(target.hasAttribute('aria-describedby')).toBe(false);
153+
});
154+
155+
it("doesn't override trigger's aria-label", () => {
156+
const result = render(
157+
<Tooltip content="Label tooltip" relationship="label">
158+
<button aria-label="test-label" />
159+
</Tooltip>,
160+
);
161+
162+
const target = result.getByRole('button');
163+
expect(target.getAttribute('aria-label')).toBe('test-label');
164+
expect(target.getAttribute('aria-labelledby')).toBe(null);
165+
});
166+
167+
it("doesn't override trigger's aria-labelledby", () => {
168+
const result = render(
169+
<Tooltip content="Label tooltip" relationship="label">
170+
<button aria-labelledby="test-labelledby">Trigger</button>
171+
</Tooltip>,
172+
);
173+
174+
const target = result.getByRole('button');
175+
expect(target.getAttribute('aria-labelledby')).toBe('test-labelledby');
176+
});
177+
178+
it("doesn't override trigger's aria-describedby", () => {
179+
const result = render(
180+
<Tooltip content="Description tooltip" relationship="description">
181+
<button aria-describedby="test-describedby">Trigger</button>
182+
</Tooltip>,
183+
);
184+
185+
const target = result.getByRole('button');
186+
expect(target.getAttribute('aria-description')).toBe(null);
187+
expect(target.getAttribute('aria-describedby')).toBe('test-describedby');
188+
});
189+
});
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: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { TooltipSlots as TooltipBaseSlots, TooltipBaseProps, TooltipBaseState } from '@fluentui/react-tooltip';
2+
import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities';
3+
4+
export type { OnVisibleChangeData, TooltipTriggerProps } from '@fluentui/react-tooltip';
5+
6+
export type TooltipSlots = TooltipBaseSlots & {
7+
/** An optional slot for the tooltip's arrow element */
8+
arrow?: Slot<'div'>;
9+
};
10+
11+
/**
12+
* Props for the Tooltip component.
13+
*
14+
* Reuses Tooltip base props while omitting `mountNode` for the headless preview API surface.
15+
* Positioning is handled by the Tooltip base implementation via `usePositioning` from
16+
* `@fluentui/react-positioning`.
17+
*/
18+
export type TooltipProps = ComponentProps<TooltipSlots> & Omit<TooltipBaseProps, 'mountNode' | 'withArrow'>;
19+
20+
/**
21+
* State used in rendering Tooltip.
22+
*
23+
* Extends Tooltip base state with headless-specific data attributes used for styling hooks.
24+
*/
25+
export type TooltipState = ComponentState<TooltipSlots> &
26+
Omit<TooltipBaseState, 'mountNode' | 'withArrow'> & {
27+
/**
28+
* Content slot additions consumed by the headless Tooltip wrapper.
29+
*/
30+
content: {
31+
/**
32+
* The positioning `position` value (e.g. `above`) as a data attribute for easy targeting with CSS selectors.
33+
*/
34+
'data-position'?: string;
35+
/**
36+
* The positioning `align` value (e.g. `start`) as a data attribute for easy targeting with CSS selectors.
37+
*/
38+
'data-align'?: string;
39+
/**
40+
* The visibility state of the tooltip as a data attribute for easy targeting with CSS selectors.
41+
*/
42+
'data-state'?: 'open' | 'closed';
43+
};
44+
};
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)