From 70d3d23742e1914983cde0c9a16bb36e1a35165a Mon Sep 17 00:00:00 2001 From: Dave Rupert Date: Mon, 4 Nov 2024 15:22:18 -0600 Subject: [PATCH] feat(web-components): add Tooltip component (#32852) --- ...-037cbaec-4ee8-4f24-8cb7-1ae8ea8546f4.json | 7 + .../.storybook/preview-head.html | 8 + packages/web-components/docs/api-report.md | 52 +++++ .../_docs/developer/polyfilling.stories.mdx | 24 +- packages/web-components/src/index-rollup.ts | 1 + packages/web-components/src/index.ts | 7 + packages/web-components/src/tooltip/define.ts | 4 + packages/web-components/src/tooltip/index.ts | 5 + .../src/tooltip/tooltip.definition.ts | 17 ++ .../src/tooltip/tooltip.options.ts | 26 +++ .../src/tooltip/tooltip.spec.ts | 160 +++++++++++++ .../src/tooltip/tooltip.stories.ts | 152 ++++++++++++ .../src/tooltip/tooltip.styles.ts | 108 +++++++++ .../src/tooltip/tooltip.template.ts | 12 + .../web-components/src/tooltip/tooltip.ts | 221 ++++++++++++++++++ 15 files changed, 803 insertions(+), 1 deletion(-) create mode 100644 change/@fluentui-web-components-037cbaec-4ee8-4f24-8cb7-1ae8ea8546f4.json create mode 100644 packages/web-components/.storybook/preview-head.html create mode 100644 packages/web-components/src/tooltip/define.ts create mode 100644 packages/web-components/src/tooltip/index.ts create mode 100644 packages/web-components/src/tooltip/tooltip.definition.ts create mode 100644 packages/web-components/src/tooltip/tooltip.options.ts create mode 100644 packages/web-components/src/tooltip/tooltip.spec.ts create mode 100644 packages/web-components/src/tooltip/tooltip.stories.ts create mode 100644 packages/web-components/src/tooltip/tooltip.styles.ts create mode 100644 packages/web-components/src/tooltip/tooltip.template.ts create mode 100644 packages/web-components/src/tooltip/tooltip.ts diff --git a/change/@fluentui-web-components-037cbaec-4ee8-4f24-8cb7-1ae8ea8546f4.json b/change/@fluentui-web-components-037cbaec-4ee8-4f24-8cb7-1ae8ea8546f4.json new file mode 100644 index 0000000000000..f82c20fa0fa23 --- /dev/null +++ b/change/@fluentui-web-components-037cbaec-4ee8-4f24-8cb7-1ae8ea8546f4.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat: add Tooltip component", + "packageName": "@fluentui/web-components", + "email": "rupertdavid@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/web-components/.storybook/preview-head.html b/packages/web-components/.storybook/preview-head.html new file mode 100644 index 0000000000000..c28e75a00f9c5 --- /dev/null +++ b/packages/web-components/.storybook/preview-head.html @@ -0,0 +1,8 @@ + diff --git a/packages/web-components/docs/api-report.md b/packages/web-components/docs/api-report.md index 6406a392a2b80..6a49a130b2022 100644 --- a/packages/web-components/docs/api-report.md +++ b/packages/web-components/docs/api-report.md @@ -4036,6 +4036,58 @@ export const ToggleButtonStyles: ElementStyles; // @public export const ToggleButtonTemplate: ElementViewTemplate; +// @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; + +// @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; + +// @public +export const TooltipStyles: ElementStyles; + +// @public +export const TooltipTemplate: ViewTemplate; + // 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) diff --git a/packages/web-components/src/_docs/developer/polyfilling.stories.mdx b/packages/web-components/src/_docs/developer/polyfilling.stories.mdx index e5982105337f0..db3e34eed7492 100644 --- a/packages/web-components/src/_docs/developer/polyfilling.stories.mdx +++ b/packages/web-components/src/_docs/developer/polyfilling.stories.mdx @@ -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; +``` diff --git a/packages/web-components/src/index-rollup.ts b/packages/web-components/src/index-rollup.ts index 832770e275eec..33b12fce648d9 100644 --- a/packages/web-components/src/index-rollup.ts +++ b/packages/web-components/src/index-rollup.ts @@ -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'; diff --git a/packages/web-components/src/index.ts b/packages/web-components/src/index.ts index a9f1707983906..1929576ed868b 100644 --- a/packages/web-components/src/index.ts +++ b/packages/web-components/src/index.ts @@ -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, diff --git a/packages/web-components/src/tooltip/define.ts b/packages/web-components/src/tooltip/define.ts new file mode 100644 index 0000000000000..ca1312dd587df --- /dev/null +++ b/packages/web-components/src/tooltip/define.ts @@ -0,0 +1,4 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { definition } from './tooltip.definition.js'; + +definition.define(FluentDesignSystem.registry); diff --git a/packages/web-components/src/tooltip/index.ts b/packages/web-components/src/tooltip/index.ts new file mode 100644 index 0000000000000..f74810684402a --- /dev/null +++ b/packages/web-components/src/tooltip/index.ts @@ -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'; diff --git a/packages/web-components/src/tooltip/tooltip.definition.ts b/packages/web-components/src/tooltip/tooltip.definition.ts new file mode 100644 index 0000000000000..eb88d205fdd88 --- /dev/null +++ b/packages/web-components/src/tooltip/tooltip.definition.ts @@ -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: `` + */ +export const definition = Tooltip.compose({ + name: `${FluentDesignSystem.prefix}-tooltip`, + template, + styles, +}); diff --git a/packages/web-components/src/tooltip/tooltip.options.ts b/packages/web-components/src/tooltip/tooltip.options.ts new file mode 100644 index 0000000000000..cda5ec6fcdbf6 --- /dev/null +++ b/packages/web-components/src/tooltip/tooltip.options.ts @@ -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; diff --git a/packages/web-components/src/tooltip/tooltip.spec.ts b/packages/web-components/src/tooltip/tooltip.spec.ts new file mode 100644 index 0000000000000..f9dbe1aecc107 --- /dev/null +++ b/packages/web-components/src/tooltip/tooltip.spec.ts @@ -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 */ ` +
+ + This is a tooltip +
+ `); + }); + + /** + * 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); + }); +}); diff --git a/packages/web-components/src/tooltip/tooltip.stories.ts b/packages/web-components/src/tooltip/tooltip.stories.ts new file mode 100644 index 0000000000000..29040e851e0bd --- /dev/null +++ b/packages/web-components/src/tooltip/tooltip.stories.ts @@ -0,0 +1,152 @@ +import { html, render, repeat } from '@microsoft/fast-element'; +import { uniqueId } from '@microsoft/fast-web-utilities'; +import { Meta, renderComponent, Story } from '../helpers.stories.js'; +import { definition } from './tooltip.definition.js'; +import { Tooltip } from './tooltip.js'; +import { TooltipPositioningOption } from './tooltip.options.js'; + +const storyTemplate = () => { + const id = uniqueId('anchor-'); + + return html` +
+ Hover me + + ${story => story.slottedContent?.()} + +
+ `; +}; + +export default { + title: 'Components/Tooltip', + component: definition.name, + render: renderComponent(storyTemplate()), + argTypes: { + anchor: { + description: 'The target element for the tooltip to anchor on', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + slottedContent: { + control: false, + description: 'The default slot', + table: { category: 'slots', type: {} }, + }, + delay: { + control: 'number', + description: 'Number of milliseconds to delay the tooltip from showing/hiding on hover. Default is 250ms', + table: { category: 'attributes', type: { summary: 'number' } }, + }, + positioning: { + control: 'select', + description: 'Controls the positioning of the tooltip', + mapping: { '': null, ...Object.keys(TooltipPositioningOption) }, + options: ['', ...Object.keys(TooltipPositioningOption)], + table: { + category: 'attributes', + type: { summary: Object.keys(TooltipPositioningOption).join('|') }, + }, + }, + }, +} as unknown as Meta; + +export const Default: Story = args => { + return renderComponent(html`${render(args, storyTemplate)}`)(args); +}; +Default.args = { + slottedContent: () => html`Really long tooltip content goes here. lorem ipsum dolor sit amet.`, +}; + +const iconArrowRight = (rotation = 0) => html` + +`; + +const iconArrowLeft = (rotation = 0) => html` + +`; + +const iconArrowUp = (rotation = 0) => html` + +`; + +const glyphs = { + 'above-start': iconArrowRight(-90), + above: iconArrowUp(), + 'above-end': iconArrowLeft(90), + 'below-start': iconArrowLeft(-90), + below: iconArrowUp(180), + 'below-end': iconArrowRight(90), + 'before-top': iconArrowLeft(0), + before: iconArrowUp(-90), + 'before-bottom': iconArrowRight(180), + 'after-top': iconArrowRight(), + after: iconArrowUp(90), + 'after-bottom': iconArrowLeft(180), +}; + +const positionButtonTemplate = html` + + ${x => glyphs[x.id as keyof typeof glyphs]} + +`; + +const positionTooltipTemplate = html` + ${x => x.id} +`; + +export const Positioning: Story = renderComponent(html` +
+ +
${repeat(x => x.storyItems, positionButtonTemplate)}
+ + ${repeat(x => x.storyItems, positionTooltipTemplate)} +
+`).bind({}); + +Positioning.args = { + storyItems: Object.keys(TooltipPositioningOption).map(id => ({ id })), +}; diff --git a/packages/web-components/src/tooltip/tooltip.styles.ts b/packages/web-components/src/tooltip/tooltip.styles.ts new file mode 100644 index 0000000000000..3b9327aaabd37 --- /dev/null +++ b/packages/web-components/src/tooltip/tooltip.styles.ts @@ -0,0 +1,108 @@ +import { css } from '@microsoft/fast-element'; +import { display } from '../utils/display.js'; +import { + borderRadiusMedium, + colorNeutralBackground1, + colorNeutralForeground1, + colorNeutralShadowAmbient, + colorNeutralShadowKey, + colorTransparentStroke, + fontFamilyBase, + fontSizeBase200, + lineHeightBase200, + spacingHorizontalMNudge, + spacingHorizontalXS, + spacingVerticalXS, +} from '../theme/design-tokens.js'; +import { TooltipPositioningOption } from './tooltip.options.js'; + +/** + * Styles for the tooltip component + * @public + */ +export const styles = css` + ${display('inline-flex')} + + :host(:not(:popover-open)) { + display: none; + } + + :host { + --position-area: block-start; + --position-try-options: flip-block; + --block-offset: ${spacingVerticalXS}; + --inline-offset: ${spacingHorizontalXS}; + background: ${colorNeutralBackground1}; + border-radius: ${borderRadiusMedium}; + border: 1px solid ${colorTransparentStroke}; + box-sizing: border-box; + color: ${colorNeutralForeground1}; + display: inline-flex; + filter: drop-shadow(0 0 2px ${colorNeutralShadowAmbient}) drop-shadow(0 4px 8px ${colorNeutralShadowKey}); + font-family: ${fontFamilyBase}; + font-size: ${fontSizeBase200}; + inset: unset; + line-height: ${lineHeightBase200}; + margin: unset; /* Remove browser default for [popover] */ + max-width: 240px; + padding: 4px ${spacingHorizontalMNudge} 6px; + position: absolute; + position-area: var(--position-area); + position-try-options: var(--position-try-options); + width: auto; + z-index: 1; + } + + @supports (inset-area: block-start) { + :host { + inset-area: var(--position-area); + position-try-fallbacks: var(--position-try-options); + } + } + + :host(:is([positioning^='above'], [positioning^='below'], :not([positioning]))) { + margin-block: var(--block-offset); + } + + :host(:is([positioning^='before'], [positioning^='after'])) { + margin-inline: var(--inline-offset); + --position-try-options: flip-inline; + } + + :host([positioning='above-start']) { + --position-area: ${TooltipPositioningOption['above-start']}; + } + :host([positioning='above']) { + --position-area: ${TooltipPositioningOption.above}; + } + :host([positioning='above-end']) { + --position-area: ${TooltipPositioningOption['above-end']}; + } + :host([positioning='below-start']) { + --position-area: ${TooltipPositioningOption['below-start']}; + } + :host([positioning='below']) { + --position-area: ${TooltipPositioningOption.below}; + } + :host([positioning='below-end']) { + --position-area: ${TooltipPositioningOption.below}; + } + :host([positioning='before-top']) { + --position-area: ${TooltipPositioningOption['before-top']}; + } + :host([positioning='before']) { + --position-area: ${TooltipPositioningOption.before}; + } + :host([positioning='before-bottom']) { + --position-area: ${TooltipPositioningOption['before-bottom']}; + } + :host([positioning='after-top']) { + --position-area: ${TooltipPositioningOption['after-top']}; + } + :host([positioning='after']) { + --position-area: ${TooltipPositioningOption.after}; + } + :host([positioning='after-bottom']) { + --position-area: ${TooltipPositioningOption['after-bottom']}; + } +`; diff --git a/packages/web-components/src/tooltip/tooltip.template.ts b/packages/web-components/src/tooltip/tooltip.template.ts new file mode 100644 index 0000000000000..769d02ac5da30 --- /dev/null +++ b/packages/web-components/src/tooltip/tooltip.template.ts @@ -0,0 +1,12 @@ +import { html } from '@microsoft/fast-element'; +import type { Tooltip } from './tooltip.js'; + +/** + * Template for the tooltip component + * @public + */ +export const template = html` + +`; diff --git a/packages/web-components/src/tooltip/tooltip.ts b/packages/web-components/src/tooltip/tooltip.ts new file mode 100644 index 0000000000000..5c8e76d6f85d4 --- /dev/null +++ b/packages/web-components/src/tooltip/tooltip.ts @@ -0,0 +1,221 @@ +import { attr, FASTElement, nullableNumberConverter } from '@microsoft/fast-element'; +import { uniqueId } from '@microsoft/fast-web-utilities'; +import type { TooltipPositioningOption } from './tooltip.options.js'; + +const SUPPORTS_CSS_ANCHOR_POSITIONING = CSS.supports('anchor-name: --a'); +const SUPPORTS_HTML_ANCHOR_POSITIONING = 'anchor' in HTMLElement.prototype; + +/** + * A Tooltip Custom HTML Element. + * Based on ARIA APG Tooltip Pattern {@link https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/ } + * @public + */ +export class Tooltip extends FASTElement { + /** + * The attached element internals + */ + public elementInternals = this.attachInternals(); + + /** + * The item ID + * + * @public + * @remarks + * HTML Attribute: id + */ + @attr + public id: string = uniqueId('tooltip-'); + + /** + * Set the delay for the tooltip + */ + @attr({ converter: nullableNumberConverter }) + public delay?: number; + + /** + * The default delay for the tooltip + * @internal + */ + private defaultDelay: number = 250; + + /** + * Set the positioning of the tooltip + */ + @attr + public positioning?: TooltipPositioningOption; + + /** + * The id of the anchor element for the tooltip + */ + @attr + public anchor: string = ''; + + /** + * Reference to the anchor element + * @internal + */ + private get anchorElement(): HTMLElement | null { + const rootNode = this.getRootNode(); + return (rootNode instanceof ShadowRoot ? rootNode : document).getElementById(this.anchor ?? ''); + } + + /** + * Reference to the anchor positioning style element + * @internal + */ + protected anchorPositioningStyleElement: HTMLStyleElement | null = null; + + public constructor() { + super(); + this.elementInternals.role = 'tooltip'; + } + + public connectedCallback(): void { + super.connectedCallback(); + + // If the anchor element is not found, tooltip will not be shown + if (!this.anchorElement) { + return; + } + + // @ts-expect-error - Baseline 2024 + const anchorName = this.anchorElement.style.anchorName || `--${this.anchor}`; + + const describedBy = this.anchorElement.getAttribute('aria-describedby'); + this.anchorElement.setAttribute('aria-describedby', describedBy ? `${describedBy} ${this.id}` : this.id); + + if (SUPPORTS_CSS_ANCHOR_POSITIONING) { + if (!SUPPORTS_HTML_ANCHOR_POSITIONING) { + // @ts-expect-error - Baseline 2024 + this.anchorElement.style.anchorName = anchorName; + // @ts-expect-error - Baseline 2024 + this.style.positionAnchor = anchorName; + } + } else { + // Provide style fallback for browsers that do not support anchor positioning + if (!this.anchorPositioningStyleElement) { + this.anchorPositioningStyleElement = document.createElement('style'); + document.head.append(this.anchorPositioningStyleElement); + } + + // Given a position with - format, return the proper CSS properties + // eslint-disable-next-line prefer-const + let [direction, alignment] = this.positioning?.split('-') ?? []; + + if (alignment === undefined && (direction === 'above' || direction === 'below')) { + alignment = 'centerX'; + } + if (alignment === undefined && (direction === 'before' || direction === 'after')) { + alignment = 'centerY'; + } + + const directionCSSMap = { + above: `bottom: anchor(${anchorName} top);`, + below: `top: anchor(${anchorName} bottom);`, + before: `right: anchor(${anchorName} left);`, + after: `left: anchor(${anchorName} right);`, + } as const; + + type DirectionMapOption = keyof typeof directionCSSMap; + const directionCSS = directionCSSMap[direction as DirectionMapOption] ?? directionCSSMap.above; + + const alignmentCSSMap = { + start: `left: anchor(${anchorName} left);`, + end: `right: anchor(${anchorName} right);`, + top: `top: anchor(${anchorName} top);`, + bottom: `bottom: anchor(${anchorName} bottom);`, + centerX: `left: anchor(${anchorName} center); translate: -50% 0;`, + centerY: `top: anchor(${anchorName} center); translate: 0 -50%;`, + } as const; + + type AlignmentMapOption = keyof typeof alignmentCSSMap; + const alignmentCSS = alignmentCSSMap[alignment as AlignmentMapOption] ?? alignmentCSSMap.centerX; + + this.anchorPositioningStyleElement.textContent = ` + #${this.anchor} { + anchor-name: ${anchorName}; + } + #${this.id} { + inset: unset; + ${directionCSS} + ${alignmentCSS} + position: absolute; + } + `; + + if (window.CSS_ANCHOR_POLYFILL) { + window.CSS_ANCHOR_POLYFILL.call({ element: this.anchorPositioningStyleElement }); + } + } + + this.anchorElement.addEventListener('focus', this.focusAnchorHandler); + this.anchorElement.addEventListener('blur', this.blurAnchorHandler); + this.anchorElement.addEventListener('mouseenter', this.mouseenterAnchorHandler); + this.anchorElement.addEventListener('mouseleave', this.mouseleaveAnchorHandler); + } + + public disconnectedCallback(): void { + super.disconnectedCallback(); + this.anchorElement?.removeEventListener('focus', this.focusAnchorHandler); + this.anchorElement?.removeEventListener('blur', this.blurAnchorHandler); + this.anchorElement?.removeEventListener('mouseenter', this.mouseenterAnchorHandler); + this.anchorElement?.removeEventListener('mouseleave', this.mouseleaveAnchorHandler); + } + + /** + * Shows the tooltip + * @param delay Number of milliseconds to delay showing the tooltip + * @internal + */ + public showTooltip(delay: number = this.defaultDelay): void { + setTimeout(() => { + this.setAttribute('aria-hidden', 'false'); + // @ts-expect-error - Baseline 2024 + this.showPopover(); + }, delay); + } + + /** + * Hide the tooltip + * @param delay Number of milliseconds to delay hiding the tooltip + * @internal + */ + public hideTooltip(delay: number = this.defaultDelay): void { + setTimeout(() => { + // Detect if the tooltip or anchor element is still hovered and enqueue another hide + if (this.matches(':hover') || this.anchorElement?.matches(':hover')) { + this.hideTooltip(delay); + return; + } + + this.setAttribute('aria-hidden', 'true'); + // @ts-expect-error - Baseline 2024 + this.hidePopover(); + }, delay); + } + + /** + * Show the tooltip on mouse enter + */ + public mouseenterAnchorHandler = () => this.showTooltip(this.delay); + /** + * Hide the tooltip on mouse leave + */ + public mouseleaveAnchorHandler = () => this.hideTooltip(this.delay); + /** + * Show the tooltip on focus + */ + public focusAnchorHandler = () => this.showTooltip(0); + /** + * Hide the tooltip on blur + */ + public blurAnchorHandler = () => this.hideTooltip(0); +} + +declare global { + interface Window { + CSS_ANCHOR_POLYFILL?: { + call: (options: { element: HTMLStyleElement }) => void; + }; + } +}