From cb7aa98ccaaad00e9e86b4575ef011986c054d08 Mon Sep 17 00:00:00 2001 From: Stephane Comeau Date: Tue, 22 Sep 2020 12:00:41 -0700 Subject: [PATCH] feat: add tooltip component (#3549) * working * single div mostly works * batch reset requests and improve initialization, feedback tweaks * stub in files * working * working * working * working (but not) * working * working * update scaling mode * working * working * rewire to new anchored region * fixed positioning * change hover query * remove unused observable * add start/end position * fix direction handling * add comments and cleanup * fix merge errors * add definition & config * add code comments, remove an unnecessary function * update font * prettier * style tweaks * add missing release tags * prettier * feedback, update site data * fix example * add link to issue * run prettier * add missing comment * add tests * fix anchored region being opitimized out * run prettier * use getdirection resource * add to read me --- .../fast-components/docs/api-report.md | 5 + .../src/component-definitions.ts | 1 + .../fast-components/src/index.ts | 1 + .../fast-components/src/tooltip/README.md | 4 + .../src/tooltip/fixtures/base.html | 151 ++++++ .../fast-components/src/tooltip/index.ts | 23 + .../src/tooltip/tooltip.definition.ts | 57 +++ .../tooltip/tooltip.open-ui.definition.json | 4 + .../src/tooltip/tooltip.stories.ts | 39 ++ .../src/tooltip/tooltip.styles.ts | 95 ++++ .../fast-foundation/docs/api-report.md | 64 +++ .../fast-foundation/src/index.ts | 1 + .../fast-foundation/src/tooltip/README.md | 34 ++ .../fast-foundation/src/tooltip/index.ts | 2 + .../src/tooltip/tooltip.spec.md | 8 +- .../src/tooltip/tooltip.spec.ts | 289 +++++++++++ .../src/tooltip/tooltip.template.ts | 31 ++ .../fast-foundation/src/tooltip/tooltip.ts | 452 ++++++++++++++++++ .../fast-components/configs/fast-tooltip.ts | 89 ++++ .../app/fast-components/configs/index.ts | 3 + .../fast-tooltip.definition.ts | 62 +++ sites/website/sidebars.js | 1 + 22 files changed, 1413 insertions(+), 3 deletions(-) create mode 100644 packages/web-components/fast-components/src/tooltip/README.md create mode 100644 packages/web-components/fast-components/src/tooltip/fixtures/base.html create mode 100644 packages/web-components/fast-components/src/tooltip/index.ts create mode 100644 packages/web-components/fast-components/src/tooltip/tooltip.definition.ts create mode 100644 packages/web-components/fast-components/src/tooltip/tooltip.open-ui.definition.json create mode 100644 packages/web-components/fast-components/src/tooltip/tooltip.stories.ts create mode 100644 packages/web-components/fast-components/src/tooltip/tooltip.styles.ts create mode 100644 packages/web-components/fast-foundation/src/tooltip/README.md create mode 100644 packages/web-components/fast-foundation/src/tooltip/index.ts rename specs/tooltip.md => packages/web-components/fast-foundation/src/tooltip/tooltip.spec.md (89%) create mode 100644 packages/web-components/fast-foundation/src/tooltip/tooltip.spec.ts create mode 100644 packages/web-components/fast-foundation/src/tooltip/tooltip.template.ts create mode 100644 packages/web-components/fast-foundation/src/tooltip/tooltip.ts create mode 100644 sites/fast-component-explorer/app/fast-components/configs/fast-tooltip.ts create mode 100644 sites/site-utilities/src/definitions/fast-components/fast-tooltip.definition.ts diff --git a/packages/web-components/fast-components/docs/api-report.md b/packages/web-components/fast-components/docs/api-report.md index 054736d7805..7ec04b3e83f 100644 --- a/packages/web-components/fast-components/docs/api-report.md +++ b/packages/web-components/fast-components/docs/api-report.md @@ -30,6 +30,7 @@ import { TabPanel } from '@microsoft/fast-foundation'; import { Tabs } from '@microsoft/fast-foundation'; import { TextArea } from '@microsoft/fast-foundation'; import { TextField } from '@microsoft/fast-foundation'; +import { Tooltip } from '@microsoft/fast-foundation'; import { TreeItem } from '@microsoft/fast-foundation'; import { TreeView } from '@microsoft/fast-foundation'; @@ -533,6 +534,10 @@ export class FASTTextField extends TextField { connectedCallback(): void; } +// @public +export class FASTTooltip extends Tooltip { +} + // @public export class FASTTreeItem extends TreeItem { } diff --git a/packages/web-components/fast-components/src/component-definitions.ts b/packages/web-components/fast-components/src/component-definitions.ts index e6fb71f9758..9e21519ca99 100644 --- a/packages/web-components/fast-components/src/component-definitions.ts +++ b/packages/web-components/fast-components/src/component-definitions.ts @@ -23,5 +23,6 @@ export * from "./tabs/tab.definition"; export * from "./tabs/tabs.definition"; export * from "./text-area/text-area.definition"; export * from "./text-field/text-field.definition"; +export * from "./tooltip/tooltip.definition"; export * from "./tree-item/tree-item.definition"; export * from "./tree-view/tree-view.definition"; diff --git a/packages/web-components/fast-components/src/index.ts b/packages/web-components/fast-components/src/index.ts index ff5581de039..2bc6ec3070a 100644 --- a/packages/web-components/fast-components/src/index.ts +++ b/packages/web-components/fast-components/src/index.ts @@ -23,5 +23,6 @@ export * from "./switch/index"; export * from "./tabs/index"; export * from "./text-area/index"; export * from "./text-field/index"; +export * from "./tooltip/index"; export * from "./tree-view/index"; export * from "./tree-item/index"; diff --git a/packages/web-components/fast-components/src/tooltip/README.md b/packages/web-components/fast-components/src/tooltip/README.md new file mode 100644 index 00000000000..a888563afb6 --- /dev/null +++ b/packages/web-components/fast-components/src/tooltip/README.md @@ -0,0 +1,4 @@ +# fast-tooltip +An implementation of a [tooltip](https://w3c.github.io/aria-practices/#tooltip) web-component. + +For more information view the [component specification](../../../fast-foundation/src/checkbox/tooltip.spec.md). diff --git a/packages/web-components/fast-components/src/tooltip/fixtures/base.html b/packages/web-components/fast-components/src/tooltip/fixtures/base.html new file mode 100644 index 00000000000..d9d13c89e09 --- /dev/null +++ b/packages/web-components/fast-components/src/tooltip/fixtures/base.html @@ -0,0 +1,151 @@ + +

Tooltip

+ +

Default

+
+ + Helpful text is helpful + + + Always visible + + + + anchor + +
+ +

Top

+
+ + Helpful text is helpful + + + + anchor + +
+ +

Right

+
+ + Helpful text is helpful + + + + anchor + +
+ +

Bottom

+
+ + Helpful text is helpful + + + + anchor + +
+ +

Left

+
+ + Helpful text is helpful + + + + anchor + +
+ +

Switch anchors

+
+ + Helpful text is helpful + + + + anchor + + + + anchor + + + + anchor + + + + anchor + + + + anchor + + + + anchor + + + + anchor + + + + anchor + +
+ +

RTL

+
+ + anchor + + + + Left + + + + Right + +
+ +

start/end

+
+ + anchor + + + + Start + + + + End + +
+ +

start/end RTL

+
+ + anchor + + + + Start + + + + End + +
+
diff --git a/packages/web-components/fast-components/src/tooltip/index.ts b/packages/web-components/fast-components/src/tooltip/index.ts new file mode 100644 index 00000000000..91edf692991 --- /dev/null +++ b/packages/web-components/fast-components/src/tooltip/index.ts @@ -0,0 +1,23 @@ +import { customElement } from "@microsoft/fast-element"; +import { Tooltip, TooltipTemplate as template } from "@microsoft/fast-foundation"; +import { TooltipStyles as styles } from "./tooltip.styles"; +import { FASTAnchoredRegion } from "../anchored-region"; + +// prevent tree shaking +FASTAnchoredRegion; + +/** + * The FAST Tooltip Custom Element. Implements {@link @microsoft/fast-foundation#Tooltip}, + * {@link @microsoft/fast-foundation#TooltipTemplate} + * + * + * @public + * @remarks + * HTML Element: \ + */ +@customElement({ + name: "fast-tooltip", + template, + styles, +}) +export class FASTTooltip extends Tooltip {} diff --git a/packages/web-components/fast-components/src/tooltip/tooltip.definition.ts b/packages/web-components/fast-components/src/tooltip/tooltip.definition.ts new file mode 100644 index 00000000000..1ca71c364ee --- /dev/null +++ b/packages/web-components/fast-components/src/tooltip/tooltip.definition.ts @@ -0,0 +1,57 @@ +import { WebComponentDefinition } from "@microsoft/fast-tooling/dist/data-utilities/web-component"; +import { DataType } from "@microsoft/fast-tooling"; +import { TooltipPosition } from "@microsoft/fast-foundation"; + +export const fastTooltipDefinition: WebComponentDefinition = { + version: 1, + tags: [ + { + name: "fast-tooltip", + description: "The FAST tooltip element", + attributes: [ + { + name: "visible", + description: "The visible attribute", + type: DataType.boolean, + default: undefined, + required: false, + }, + { + name: "anchor", + description: "The anchor attribute", + type: DataType.string, + default: undefined, + required: false, + }, + { + name: "delay", + description: "The delay attribute", + type: DataType.number, + default: 300, + required: false, + }, + { + name: "position", + description: "The position attribute", + values: [ + { name: TooltipPosition.top }, + { name: TooltipPosition.right }, + { name: TooltipPosition.bottom }, + { name: TooltipPosition.left }, + { name: TooltipPosition.start }, + { name: TooltipPosition.end }, + ], + type: DataType.string, + default: undefined, + required: false, + }, + ], + slots: [ + { + name: "", + description: "The default slot", + }, + ], + }, + ], +}; diff --git a/packages/web-components/fast-components/src/tooltip/tooltip.open-ui.definition.json b/packages/web-components/fast-components/src/tooltip/tooltip.open-ui.definition.json new file mode 100644 index 00000000000..c268804b39f --- /dev/null +++ b/packages/web-components/fast-components/src/tooltip/tooltip.open-ui.definition.json @@ -0,0 +1,4 @@ +{ + "name": "Tooltip", + "url": "https://fast.design/docs/components/tooltip" +} \ No newline at end of file diff --git a/packages/web-components/fast-components/src/tooltip/tooltip.stories.ts b/packages/web-components/fast-components/src/tooltip/tooltip.stories.ts new file mode 100644 index 00000000000..0bf1f3b3370 --- /dev/null +++ b/packages/web-components/fast-components/src/tooltip/tooltip.stories.ts @@ -0,0 +1,39 @@ +import { STORY_RENDERED } from "@storybook/core-events"; +import addons from "@storybook/addons"; +import { FASTDesignSystemProvider } from "../design-system-provider"; +import TooltipTemplate from "./fixtures/base.html"; +import { FASTTooltip } from "./"; + +// Prevent tree-shaking +FASTTooltip; +FASTDesignSystemProvider; + +addons.getChannel().addListener(STORY_RENDERED, (name: string) => { + if (name.toLowerCase().startsWith("tooltip")) { + connectAnchors(); + } +}); + +function onAnchorMouseEnter(e: MouseEvent): void { + if (e.target === null) { + return; + } + const tooltipInstance: HTMLElement | null = document.getElementById( + "tooltip-anchor-switch" + ); + (tooltipInstance as FASTTooltip).anchorElement = e.target as HTMLElement; +} + +function connectAnchors(): void { + document.querySelectorAll("fast-button").forEach(el => { + if (el !== null && el.id.startsWith("anchor-anchor-switch")) { + (el as HTMLElement).onmouseenter = onAnchorMouseEnter; + } + }); +} + +export default { + title: "Tooltip", +}; + +export const base = () => TooltipTemplate; diff --git a/packages/web-components/fast-components/src/tooltip/tooltip.styles.ts b/packages/web-components/fast-components/src/tooltip/tooltip.styles.ts new file mode 100644 index 00000000000..3cce08ae102 --- /dev/null +++ b/packages/web-components/fast-components/src/tooltip/tooltip.styles.ts @@ -0,0 +1,95 @@ +import { css } from "@microsoft/fast-element"; +import { forcedColorsStylesheetBehavior } from "@microsoft/fast-foundation"; +import { + accentFillActiveBehavior, + accentFillHoverBehavior, + accentFillRestBehavior, + neutralFillHoverBehavior, + neutralFillInputActiveBehavior, + neutralFillInputHoverBehavior, + neutralFillInputRestBehavior, + neutralFillRestBehavior, + neutralFocusBehavior, + neutralForegroundRestBehavior, + neutralOutlineRestBehavior, +} from "../styles/index"; + +export const TooltipStyles = css` + :host { + contain: layout; + overflow: visible; + height: 0; + width: 0; + } + + .tooltip { + box-sizing: border-box; + border-radius: calc(var(--corner-radius) * 1px); + border: calc(var(--outline-width) * 1px) solid ${neutralFocusBehavior.var}; + box-shadow: 0 0 0 1px ${neutralFocusBehavior.var} inset; + background: ${neutralFillRestBehavior.var}; + color: ${neutralForegroundRestBehavior.var}; + padding: 4px; + height: fit-content; + width: fit-content; + font-family: var(--body-font); + font-size: var(--type-ramp-base-font-size); + line-height: var(--type-ramp-base-line-height); + white-space: nowrap; + // TODO: a mechanism to manage z-index across components + // https://github.com/microsoft/fast/issues/3813 + z-index: 10000; + } + + fast-anchored-region { + display: flex; + justify-content: center; + align-items: center; + overflow: visible; + } + + :host(.top) fast-anchored-region, + :host(.bottom) fast-anchored-region { + flex-direction: row; + } + + :host(.right) fast-anchored-region, + :host(.left) fast-anchored-region { + flex-direction: column; + } + + :host(.top) .tooltip { + margin-bottom: 4px; + } + + :host(.bottom) .tooltip { + margin-top: 4px; + } + + :host(.left) .tooltip { + margin-right: 4px; + } + + :host(.right) .tooltip { + margin-left: 4px; + } +`.withBehaviors( + accentFillActiveBehavior, + accentFillHoverBehavior, + accentFillRestBehavior, + neutralFillHoverBehavior, + neutralFillInputActiveBehavior, + neutralFillInputHoverBehavior, + neutralFillInputRestBehavior, + neutralFillRestBehavior, + neutralFocusBehavior, + neutralForegroundRestBehavior, + neutralOutlineRestBehavior, + forcedColorsStylesheetBehavior( + css` + :host([disabled]) { + opacity: 1; + } + ` + ) +); diff --git a/packages/web-components/fast-foundation/docs/api-report.md b/packages/web-components/fast-foundation/docs/api-report.md index 6e63e947304..0e95c0260ce 100644 --- a/packages/web-components/fast-foundation/docs/api-report.md +++ b/packages/web-components/fast-foundation/docs/api-report.md @@ -847,6 +847,70 @@ export enum TextFieldType { url = "url" } +// @public +export class Tooltip extends FASTElement { + anchor: string; + anchorElement: HTMLElement | null; + // (undocumented) + connectedCallback(): void; + // @internal + currentDirection: Direction; + delay: number; + // (undocumented) + disconnectedCallback(): void; + // @internal + handlePositionChange: (ev: Event) => void; + // @internal (undocumented) + horizontalDefaultPosition: string | undefined; + // @internal (undocumented) + horizontalInset: string; + // @internal (undocumented) + horizontalPositioningMode: AxisPositioningMode; + // Warning: (ae-forgotten-export) The symbol "AxisScalingMode" needs to be exported by the entry point index.d.ts + // + // @internal (undocumented) + horizontalScaling: AxisScalingMode; + position: TooltipPosition; + // Warning: (ae-forgotten-export) The symbol "AnchoredRegion" needs to be exported by the entry point index.d.ts + // + // @internal + region: AnchoredRegion; + // @internal (undocumented) + tooltipVisible: boolean; + // @internal (undocumented) + verticalDefaultPosition: string | undefined; + // @internal (undocumented) + verticalInset: string; + // Warning: (ae-forgotten-export) The symbol "AxisPositioningMode" needs to be exported by the entry point index.d.ts + // + // @internal (undocumented) + verticalPositioningMode: AxisPositioningMode; + // @internal (undocumented) + verticalScaling: AxisScalingMode; + // @internal + viewportElement: HTMLElement | null; + visible: boolean; + } + +// @public +export enum TooltipPosition { + // (undocumented) + bottom = "bottom", + // (undocumented) + end = "end", + // (undocumented) + left = "left", + // (undocumented) + right = "right", + // (undocumented) + start = "start", + // (undocumented) + top = "top" +} + +// @public +export const TooltipTemplate: import("@microsoft/fast-element").ViewTemplate; + // Warning: (ae-different-release-tags) This symbol has another declaration with a different release tag // Warning: (ae-internal-mixed-release-tag) Mixed release tags are not allowed for "TreeItem" because one of its declarations is marked as @internal // diff --git a/packages/web-components/fast-foundation/src/index.ts b/packages/web-components/fast-foundation/src/index.ts index c0d62bce7b9..88761dda8ff 100644 --- a/packages/web-components/fast-foundation/src/index.ts +++ b/packages/web-components/fast-foundation/src/index.ts @@ -28,6 +28,7 @@ export * from "./text-area/index"; export * from "./text-field/index"; export * from "./tree-item/index"; export * from "./tree-view/index"; +export * from "./tooltip/index"; // export our utilities export * from "./utilities/index"; diff --git a/packages/web-components/fast-foundation/src/tooltip/README.md b/packages/web-components/fast-foundation/src/tooltip/README.md new file mode 100644 index 00000000000..f2c0b865592 --- /dev/null +++ b/packages/web-components/fast-foundation/src/tooltip/README.md @@ -0,0 +1,34 @@ +--- +id: fast-tooltip +title: fast-tooltip +sidebar_label: fast-tooltip +custom_edit_url: https://github.com/microsoft/fast-dna/edit/master/packages/web-components/fast-foundation/src/tooltip/README.md + +## Usage + +```html live + + + + helpful text + + +``` +--- + +## Applying custom styles + +```ts +import { customElement } from "@microsoft/fast-element"; +import { Tooltip, TooltipTemplate as template } from "@microsoft/fast-foundation"; +import { TooltipStyles as styles } from "./tooltip.styles"; + +@customElement({ + name: "fast-tooltip", + template, + styles, +}) +export class FASTTooltip extends Tooltip {} +``` \ No newline at end of file diff --git a/packages/web-components/fast-foundation/src/tooltip/index.ts b/packages/web-components/fast-foundation/src/tooltip/index.ts new file mode 100644 index 00000000000..e7a57867127 --- /dev/null +++ b/packages/web-components/fast-foundation/src/tooltip/index.ts @@ -0,0 +1,2 @@ +export * from "./tooltip.template"; +export * from "./tooltip"; diff --git a/specs/tooltip.md b/packages/web-components/fast-foundation/src/tooltip/tooltip.spec.md similarity index 89% rename from specs/tooltip.md rename to packages/web-components/fast-foundation/src/tooltip/tooltip.spec.md index 994b88b9c4d..b0564e383f3 100644 --- a/specs/tooltip.md +++ b/packages/web-components/fast-foundation/src/tooltip/tooltip.spec.md @@ -30,12 +30,14 @@ Tooltip widgets do not receive focus. A hover that contains focusable elements c *Attributes:* - `anchor` - The html id of the HTMLElement which the tooltip is attached to. - `delay` - time in milliseconds to wait before showing and hiding the tooltip. Defaults to 300. -- `visible` - the visiblity of the tooltip -- `position` - enum; where the tooltip should appear relative to its target. +- `visible` - boolean value to toggle the visibility of the tooltip (defaults to undefined). +- `position` - enum; where the tooltip should appear relative to its target. 'start' and 'end' are like 'left' and 'right' but are inverted when the direction is 'rtl' When the position is undefined the tooltip is placed above or below the anchor based on available space. + - top - bottom - left - right - - top + - start + - end *Properties:* - `anchorElement` - Holds a reference to the HTMLElement currently being used as the anchor. Can be set directly or be populated by setting the anchor attribute. diff --git a/packages/web-components/fast-foundation/src/tooltip/tooltip.spec.ts b/packages/web-components/fast-foundation/src/tooltip/tooltip.spec.ts new file mode 100644 index 00000000000..4de561e8f5c --- /dev/null +++ b/packages/web-components/fast-foundation/src/tooltip/tooltip.spec.ts @@ -0,0 +1,289 @@ +import { expect } from "chai"; +import { customElement, DOM, html } from "@microsoft/fast-element"; +import { fixture } from "../fixture"; +import { Tooltip, TooltipTemplate as template } from "./index"; +import { TooltipPosition } from './tooltip'; +import { delay } from 'lodash-es'; + +@customElement({ + name: "fast-tooltip", + template, +}) +class FASTTooltip extends Tooltip {} + +async function setup() { + + const { element, connect, disconnect } = await fixture(html` +
+ + + helpful text + +
+ `); + return { element, connect, disconnect }; +} + +describe("Tooltip", () => { + it("should not render the toolip by default", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + tooltip.delay = 0; + + await connect(); + await DOM.nextUpdate(); + + expect(tooltip.tooltipVisible).to.equal(false); + expect(tooltip.shadowRoot?.querySelector("fast-anchored-region")).to.equal(null); + + await disconnect(); + }); + + it("should render the toolip when visible is true", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + + tooltip.visible = true; + tooltip.delay = 0; + + await connect(); + await DOM.nextUpdate(); + + expect(tooltip.tooltipVisible).to.equal(true); + expect(tooltip.shadowRoot?.querySelector("fast-anchored-region")).not.to.equal(null); + + await disconnect(); + }); + + it("should not render the toolip when visible is false", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + + tooltip.visible = false; + tooltip.delay = 0; + + await connect(); + await DOM.nextUpdate(); + + expect(tooltip.tooltipVisible).to.equal(false); + expect(tooltip.shadowRoot?.querySelector("fast-anchored-region")).to.equal(null); + + await disconnect(); + }); + + it("should set positioning mode to dynamic by default", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + + await connect(); + + expect(tooltip.verticalPositioningMode).to.equal("dynamic"); + expect(tooltip.horizontalPositioningMode).to.equal("dynamic"); + + await disconnect(); + }); + + it("should not set a default position by default", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + + await connect(); + + expect(tooltip.verticalDefaultPosition).to.equal(undefined); + expect(tooltip.horizontalDefaultPosition).to.equal(undefined); + + await disconnect(); + }); + + it("should set horizontal scaling to match anchor and vertical scaling to match content by default", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + + await connect(); + + expect(tooltip.verticalScaling).to.equal("content"); + expect(tooltip.horizontalScaling).to.equal("anchor"); + + await disconnect(); + }); + + // top position settings + + it("should set vertical positioning mode to locked and horizontal to dynamic when position is set to top", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + + tooltip.position = TooltipPosition.top; + + await connect(); + + expect(tooltip.verticalPositioningMode).to.equal("locktodefault"); + expect(tooltip.horizontalPositioningMode).to.equal("dynamic"); + + await disconnect(); + }); + + it("should set default vertical position to top when position is set to top", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + + tooltip.position = TooltipPosition.top; + + await connect(); + + expect(tooltip.verticalDefaultPosition).to.equal("top"); + expect(tooltip.horizontalDefaultPosition).to.equal(undefined); + + await disconnect(); + }); + + it("should set horizontal scaling to match anchor and vertical scaling to match content when position is set to top", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + + tooltip.position = TooltipPosition.top; + + await connect(); + + expect(tooltip.verticalScaling).to.equal("content"); + expect(tooltip.horizontalScaling).to.equal("anchor"); + + await disconnect(); + }); + + // bottom position settings + + it("should set vertical positioning mode to locked and horizontal to dynamic when position is set to bottom", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + + tooltip.position = TooltipPosition.bottom; + + await connect(); + + expect(tooltip.verticalPositioningMode).to.equal("locktodefault"); + expect(tooltip.horizontalPositioningMode).to.equal("dynamic"); + + await disconnect(); + }); + + it("should set default vertical position to top when position is set to top", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + + tooltip.position = TooltipPosition.bottom; + + await connect(); + + expect(tooltip.verticalDefaultPosition).to.equal("bottom"); + expect(tooltip.horizontalDefaultPosition).to.equal(undefined); + + await disconnect(); + }); + + it("should set horizontal scaling to match anchor and vertical scaling to match content when position is set to bottom", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + + tooltip.position = TooltipPosition.bottom; + + await connect(); + + expect(tooltip.verticalScaling).to.equal("content"); + expect(tooltip.horizontalScaling).to.equal("anchor"); + + await disconnect(); + }); + + // left position settings + + it("should set horizontal positioning mode to locked and vertical to dynamic when position is set to left", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + + tooltip.position = TooltipPosition.left; + + await connect(); + + expect(tooltip.verticalPositioningMode).to.equal("dynamic"); + expect(tooltip.horizontalPositioningMode).to.equal("locktodefault"); + + await disconnect(); + }); + + it("should set default horizontal position to left when position is set to left", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + + tooltip.position = TooltipPosition.left; + + await connect(); + + expect(tooltip.verticalDefaultPosition).to.equal(undefined); + expect(tooltip.horizontalDefaultPosition).to.equal("left"); + + await disconnect(); + }); + + it("should set vertical scaling to match anchor and horizontal scaling to match content when position is set to bottom", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + + tooltip.position = TooltipPosition.left; + + await connect(); + + expect(tooltip.verticalScaling).to.equal("anchor"); + expect(tooltip.horizontalScaling).to.equal("content"); + + await disconnect(); + }); + + // right position settings + + it("should set horizontal positioning mode to locked and vertical to dynamic when position is set to right", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + + tooltip.position = TooltipPosition.right; + + await connect(); + + expect(tooltip.verticalPositioningMode).to.equal("dynamic"); + expect(tooltip.horizontalPositioningMode).to.equal("locktodefault"); + + await disconnect(); + }); + + it("should set default horizontal position to right when position is set to right", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + + tooltip.position = TooltipPosition.right; + + await connect(); + + expect(tooltip.verticalDefaultPosition).to.equal(undefined); + expect(tooltip.horizontalDefaultPosition).to.equal("right"); + + await disconnect(); + }); + + it("should set vertical scaling to match anchor and horizontal scaling to match content when position is set to rig", async () => { + const { element, connect, disconnect } = await setup(); + const tooltip: FASTTooltip = element.querySelector("fast-tooltip") as FASTTooltip; + + tooltip.position = TooltipPosition.right; + + await connect(); + + expect(tooltip.verticalScaling).to.equal("anchor"); + expect(tooltip.horizontalScaling).to.equal("content"); + + await disconnect(); + }); + +}); diff --git a/packages/web-components/fast-foundation/src/tooltip/tooltip.template.ts b/packages/web-components/fast-foundation/src/tooltip/tooltip.template.ts new file mode 100644 index 00000000000..060fce22faa --- /dev/null +++ b/packages/web-components/fast-foundation/src/tooltip/tooltip.template.ts @@ -0,0 +1,31 @@ +import { html, ref, when } from "@microsoft/fast-element"; +import { Tooltip } from "./tooltip"; + +/** + * The template for the {@link @microsoft/fast-foundation#(Tooltip:class)} component. + * @public + */ +export const TooltipTemplate = html` + ${when( + x => x.tooltipVisible, + html` + x.currentDirection} + vertical-positioning-mode=${x => x.verticalPositioningMode} + vertical-default-position=${x => x.verticalDefaultPosition} + vertical-inset=${x => x.verticalInset} + vertical-scaling=${x => x.verticalScaling} + horizontal-positioning-mode=${x => x.horizontalPositioningMode} + horizontal-default-position=${x => x.horizontalDefaultPosition} + horizontal-scaling=${x => x.horizontalScaling} + horizontal-inset=${x => x.horizontalInset} + fixed-placement="true" + > + + + ` + )} +`; diff --git a/packages/web-components/fast-foundation/src/tooltip/tooltip.ts b/packages/web-components/fast-foundation/src/tooltip/tooltip.ts new file mode 100644 index 00000000000..6d6f4517ed1 --- /dev/null +++ b/packages/web-components/fast-foundation/src/tooltip/tooltip.ts @@ -0,0 +1,452 @@ +import { attr, DOM, FASTElement, observable } from "@microsoft/fast-element"; +import { AnchoredRegion, AxisPositioningMode, AxisScalingMode } from "../anchored-region"; +import { Direction, keyCodeEscape } from "@microsoft/fast-web-utilities"; +import { getDirection } from "../utilities/"; + +/** + * Enumerates possible tooltip positions + * + * @public + */ +export enum TooltipPosition { + top = "top", + right = "right", + bottom = "bottom", + left = "left", + start = "start", + end = "end", +} + +/** + * An Tooltip Custom HTML Element. + * + * @public + */ +export class Tooltip extends FASTElement { + private static DirectionAttributeName: string = "dir"; + + /** + * Whether the tooltip is visible or not. + * If undefined tooltip is shown when anchor element is hovered + * + * @defaultValue - undefined + * @public + * HTML Attribute: visible + */ + @attr({ mode: "boolean" }) + public visible: boolean; + private visibleChanged(): void { + if ((this as FASTElement).$fastController.isConnected) { + this.updateTooltipVisibility(); + this.updateLayout(); + } + } + + /** + * The id of the element the tooltip is anchored to + * + * @defaultValue - undefined + * @public + * HTML Attribute: anchor + */ + @attr + public anchor: string = ""; + private anchorChanged(): void { + if ((this as FASTElement).$fastController.isConnected) { + this.updateLayout(); + } + } + + /** + * The delay in milliseconds before a tooltip is shown after a hover event + * + * @defaultValue - 300 + * @public + * HTML Attribute: delay + */ + @attr + public delay: number = 300; + + /** + * Controls the placement of the tooltip relative to the anchor. + * When the position is undefined the tooltip is placed above or below the anchor based on available space. + * + * @defaultValue - undefined + * @public + * HTML Attribute: position + */ + @attr + public position: TooltipPosition; + private positionChanged(): void { + if ((this as FASTElement).$fastController.isConnected) { + this.updateLayout(); + } + } + + /** + * the html element currently being used as anchor. + * Setting this directly overrides the anchor attribute. + * + * @public + */ + @observable + public anchorElement: HTMLElement | null = null; + private anchorElementChanged(oldValue: HTMLElement | null): void { + if ((this as FASTElement).$fastController.isConnected) { + if (oldValue !== null && oldValue !== undefined) { + oldValue.removeEventListener("mouseover", this.handleAnchorMouseOver); + oldValue.removeEventListener("mouseout", this.handleAnchorMouseOut); + } + + if (this.anchorElement !== null && this.anchorElement !== undefined) { + this.anchorElement.addEventListener( + "mouseover", + this.handleAnchorMouseOver, + { passive: true } + ); + this.anchorElement.addEventListener( + "mouseout", + this.handleAnchorMouseOut, + { passive: true } + ); + + const anchorId: string = this.anchorElement.id; + + if (this.anchorElement.parentElement !== null) { + this.anchorElement.parentElement + .querySelectorAll(":hover") + .forEach(element => { + if (element.id === anchorId) { + this.startHoverTimer(); + } + }); + } + } + + if ( + this.region !== null && + this.region !== undefined && + this.tooltipVisible + ) { + this.region.anchorElement = this.anchorElement; + } + + this.updateLayout(); + } + } + + /** + * The current viewport element instance + * + * @internal + */ + @observable + public viewportElement: HTMLElement | null = null; + private viewportElementChanged(): void { + if (this.region !== null && this.region !== undefined) { + this.region.viewportElement = this.viewportElement; + } + this.updateLayout(); + } + + /** + * @internal + */ + @observable + public verticalPositioningMode: AxisPositioningMode = "dynamic"; + + /** + * @internal + */ + @observable + public horizontalPositioningMode: AxisPositioningMode = "dynamic"; + + /** + * @internal + */ + @observable + public horizontalInset: string = "true"; + + /** + * @internal + */ + @observable + public verticalInset: string = "false"; + + /** + * @internal + */ + @observable + public horizontalScaling: AxisScalingMode = "anchor"; + + /** + * @internal + */ + @observable + public verticalScaling: AxisScalingMode = "content"; + + /** + * @internal + */ + @observable + public verticalDefaultPosition: string | undefined = undefined; + + /** + * @internal + */ + @observable + public horizontalDefaultPosition: string | undefined = undefined; + + /** + * @internal + */ + @observable + public tooltipVisible: boolean = false; + + /** + * Track current direction to pass to the anchored region + * updated when tooltip is shown + * + * @internal + */ + @observable + public currentDirection: Direction = Direction.ltr; + + /** + * reference to the anchored region + * + * @internal + */ + public region: AnchoredRegion; + + /** + * The timer that tracks delay time before the tooltip is shown on hover + */ + private delayTimer: number | null = null; + + /** + * Indicates whether the anchor is currently being hovered + */ + private isAnchorHovered: boolean = false; + + public connectedCallback(): void { + super.connectedCallback(); + this.anchorElement = this.getAnchor(); + + this.updateLayout(); + this.updateTooltipVisibility(); + } + + public disconnectedCallback(): void { + this.hideTooltip(); + this.clearDelayTimer(); + super.disconnectedCallback(); + } + + /** + * invoked when the anchored region's position relative to the anchor changes + * + * @internal + */ + public handlePositionChange = (ev: Event): void => { + this.classList.toggle("top", this.region.verticalPosition === "top"); + this.classList.toggle("bottom", this.region.verticalPosition === "bottom"); + this.classList.toggle("inset-top", this.region.verticalPosition === "insetTop"); + this.classList.toggle( + "inset-bottom", + this.region.verticalPosition === "insetBottom" + ); + + this.classList.toggle("left", this.region.horizontalPosition === "left"); + this.classList.toggle("right", this.region.horizontalPosition === "right"); + this.classList.toggle( + "inset-left", + this.region.horizontalPosition === "insetLeft" + ); + this.classList.toggle( + "inset-right", + this.region.horizontalPosition === "insetRight" + ); + }; + + /** + * mouse enters anchor + */ + private handleAnchorMouseOver = (ev: Event): void => { + this.startHoverTimer(); + }; + + /** + * mouse leaves anchor + */ + private handleAnchorMouseOut = (ev: Event): void => { + if (this.isAnchorHovered) { + this.isAnchorHovered = false; + this.updateTooltipVisibility(); + } + this.clearDelayTimer(); + }; + + /** + * starts the hover timer if not currently running + */ + private startHoverTimer = (): void => { + if (this.isAnchorHovered) { + return; + } + + if (this.delay > 1) { + if (this.delayTimer === null) + this.delayTimer = window.setTimeout((): void => { + this.startHover(); + }, this.delay); + return; + } + + this.startHover(); + }; + + /** + * starts the hover delay timer + */ + private startHover = (): void => { + this.isAnchorHovered = true; + this.updateTooltipVisibility(); + }; + + /** + * clears the hover delay + */ + private clearDelayTimer = (): void => { + if (this.delayTimer !== null) { + clearTimeout(this.delayTimer); + this.delayTimer = null; + } + }; + + /** + * updated the properties being passed to the anchored region + */ + private updateLayout(): void { + switch (this.position) { + case TooltipPosition.top: + case TooltipPosition.bottom: + this.verticalPositioningMode = "locktodefault"; + this.horizontalPositioningMode = "dynamic"; + this.verticalDefaultPosition = this.position; + this.horizontalDefaultPosition = undefined; + this.horizontalInset = "true"; + this.verticalInset = "false"; + this.horizontalScaling = "anchor"; + this.verticalScaling = "content"; + break; + + case TooltipPosition.right: + case TooltipPosition.left: + case TooltipPosition.start: + case TooltipPosition.end: + this.verticalPositioningMode = "dynamic"; + this.horizontalPositioningMode = "locktodefault"; + this.verticalDefaultPosition = undefined; + this.horizontalDefaultPosition = this.position; + this.horizontalInset = "false"; + this.verticalInset = "true"; + this.horizontalScaling = "content"; + this.verticalScaling = "anchor"; + break; + + default: + this.verticalPositioningMode = "dynamic"; + this.horizontalPositioningMode = "dynamic"; + this.verticalDefaultPosition = undefined; + this.horizontalDefaultPosition = undefined; + this.horizontalInset = "true"; + this.verticalInset = "false"; + this.horizontalScaling = "anchor"; + this.verticalScaling = "content"; + break; + } + } + + /** + * Gets the anchor element by id + */ + private getAnchor = (): HTMLElement | null => { + return document.getElementById(this.anchor); + }; + + /** + * handles key down events to check for dismiss + */ + private handleDocumentKeydown = (e: KeyboardEvent): void => { + if (!e.defaultPrevented && this.tooltipVisible) { + switch (e.keyCode) { + case keyCodeEscape: + this.isAnchorHovered = false; + this.updateTooltipVisibility(); + this.$emit("dismiss"); + break; + } + } + }; + + /** + * determines whether to show or hide the tooltip based on current state + */ + private updateTooltipVisibility = (): void => { + if (this.visible === false) { + this.hideTooltip(); + } else if (this.visible === true) { + this.showTooltip(); + } else { + if (this.isAnchorHovered) { + this.showTooltip(); + return; + } + this.hideTooltip(); + } + }; + + /** + * shows the tooltip + */ + private showTooltip = (): void => { + if (this.tooltipVisible) { + return; + } + this.currentDirection = getDirection(this); + this.tooltipVisible = true; + document.addEventListener("keydown", this.handleDocumentKeydown); + DOM.queueUpdate(this.setRegionProps); + }; + + /** + * hides the tooltip + */ + private hideTooltip = (): void => { + if (!this.tooltipVisible) { + return; + } + if (this.region !== null && this.region !== undefined) { + (this.region as any).removeEventListener("change", this.handlePositionChange); + this.region.viewportElement = null; + this.region.anchorElement = null; + } + document.removeEventListener("keydown", this.handleDocumentKeydown); + this.tooltipVisible = false; + }; + + /** + * updates the tooltip anchored region props after it has been + * added to the DOM + */ + private setRegionProps = (): void => { + if (!this.tooltipVisible) { + return; + } + this.viewportElement = document.body; + this.region.viewportElement = this.viewportElement; + this.region.anchorElement = this.anchorElement; + (this.region as any).addEventListener("change", this.handlePositionChange); + }; +} diff --git a/sites/fast-component-explorer/app/fast-components/configs/fast-tooltip.ts b/sites/fast-component-explorer/app/fast-components/configs/fast-tooltip.ts new file mode 100644 index 00000000000..20ca828346e --- /dev/null +++ b/sites/fast-component-explorer/app/fast-components/configs/fast-tooltip.ts @@ -0,0 +1,89 @@ +import { + fastComponentDefinitions, + fastComponentSchemas, + textSchema, +} from "@microsoft/site-utilities"; +import { camelCase } from "lodash-es"; +import Guidance from "../../.tmp/tooltip/guidance"; +import { ComponentViewConfig } from "./data.props"; +import { fastButtonId } from "./fast-button"; + +export const fastTooltipId = "fast-tooltip"; +const fastTooltipConfig: ComponentViewConfig = { + schema: fastComponentSchemas[fastTooltipId], + definition: fastComponentDefinitions[`${camelCase(fastTooltipId)}Definition`], + guidance: Guidance, + scenarios: [ + { + displayName: "Default", + dataDictionary: [ + { + root: { + schemaId: "div", + data: { + Slot: [ + { + id: "RootSlot1", + }, + { + id: "RootSlot2", + }, + ], + }, + }, + RootSlot1: { + parent: { + id: "root", + dataLocation: "Slot", + }, + schemaId: fastButtonId, + data: { + id: "anchor", + style: + "height: 40px; width: 100px; margin: 100px; background: green", + Slot: [ + { + id: "ButtonSlot", + }, + ], + }, + }, + RootSlot2: { + parent: { + id: "root", + dataLocation: "Slot", + }, + schemaId: fastTooltipId, + data: { + anchor: "anchor", + Slot: [ + { + id: "TooltipSlot", + }, + ], + }, + }, + TooltipSlot: { + parent: { + id: "RootSlot2", + dataLocation: "Slot", + }, + schemaId: textSchema.id, + data: "Tooltip text", + }, + ButtonSlot: { + parent: { + id: "RootSlot1", + dataLocation: "Slot", + }, + schemaId: textSchema.id, + data: "Hover me", + }, + }, + "root", + ], + }, + ], +}; + +export default fastTooltipConfig; diff --git a/sites/fast-component-explorer/app/fast-components/configs/index.ts b/sites/fast-component-explorer/app/fast-components/configs/index.ts index c0976c30ab5..84d8f08e569 100644 --- a/sites/fast-component-explorer/app/fast-components/configs/index.ts +++ b/sites/fast-component-explorer/app/fast-components/configs/index.ts @@ -55,5 +55,8 @@ export { fastTextAreaConfig }; import fastTextFieldConfig from "./fast-text-field"; export { fastTextFieldConfig }; +import fastTooltipConfig from "./fast-tooltip"; +export { fastTooltipConfig }; + import fastTreeViewConfig from "./fast-tree-view"; export { fastTreeViewConfig }; diff --git a/sites/site-utilities/src/definitions/fast-components/fast-tooltip.definition.ts b/sites/site-utilities/src/definitions/fast-components/fast-tooltip.definition.ts new file mode 100644 index 00000000000..0deadc15228 --- /dev/null +++ b/sites/site-utilities/src/definitions/fast-components/fast-tooltip.definition.ts @@ -0,0 +1,62 @@ +import { WebComponentDefinition } from "@microsoft/fast-tooling/dist/data-utilities/web-component"; +import { DataType } from "@microsoft/fast-tooling"; + +export const fastTooltipDefinition: WebComponentDefinition = { + version: 1, + tags: [ + { + name: "fast-tooltip", + description: "The FAST tooltip element", + attributes: [ + { + name: "visible", + description: "The visible attribute", + type: DataType.boolean, + default: undefined, + required: false, + }, + { + name: "anchor", + description: "The anchor attribute", + type: DataType.string, + default: undefined, + required: false, + }, + { + name: "delay", + description: "The delay attribute", + type: DataType.number, + default: 300, + required: false, + }, + { + name: "position", + description: "The position attribute", + type: DataType.string, + default: undefined, + required: false, + values: [ + { + name: "top", + }, + { + name: "right", + }, + { + name: "bottom", + }, + { + name: "left", + }, + ], + }, + ], + slots: [ + { + name: "", + description: "The default slot", + }, + ], + }, + ], +}; diff --git a/sites/website/sidebars.js b/sites/website/sidebars.js index ac008e69fdb..d8b30b42780 100644 --- a/sites/website/sidebars.js +++ b/sites/website/sidebars.js @@ -38,6 +38,7 @@ module.exports = { "components/tabs", "components/text-area", "components/text-field", + "components/tooltip", "components/tree-view", ], },