-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat(react-headless-components-preview): add Tooltip component #36079
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "type": "patch", | ||
| "comment": "feat: add Tooltip component", | ||
| "packageName": "@fluentui/react-headless-components-preview", | ||
| "email": "dmytrokirpa@microsoft.com", | ||
| "dependentChangeType": "patch" | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
|
@@ -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", | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| "ws": "8.17.1", | ||
| "playwright": "1.55.1", | ||
| "**/prismjs": "^1.30.0", | ||
|
|
||
| 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'); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. polyfills JSDOM with Popover API |
||
|
|
||
| global.ResizeObserver = class ResizeObserver { | ||
| observe() { | ||
|
|
@@ -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') { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. replaced with polyfill
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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"]'); | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can't we just use |
||||||||||||||||||
| 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'); | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| 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); | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| 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); | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
| 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'; | ||
|
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. | ||
|
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'; |
Uh oh!
There was an error while loading. Please reload this page.