Skip to content
Open
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
@@ -0,0 +1,7 @@
{
Comment thread
dmytrokirpa marked this conversation as resolved.
"type": "patch",
"comment": "feat: add Tooltip component",
"packageName": "@fluentui/react-headless-components-preview",
"email": "dmytrokirpa@microsoft.com",
"dependentChangeType": "patch"
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@
"postcss-modules": "4.1.3",
"prettier": "2.8.8",
"progress": "2.0.3",
"puppeteer": "19.6.3",
"puppeteer": "24.42.0",
"raw-loader": "4.0.2",
"react": "19.2.0",
"react-app-polyfill": "2.0.0",
Expand Down Expand Up @@ -352,7 +352,7 @@
"esbuild": "0.25.0",
"swc-loader": "^0.2.6",
"prettier": "2.8.8",
"puppeteer": "19.6.3",
"puppeteer": "24.42.0",
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

puppeteer@19.6.3 used previous versions of chrome/chromium where Popover API is not available, which causes test-ssr failures

"ws": "8.17.1",
"playwright": "1.55.1",
"**/prismjs": "^1.30.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import * as TabList from '@fluentui/react-headless-components-preview/tab-list';
import * as Textarea from '@fluentui/react-headless-components-preview/textarea';
import * as ToggleButton from '@fluentui/react-headless-components-preview/toggle-button';
import * as Toolbar from '@fluentui/react-headless-components-preview/toolbar';
import * as Tooltip from '@fluentui/react-headless-components-preview/tooltip';

console.log({
Accordion,
Expand Down Expand Up @@ -58,6 +59,7 @@ console.log({
Textarea,
ToggleButton,
Toolbar,
Tooltip,
});

export default {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** Jest test setup file. */

require('@testing-library/jest-dom');
require('@oddbird/popover-polyfill');
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

polyfills JSDOM with Popover API


global.ResizeObserver = class ResizeObserver {
observe() {
Expand Down Expand Up @@ -36,21 +37,3 @@ if (typeof HTMLDialogElement !== 'undefined') {
};
}
}

// JSDOM does not implement the Popover API yet.
// Provide a minimal test shim so components using showPopover/hidePopover can run in Jest.
if (typeof HTMLElement !== 'undefined') {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

replaced with polyfill

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

is it not enough for testing to have minimal shim? not sure if we need to bring that polyfill dependency only for testing

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

it's not enough, as if we want to go with this we need to use the same hacks we have in Popover - like setting popover attribute conditionally in useEffect, etc. So I think it's better to have polyfill in tests and simpler/more robust implementation in the component itself

const proto = HTMLElement.prototype;

if (!proto.showPopover) {
proto.showPopover = function showPopover() {
/* no-op */
};
}

if (!proto.hidePopover) {
proto.hidePopover = function hidePopover() {
/* no-op */
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
## API Report File for "@fluentui/react-headless-components-preview"

> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).

```ts

import type { JSXElement } from '@fluentui/react-utilities';
import { OnVisibleChangeData } from '@fluentui/react-tooltip';
import type { TooltipBaseProps } from '@fluentui/react-tooltip';
import type { TooltipBaseState } from '@fluentui/react-tooltip';
import { TooltipSlots } from '@fluentui/react-tooltip';
import { TooltipTriggerProps } from '@fluentui/react-tooltip';

export { OnVisibleChangeData }

// @public
export const renderTooltip: (state: TooltipState) => JSXElement;

// @public
export const Tooltip: {
(props: TooltipProps): JSXElement;
displayName: string;
};

// @public
export type TooltipProps = Omit<TooltipBaseProps, 'mountNode'>;

export { TooltipSlots }

// @public
export type TooltipState = Omit<TooltipBaseState, 'mountNode'>;

export { TooltipTriggerProps }

// @public
export const useTooltip: (props: TooltipProps) => TooltipState;

// (No @packageDocumentation comment for this package)

```
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,11 @@
"@fluentui/react-spinner": "^9.8.2",
"@fluentui/react-switch": "^9.7.2",
"@fluentui/react-tabs": "^9.12.1",
"@fluentui/react-tabster": "^9.26.14",
"@fluentui/react-tags": "^9.8.1",
"@fluentui/react-textarea": "^9.7.2",
"@fluentui/react-toolbar": "^9.8.0",
"@fluentui/react-tooltip": "^9.10.1",
"@fluentui/react-utilities": "^9.26.3",
"@swc/helpers": "^0.5.1"
},
Expand Down Expand Up @@ -259,6 +261,12 @@
"import": "./lib/toolbar.js",
"require": "./lib-commonjs/toolbar.js"
},
"./tooltip": {
"types": "./dist/tooltip.d.ts",
"node": "./lib-commonjs/tooltip.js",
"import": "./lib/tooltip.js",
"require": "./lib-commonjs/tooltip.js"
},
"./package.json": "./package.json"
},
"beachball": {
Expand All @@ -268,6 +276,7 @@
]
},
"devDependencies": {
"@fluentui/scripts-cypress": "*"
"@fluentui/scripts-cypress": "*",
"@oddbird/popover-polyfill": "^0.6.1"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import { resetIdsForTests } from '@fluentui/react-utilities';
import { isConformant } from '../../testing/isConformant';
import type { IsConformantOptions } from '@fluentui/react-conformance';
import type { RenderResult } from '@testing-library/react';
import { Tooltip } from './Tooltip';

function queryByRoleTooltip(result: RenderResult) {
const tooltips = result.baseElement.querySelectorAll('*[role="tooltip"]');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can't we just use screen.queryByRole('tooltip') ?

if (!tooltips?.length) {
return null;
} else {
expect(tooltips.length).toBe(1);
return tooltips.item(0) as HTMLElement;
}
}

function getByRoleTooltip(result: RenderResult) {
const tooltip = queryByRoleTooltip(result);
expect(tooltip).not.toBeNull();
return tooltip!;
}

export const getTooltipElement: IsConformantOptions['getTargetElement'] = result => {
return queryByRoleTooltip(result)!;
};

describe('Tooltip', () => {
isConformant({
Component: Tooltip,
displayName: 'Tooltip',
requiredProps: {
content: 'Example tooltip',
relationship: 'label',
children: <button aria-label="trigger" />,
visible: true,
},
getTargetElement: getTooltipElement,
disabledTests: [
// Tooltip is a wrapper with no root DOM element — ref/className tests don't apply
'component-handles-ref',
'component-has-root-ref',
'component-handles-classname',
],
testOptions: {
'consistent-callback-args': {
legacyCallbacks: ['onVisibleChange'],
},
},
});

afterEach(() => {
resetIdsForTests();
});

it('renders trigger and tooltip content with correct positioning attributes', async () => {
const result = render(
<Tooltip
content="Default Tooltip"
relationship="label"
visible
positioning={{ position: 'above', align: 'center' }}
>
<button>Trigger</button>
</Tooltip>,
);

const trigger = result.getByRole('button');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this can be simplified to:

expect(screen.getByLabelText('Default Tooltip')).toBeInTheDocument();
expect(screen.getByRole('tooltip')).toHaveAttribute('popover', 'manual');

const tooltip = getByRoleTooltip(result);

// Trigger gets aria-label from label relationship.
expect(trigger).toHaveAttribute('aria-label', 'Default Tooltip');

// Content renders with popover API attribute.
expect(tooltip).toHaveAttribute('popover', 'manual');
});

it('renders only aria-label for a simple label tooltip', () => {
const tooltipText = 'The tooltip text';
const result = render(
<Tooltip content={tooltipText} relationship="label">
<button data-testid="the-target">Trigger</button>
</Tooltip>,
);

const tooltip = queryByRoleTooltip(result);
const target = result.getByRole('button');
expect(tooltip).toBeNull();
expect(target.getAttribute('aria-label')).toBe(tooltipText);
});

it('renders the content of a nontrivial label tooltip', () => {
const result = render(
<Tooltip
relationship="label"
content={{
children: (
<span>
This is a <strong>formatted</strong> tooltip
</span>
),
id: 'the-tooltip-id',
}}
>
<button>Trigger</button>
</Tooltip>,
);

const tooltip = getByRoleTooltip(result);
const target = result.getByRole('button');
expect(tooltip.id).toBe('the-tooltip-id');
expect(target.getAttribute('aria-labelledby')).toBe('the-tooltip-id');
});

it('renders a description tooltip content always', () => {
const result = render(
<Tooltip content="Description tooltip" relationship="description">
<button>Trigger</button>
</Tooltip>,
);

const tooltip = getByRoleTooltip(result);
const target = result.getByRole('button');
expect(target.getAttribute('aria-describedby')).toBe(tooltip.id);
});

it('renders arrow element when withArrow is true', () => {
const result = render(
<Tooltip content="Arrow tooltip" relationship="label" visible withArrow>
<button>Trigger</button>
</Tooltip>,
);

const tooltip = getByRoleTooltip(result);
expect(tooltip.querySelector('[data-arrow]')).not.toBeNull();
});

it('does not render arrow element when withArrow is false', () => {
const result = render(
<Tooltip content="No arrow tooltip" relationship="label" visible>
<button>Trigger</button>
</Tooltip>,
);

const tooltip = getByRoleTooltip(result);
expect(tooltip.querySelector('[data-arrow]')).toBeNull();
});

it("doesn't set any aria attributes for relationship='inaccessible'", () => {
const result = render(
<Tooltip content="Inaccessible tooltip" relationship="inaccessible">
<button>Trigger</button>
</Tooltip>,
);

const target = result.getByRole('button');
expect(target.hasAttribute('aria-label')).toBe(false);
expect(target.hasAttribute('aria-labelledby')).toBe(false);
expect(target.hasAttribute('aria-description')).toBe(false);
expect(target.hasAttribute('aria-describedby')).toBe(false);
Comment on lines +158 to +161
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
expect(target.hasAttribute('aria-label')).toBe(false);
expect(target.hasAttribute('aria-labelledby')).toBe(false);
expect(target.hasAttribute('aria-description')).toBe(false);
expect(target.hasAttribute('aria-describedby')).toBe(false);
expect(target).not.toHaveAttribute('aria-label');
expect(target).not.toHaveAttribute('aria-labelledby');
expect(target).not.toHaveAttribute('aria-description');
expect(target).not.toHaveAttribute('aria-describedby');

});

it("doesn't override trigger's aria-label", () => {
const result = render(
<Tooltip content="Label tooltip" relationship="label">
<button aria-label="test-label" />
</Tooltip>,
);

const target = result.getByRole('button');
expect(target.getAttribute('aria-label')).toBe('test-label');
expect(target.getAttribute('aria-labelledby')).toBe(null);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
expect(target.getAttribute('aria-labelledby')).toBe(null);
expect(target).not.toHaveAttribute('aria-labelledby');

});

it("doesn't override trigger's aria-labelledby", () => {
const result = render(
<Tooltip content="Label tooltip" relationship="label">
<button aria-labelledby="test-labelledby">Trigger</button>
</Tooltip>,
);

const target = result.getByRole('button');
expect(target.getAttribute('aria-labelledby')).toBe('test-labelledby');
});

it("doesn't override trigger's aria-describedby", () => {
const result = render(
<Tooltip content="Description tooltip" relationship="description">
<button aria-describedby="test-describedby">Trigger</button>
</Tooltip>,
);

const target = result.getByRole('button');
expect(target.getAttribute('aria-description')).toBe(null);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
expect(target.getAttribute('aria-description')).toBe(null);
expect(target).not.toHaveAttribute('aria-description');

expect(target.getAttribute('aria-describedby')).toBe('test-describedby');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
'use client';

import type { JSXElement } from '@fluentui/react-utilities';
import { useTooltip } from './useTooltip';
import { renderTooltip } from './renderTooltip';
import type { TooltipProps } from './Tooltip.types';

/**
* Tooltip renders a non-modal floating label or description anchored to a trigger element.
*/
export const Tooltip = (props: TooltipProps): JSXElement => {
const state = useTooltip(props);
return renderTooltip(state);
};

Tooltip.displayName = 'Tooltip';
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { TooltipBaseProps, TooltipBaseState } from '@fluentui/react-tooltip';

export type { OnVisibleChangeData, TooltipSlots, TooltipTriggerProps } from '@fluentui/react-tooltip';
Comment thread
dmytrokirpa marked this conversation as resolved.

/**
* Props for the Tooltip component.
*
* Reuses Tooltip base props while omitting `mountNode` for the headless preview API surface.
* Positioning is handled by the Tooltip base implementation via `usePositioning` from
* `@fluentui/react-positioning`.
*/
export type TooltipProps = Omit<TooltipBaseProps, 'mountNode'>;

/**
* State used in rendering Tooltip.
*
* Extends Tooltip base state with headless-specific data attributes used for styling hooks.
Comment thread
dmytrokirpa marked this conversation as resolved.
*/
export type TooltipState = Omit<TooltipBaseState, 'mountNode'>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export { Tooltip } from './Tooltip';
export type {
OnVisibleChangeData,
TooltipTriggerProps,
TooltipProps,
TooltipSlots,
TooltipState,
} from './Tooltip.types';
export { renderTooltip } from './renderTooltip';
export { useTooltip } from './useTooltip';
Loading
Loading