Skip to content

Commit

Permalink
feat(web-components): add Tooltip component (#32852)
Browse files Browse the repository at this point in the history
  • Loading branch information
davatron5000 authored Nov 4, 2024
1 parent b9672fa commit 70d3d23
Show file tree
Hide file tree
Showing 15 changed files with 803 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "feat: add Tooltip component",
"packageName": "@fluentui/web-components",
"email": "[email protected]",
"dependentChangeType": "patch"
}
8 changes: 8 additions & 0 deletions packages/web-components/.storybook/preview-head.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script id="anchor-polyfill" type="module">
if (!CSS.supports('anchor-name: --foo')) {
const { default: applyPolyfill } = await import(
'https://unpkg.com/@oddbird/css-anchor-positioning/dist/css-anchor-positioning-fn.js'
);
window.CSS_ANCHOR_POLYFILL = applyPolyfill;
}
</script>
52 changes: 52 additions & 0 deletions packages/web-components/docs/api-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -4036,6 +4036,58 @@ export const ToggleButtonStyles: ElementStyles;
// @public
export const ToggleButtonTemplate: ElementViewTemplate<ToggleButton>;

// @public
export class Tooltip extends FASTElement {
constructor();
anchor: string;
// @internal
protected anchorPositioningStyleElement: HTMLStyleElement | null;
blurAnchorHandler: () => void;
// (undocumented)
connectedCallback(): void;
delay?: number;
// (undocumented)
disconnectedCallback(): void;
elementInternals: ElementInternals;
focusAnchorHandler: () => void;
// @internal
hideTooltip(delay?: number): void;
id: string;
mouseenterAnchorHandler: () => void;
mouseleaveAnchorHandler: () => void;
positioning?: TooltipPositioningOption;
// @internal
showTooltip(delay?: number): void;
}

// @public
export const TooltipDefinition: FASTElementDefinition<typeof Tooltip>;

// @public
export const TooltipPositioningOption: {
readonly 'above-start': "block-start span-inline-end";
readonly above: "block-start";
readonly 'above-end': "block-start span-inline-start";
readonly 'below-start': "block-end span-inline-end";
readonly below: "block-end";
readonly 'below-end': "block-end span-inline-start";
readonly 'before-top': "inline-start span-block-end";
readonly before: "inline-start";
readonly 'before-bottom': "inline-start span-block-start";
readonly 'after-top': "inline-end span-block-end";
readonly after: "inline-end";
readonly 'after-bottom': "inline-end span-block-start";
};

// @public
export type TooltipPositioningOption = ValuesOf<typeof TooltipPositioningOption>;

// @public
export const TooltipStyles: ElementStyles;

// @public
export const TooltipTemplate: ViewTemplate<Tooltip, any>;

// Warning: (ae-missing-release-tag) "typographyBody1StrongerStyles" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,26 @@ Two lines of global CSS are needed to cleanup a potential positioning side effec

## CSS Anchor Positioning

All [CSS anchor positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning) fallbacks are handled minimally in script and CSS. In the near future, we may recommend the [anchor positioning polyfill](https://github.com/oddbird/css-anchor-positioning) but it doesn't cover all of our use cases at this time.
Menu anchor positioning is handled minimally in a fallback script but for more complex anchor positioned components (Tooltip, Dropdown, Combobox), you will need to implement your own polyfill.

```js
/**
* Client-side Import example with conditional polyfill import for older browsers.
* This MUST be included before Fluent UI.
*/
if (!CSS.supports('anchor-name: --foo')) {
const { default: applyPolyfill } = await import(
'https://unpkg.com/@oddbird/css-anchor-positioning/dist/css-anchor-positioning-fn.js'
);
window.CSS_ANCHOR_POLYFILL = applyPolyfill;
}
```

```js
/**
* NPM Import example where polyfill is bundled into main bundle
* This MUST be included before Fluent UI.
*/
import { default as applyPolyfill } from '@oddbird/css-anchor-positioning/fn';
window.CSS_ANCHOR_POLYFILL = applyPolyfill;
```
1 change: 1 addition & 0 deletions packages/web-components/src/index-rollup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ import './textarea/define.js';
import './text-input/define.js';
import './text/define.js';
import './toggle-button/define.js';
import './tooltip/define.js';
7 changes: 7 additions & 0 deletions packages/web-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,13 @@ export {
ToggleButtonTemplate,
} from './toggle-button/index.js';
export type { ToggleButtonOptions } from './toggle-button/index.js';
export {
Tooltip,
TooltipDefinition,
TooltipPositioningOption,
TooltipStyles,
TooltipTemplate,
} from './tooltip/index.js';
export {
darkModeStylesheetBehavior,
forcedColorsStylesheetBehavior,
Expand Down
4 changes: 4 additions & 0 deletions packages/web-components/src/tooltip/define.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { FluentDesignSystem } from '../fluent-design-system.js';
import { definition } from './tooltip.definition.js';

definition.define(FluentDesignSystem.registry);
5 changes: 5 additions & 0 deletions packages/web-components/src/tooltip/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { definition as TooltipDefinition } from './tooltip.definition.js';
export { Tooltip } from './tooltip.js';
export { TooltipPositioningOption } from './tooltip.options.js';
export { styles as TooltipStyles } from './tooltip.styles.js';
export { template as TooltipTemplate } from './tooltip.template.js';
17 changes: 17 additions & 0 deletions packages/web-components/src/tooltip/tooltip.definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { FluentDesignSystem } from '../fluent-design-system.js';
import { Tooltip } from './tooltip.js';
import { styles } from './tooltip.styles.js';
import { template } from './tooltip.template.js';

/**
* The {@link Tooltip } custom element definition.
*
* @public
* @remarks
* HTML Element: `<fluent-tooltip>`
*/
export const definition = Tooltip.compose({
name: `${FluentDesignSystem.prefix}-tooltip`,
template,
styles,
});
26 changes: 26 additions & 0 deletions packages/web-components/src/tooltip/tooltip.options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { ValuesOf } from '../utils/typings.js';

/**
* The TooltipPositioning options and their corresponding CSS values
* @public
*/
export const TooltipPositioningOption = {
'above-start': 'block-start span-inline-end',
above: 'block-start',
'above-end': 'block-start span-inline-start',
'below-start': 'block-end span-inline-end',
below: 'block-end',
'below-end': 'block-end span-inline-start',
'before-top': 'inline-start span-block-end',
before: 'inline-start',
'before-bottom': 'inline-start span-block-start',
'after-top': 'inline-end span-block-end',
after: 'inline-end',
'after-bottom': 'inline-end span-block-start',
} as const;

/**
* The TooltipPositioning type
* @public
*/
export type TooltipPositioningOption = ValuesOf<typeof TooltipPositioningOption>;
160 changes: 160 additions & 0 deletions packages/web-components/src/tooltip/tooltip.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { test } from '@playwright/test';
import { expect, fixtureURL } from '../helpers.tests.js';
import type { Tooltip } from './tooltip.js';
import type { TooltipPositioningOption } from './tooltip.options.js';

test.describe('Tooltip', () => {
test.beforeEach(async ({ page }) => {
await page.goto(fixtureURL('components-tooltip--docs'));
await page.waitForFunction(() => customElements.whenDefined('fluent-tooltip'));

await page.setContent(/* html */ `
<div style="position: absolute; inset: 200px">
<button id="target">Target</button>
<fluent-tooltip anchor="target">This is a tooltip</fluent-tooltip>
</div>
`);
});

/**
* ARIA APG Tooltip Pattern {@link https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/ }
* ESC dismisses the tooltip.
* The element that serves as the tooltip container has role tooltip.
* The element that triggers the tooltip references the tooltip element with aria-describedby.
*/
test('escape key should hide the tooltip', async ({ page }) => {
const element = page.locator('fluent-tooltip');
const button = page.locator('button');

await button.focus();
await expect(element).toBeVisible();
await page.keyboard.press('Escape');
await expect(element).toBeHidden();
});

test('should have the role set to `tooltip`', async ({ page }) => {
const element = page.locator('fluent-tooltip');
await expect(element).toHaveJSProperty('elementInternals.role', 'tooltip');
});

test('should have the `aria-describedby` attribute set to the tooltip id', async ({ page }) => {
const element = page.locator('fluent-tooltip');
const button = page.locator('button');

await expect(element).toHaveAttribute('id');
const id = await element.evaluate((node: Tooltip) => node.id);
await expect(button).toHaveAttribute('aria-describedby', id);
});

test('should not be visible by default', async ({ page }) => {
const element = page.locator('fluent-tooltip');
await expect(element).toBeHidden();
});

test('should show the tooltip on hover', async ({ page }) => {
const element = page.locator('fluent-tooltip');
const button = page.locator('button');

await expect(element).toBeHidden();
await button.hover();
await expect(element).toBeVisible();
});

test('should show the tooltip on focus', async ({ page }) => {
const element = page.locator('fluent-tooltip');
const button = page.locator('button');

await expect(element).toBeHidden();
await button.focus();
await expect(element).toBeVisible();
await button.blur();
await expect(element).toBeHidden();
});

test('default placement should be set to `above`', async ({ page }) => {
const element = page.locator('fluent-tooltip');
const button = page.locator('button');
await expect(element).not.toHaveAttribute('positioning', 'above');

// show the element to get the position
await button.focus();
await expect(element).toBeVisible();

const buttonTop = await button.evaluate((node: HTMLElement) => node.getBoundingClientRect().top);
const elementBottom = await element.evaluate((node: HTMLElement) => node.getBoundingClientRect().bottom);

await expect(buttonTop).toBeGreaterThan(elementBottom);
});

test('position should be set to `above` when `positioning` is set to `above`', async ({ page }) => {
const element = page.locator('fluent-tooltip');
const button = page.locator('button');

await element.evaluate((node: Tooltip) => {
node.positioning = 'above' as TooltipPositioningOption;
});
await expect(element).toHaveAttribute('positioning', 'above');

// show the element to get the position
await button.focus();

const buttonTop = await button.evaluate((node: HTMLElement) => node.getBoundingClientRect().top);
const elementBottom = await element.evaluate((node: HTMLElement) => node.getBoundingClientRect().bottom);

await expect(buttonTop).toBeGreaterThan(elementBottom);
});

test('position should be set to `below` when `positioning` is set to `below`', async ({ page }) => {
const element = page.locator('fluent-tooltip');
const button = page.locator('button');

await element.evaluate((node: Tooltip) => {
node.positioning = 'below' as TooltipPositioningOption;
});
await expect(element).toHaveAttribute('positioning', 'below');

// show the element to get the position
await button.focus();

const buttonBottom = await button.evaluate((node: HTMLElement) => node.getBoundingClientRect().bottom);
const elementTop = await element.evaluate((node: HTMLElement) => node.getBoundingClientRect().top);

await expect(buttonBottom).toBeLessThan(elementTop);
});

test('position should be set to `before` when `positioning` is set to `before`', async ({ page }) => {
const element = page.locator('fluent-tooltip');
const button = page.locator('button');

await element.evaluate((node: Tooltip) => {
node.positioning = 'before' as TooltipPositioningOption;
});
await expect(element).toHaveAttribute('positioning', 'before');

// show the element to get the position
await button.focus();

const buttonLeft = await button.evaluate((node: HTMLElement) => node.getBoundingClientRect().left);
const elementRight = await element.evaluate((node: HTMLElement) => node.getBoundingClientRect().right);

await expect(buttonLeft).toBeGreaterThan(elementRight);
});

test('position should be set to `after` when `positioning` is set to `after`', async ({ page }) => {
const element = page.locator('fluent-tooltip');
const button = page.locator('button');

await element.evaluate((node: Tooltip) => {
node.positioning = 'after' as TooltipPositioningOption;
});
await expect(element).toHaveAttribute('positioning', 'after');

// show the element to get the position
await button.focus();

const buttonRight = await button.evaluate((node: HTMLElement) => node.getBoundingClientRect().right);
const elementLeft = await element.evaluate((node: HTMLElement) => node.getBoundingClientRect().left);

await expect(buttonRight).toBeLessThan(elementLeft);
});
});
Loading

0 comments on commit 70d3d23

Please sign in to comment.