diff --git a/elements/pf-v5-chip/demo/long-chip-with-tooltip.html b/elements/pf-v5-chip/demo/long-chip-with-tooltip.html index 03ebabe000..9c1c49331e 100644 --- a/elements/pf-v5-chip/demo/long-chip-with-tooltip.html +++ b/elements/pf-v5-chip/demo/long-chip-with-tooltip.html @@ -1,7 +1,7 @@ - + Really long chip that goes on and on Really long chip that goes on and on - + diff --git a/elements/pf-v5-tooltip/demo/custom-styles.html b/elements/pf-v5-tooltip/demo/custom-styles.html deleted file mode 100644 index 390c36cc39..0000000000 --- a/elements/pf-v5-tooltip/demo/custom-styles.html +++ /dev/null @@ -1,60 +0,0 @@ -
-

Toggle Container Width

- - - -
- -
-

Tooltips can be styled using CSS custom properties. For example, a tooltip may have - - custom text color - , - - a custom shadow - , or - - custom content padding - . -

-
- - - - diff --git a/elements/pf-v5-tooltip/demo/flip.html b/elements/pf-v5-tooltip/demo/flip.html deleted file mode 100644 index 7350a12512..0000000000 --- a/elements/pf-v5-tooltip/demo/flip.html +++ /dev/null @@ -1,95 +0,0 @@ -
- - Tooltip! - - - -
Flip fallback
-
- -
- - Tooltip! - -
No flip
-
- - - - - diff --git a/elements/pf-v5-tooltip/demo/index.html b/elements/pf-v5-tooltip/demo/index.html deleted file mode 100644 index 7b8c713c8b..0000000000 --- a/elements/pf-v5-tooltip/demo/index.html +++ /dev/null @@ -1,37 +0,0 @@ -
-

Toggle Container Width

- - - -
- -
-

- A Tooltip - is a piece of flow content with an associated secondary piece of hidden flow content. - Tooltips which wrap non-interactive content must have a tabindex of 0. -

-
- - - - diff --git a/elements/pf-v5-tooltip/demo/performance.html b/elements/pf-v5-tooltip/demo/performance.html deleted file mode 100644 index 40d8734e1d..0000000000 --- a/elements/pf-v5-tooltip/demo/performance.html +++ /dev/null @@ -1,63 +0,0 @@ -
-

Performance

-

This demo measures the time needed to render 10000 tooltip elements then toggle the first tooltip.

-
- Render - Reset - -
-
-
- - - - diff --git a/elements/pf-v5-tooltip/demo/placement.html b/elements/pf-v5-tooltip/demo/placement.html deleted file mode 100644 index 9b6e6dabaf..0000000000 --- a/elements/pf-v5-tooltip/demo/placement.html +++ /dev/null @@ -1,70 +0,0 @@ -
-

Toggle Container Width

- - - -
- -
-

- Tooltips can be anchored to the left: - Left, - Left Start, - Left End. - They can be anchored to the right: - Right, - Right Start, - Right End. - They can be anchored to the top: - Top, - Top Start, - Top End. - They can be anchored to the bottom: - Bottom, - Bottom Start, - Bottom End. -

-

- Tooltips can have content which wraps lines, but this is discouraged, - because tooltips are meant to contain as little content as possible. - When tooltips have lengthy content anchored to the left, it looks like this: - Left, - Left Start, - Left End. - When tooltips have lengthy content anchored to the right, it looks like this: - Right, - Right Start, - Right End. - When tooltips have lengthy content anchored to the top, it looks like this: - Top, - Top Start, - Top End. - When tooltips have lengthy content anchored to the bottom, it looks like this: - Bottom, - Bottom Start, - Bottom End. -

-
- - - - diff --git a/elements/pf-v5-tooltip/demo/slotted-content.html b/elements/pf-v5-tooltip/demo/slotted-content.html deleted file mode 100644 index 220cfce0af..0000000000 --- a/elements/pf-v5-tooltip/demo/slotted-content.html +++ /dev/null @@ -1,42 +0,0 @@ -
-

Toggle Container Width

- - - -
- -
-

A tooltip may contain - - HTML content - - Slotted content can be formatted, using HTML - tags like em, strong, or code. - - by using the content slot. - Slotted content must be phrasing content, so no <p>s. -

-
- - - - diff --git a/elements/pf-v5-tooltip/demo/trigger.html b/elements/pf-v5-tooltip/demo/trigger.html deleted file mode 100644 index c5ae928272..0000000000 --- a/elements/pf-v5-tooltip/demo/trigger.html +++ /dev/null @@ -1,15 +0,0 @@ -
- - Button -
- - - - diff --git a/elements/pf-v5-tooltip/docs/CHANGELOG.old.md b/elements/pf-v5-tooltip/docs/CHANGELOG.old.md deleted file mode 100644 index 845280705c..0000000000 --- a/elements/pf-v5-tooltip/docs/CHANGELOG.old.md +++ /dev/null @@ -1,125 +0,0 @@ -# @patternfly/pfe-tooltip - -## 2.0.0-next.9 - -### Patch Changes - -- 457eaa9d0: `pfe-tools`: Set typescript compilerOptions `composite: true` - - `pfe-tooltip`: Added return type for anonymous function for content in constructor - -- Updated dependencies [82da44c11] - - @patternfly/pfe-core@2.0.0-next.14 - -## 2.0.0-next.8 - -### Major Changes - -- b841afe40: ``: - - - updated `FloatingDOMController` - - removed `offset` attribute - - added `content` attribute - - simplified DOM and CSS - -### Patch Changes - -- Updated dependencies [b841afe40] -- Updated dependencies [0fe6c52db] -- Updated dependencies [0fe6c52db] - - @patternfly/pfe-core@2.0.0-next.13 - -## 1.1.0-next.7 - -### Patch Changes - -- b51b551f: Use `NumberListConverter` from pfe-core -- Updated dependencies [6b6e2617] - - @patternfly/pfe-core@2.0.0-next.12 - -## 1.1.0-next.6 - -### Minor Changes - -- daba8a53: Adds styles to slotted timestamps. - - ```html - - - Last updated on - - - - - ``` - -## 1.1.0-next.5 - -### Patch Changes - -- 07ad1d3d: Updates use of `` -- Updated dependencies [07ad1d3d] - - @patternfly/pfe-core@2.0.0-next.10 - -## 1.1.0-next.4 - -### Minor Changes - -- 166ecee1: Improves performance of floating DOM (tooltip) by lazily initializing - -### Patch Changes - -- 0b90b899: Null check in tooltip's `show()` method -- Updated dependencies [166ecee1] - - @patternfly/pfe-core@2.0.0-next.9 - -## 1.1.0-next.3 - -### Patch Changes - -- bfad8b4b: Updates dependencies -- Updated dependencies [bfad8b4b] - - @patternfly/pfe-core@2.0.0-next.8 - -## 1.1.0-next.2 - -### Patch Changes - -- f25258e9: Updating the README.md, fixing an scss variable found in pfe-tooltip for the background color. - -## 1.1.0-next.1 - -### Patch Changes - -- b5fe1d3e: Use popper version from pfe-core - -## 1.1.0-next.0 - -### Minor Changes - -- 7c9b85cc: Adding `pfe-tooltip` - - ```html - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod - tempor incididunt ut labore et dolore magna aliqua. Mi eget mauris - pharetra et ultrices. - - ``` - -### Patch Changes - -- Updated dependencies [7c9b85cc] - - @patternfly/pfe-core@2.0.0-next.7 diff --git a/elements/pf-v5-tooltip/docs/pf-v5-tooltip.md b/elements/pf-v5-tooltip/docs/pf-v5-tooltip.md deleted file mode 100644 index 2688f24a4b..0000000000 --- a/elements/pf-v5-tooltip/docs/pf-v5-tooltip.md +++ /dev/null @@ -1,106 +0,0 @@ -{% renderInstallation %} {% endrenderInstallation %} - -{% renderOverview %} - ### Default - - Tooltip - -{% endrenderOverview %} - -{% band header="Usage" %} - ### Left Tooltip - {% htmlexample %} - - Tooltip - - {% endhtmlexample %} - - ### Left-Start Tooltip - {% htmlexample %} - - Tooltip - - {% endhtmlexample %} - - ### Left-End Tooltip - {% htmlexample %} - - Tooltip - - {% endhtmlexample %} - - ### Right Tooltip - {% htmlexample %} - - Tooltip - - {% endhtmlexample %} - - ### Right-Start Tooltip - {% htmlexample %} - - Tooltip - - {% endhtmlexample %} - - ### Right-End Tooltip - {% htmlexample %} - - Tooltip - - {% endhtmlexample %} - - ### Top Tooltip - {% htmlexample %} - - Tooltip - - {% endhtmlexample %} - - ### Top-Start Tooltip - {% htmlexample %} - - Tooltip - - {% endhtmlexample %} - - ### Top-End Tooltip - {% htmlexample %} - - Tooltip - - {% endhtmlexample %} - - ### Bottom Tooltip - {% htmlexample %} - - Tooltip - - {% endhtmlexample %} - - ### Bottom-Start Tooltip - {% htmlexample %} - - Tooltip - - {% endhtmlexample %} - - ### Bottom-End Tooltip - {% htmlexample %} - - Tooltip - - {% endhtmlexample %} -{% endband %} - -{% renderSlots %}{% endrenderSlots %} - -{% renderAttributes %}{% endrenderAttributes %} - -{% renderMethods %}{% endrenderMethods %} - -{% renderEvents %}{% endrenderEvents %} - -{% renderCssCustomProperties %}{% endrenderCssCustomProperties %} - -{% renderCssParts %}{% endrenderCssParts %} diff --git a/elements/pf-v5-tooltip/docs/screenshot.png b/elements/pf-v5-tooltip/docs/screenshot.png deleted file mode 100644 index 54986c4291..0000000000 Binary files a/elements/pf-v5-tooltip/docs/screenshot.png and /dev/null differ diff --git a/elements/pf-v5-tooltip/pf-v5-tooltip.css b/elements/pf-v5-tooltip/pf-v5-tooltip.css deleted file mode 100644 index 7fe60ddfdc..0000000000 --- a/elements/pf-v5-tooltip/pf-v5-tooltip.css +++ /dev/null @@ -1,108 +0,0 @@ -:host { - --_timestamp-text-decoration: underline dashed 1px; - --_timestamp-text-underline-offset: 4px; - display: inline; -} - -* { box-sizing: border-box; } - -#container { - display: inline-flex; - position: relative; - max-width: 100%; - /** Tooltip arrow width */ - --_floating-arrow-size: var(--pf-v5-c-tooltip__arrow--Width, 0.5rem); -} - -#tooltip, -#tooltip::after { - position: absolute; -} - -#tooltip { - --_timestamp-text-decoration: none; - --_timestamp-text-underline-offset: initial; - user-select: none; - display: block; - opacity: 0; - pointer-events: none; - z-index: 10000; - transition: opacity 300ms cubic-bezier(0.54, 1.5, 0.38, 1.11) 0s; - text-align: center; - word-break: break-word; - translate: var(--_floating-content-translate); - max-width: calc(100vw - 10px); - width: max-content; - top: 0; - left: 0; - will-change: opacity; - /** Sets the font color for the tooltip content */ - line-height: var(--pf-v5-c-tooltip--line-height, 1.5); - /** Maximum width for the tooltip */ - max-width: var(--pf-v5-c-tooltip--MaxWidth, 18.75rem); - /** Box shadow for the tooltip */ - box-shadow: var(--pf-v5-c-tooltip--BoxShadow, - var(--pf-global--BoxShadow--md, - 0 0.25rem 0.5rem 0rem rgba(3, 3, 3, 0.12), - 0 0 0.25rem 0 rgba(3, 3, 3, 0.06))); - /** Tooltip padding (top, right, bottom, left) */ - padding: - var(--pf-v5-c-tooltip__content--PaddingTop, - var(--pf-global--spacer--sm, 0.5rem)) - var(--pf-v5-c-tooltip__content--PaddingRight, - var(--pf-global--spacer--sm, 0.5rem)) - var(--pf-v5-c-tooltip__content--PaddingBottom, - var(--pf-global--spacer--sm, 0.5rem)) - var(--pf-v5-c-tooltip__content--PaddingLeft, - var(--pf-global--spacer--sm, 0.5rem)); - /** Font size for the tooltip content */ - font-size: var(--pf-v5-c-tooltip__content--FontSize, - var(--pf-global--FontSize--sm, 0.875rem)); - /** Sets the font color for the tooltip content */ - color: var(--pf-v5-c-tooltip__content--Color, - var(--pf-global--Color--light-100, #ffffff)); - /** Sets the background color for the tooltip content */ - background-color: var(--pf-v5-c-tooltip__content--BackgroundColor, - var(--pf-global--BackgroundColor--dark-100, #151515)); -} - -#tooltip::after { - display: block; - content: ''; - rotate: 45deg; - width: var(--_floating-arrow-size); - height: var(--_floating-arrow-size); - will-change: left top right bottom; - background-color: var(--pf-v5-c-tooltip__content--BackgroundColor, - var(--pf-global--BackgroundColor--dark-100, #151515)); -} - -.open #tooltip { - opacity: 1; - user-select: initial; -} - -/* LEFT */ -.left #tooltip::after { right: calc(-0.5 * var(--_floating-arrow-size)); } -.left.center #tooltip::after { top: calc(50% - 0.5 * var(--_floating-arrow-size)); } -.left.start #tooltip::after { top: var(--_floating-arrow-size); } -.left.end #tooltip::after { bottom: var(--_floating-arrow-size); } - -/* TOP */ -.top #tooltip::after { top: calc(100% - 0.5 * var(--_floating-arrow-size)); } -.top.center #tooltip::after { right: calc(50% - 0.5 * var(--_floating-arrow-size)); } -.top.start #tooltip::after { left: var(--_floating-arrow-size); } -.top.end #tooltip::after { right: var(--_floating-arrow-size); } - -/* RIGHT */ -.right #tooltip::after { right: calc(100% - 0.5 * var(--_floating-arrow-size)); } -.right.center #tooltip::after { top: calc(50% - 0.5 * var(--_floating-arrow-size)); } -.right.start #tooltip::after { top: var(--_floating-arrow-size); } -.right.end #tooltip::after { bottom: var(--_floating-arrow-size); } - -/* BOTTOM */ -.bottom #tooltip::after { bottom: calc(100% - 0.5 * var(--_floating-arrow-size)); } -.bottom.center #tooltip::after { right: calc(50% - 0.5 * var(--_floating-arrow-size)); } -.bottom.start #tooltip::after { left: var(--_floating-arrow-size); } -.bottom.end #tooltip::after { right: var(--_floating-arrow-size); } - diff --git a/elements/pf-v5-tooltip/pf-v5-tooltip.ts b/elements/pf-v5-tooltip/pf-v5-tooltip.ts deleted file mode 100644 index 12f1682dba..0000000000 --- a/elements/pf-v5-tooltip/pf-v5-tooltip.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { PropertyValues, TemplateResult } from 'lit'; -import { LitElement, html, isServer } from 'lit'; -import { customElement } from 'lit/decorators/custom-element.js'; -import { property } from 'lit/decorators/property.js'; -import { styleMap } from 'lit/directives/style-map.js'; -import { classMap } from 'lit/directives/class-map.js'; - -import { - FloatingDOMController, - type Placement, -} from '@patternfly/pfe-core/controllers/floating-dom-controller.js'; - -import { bound } from '@patternfly/pfe-core/decorators/bound.js'; - -import { StringListConverter } from '@patternfly/pfe-core'; - -import styles from './pf-v5-tooltip.css'; - -const EnterEvents = ['focusin', 'tap', 'click', 'mouseenter']; -const ExitEvents = ['focusout', 'blur', 'mouseleave']; - -/** - * A **tooltip** is in-app messaging used to identify elements on a page with short, - * clarifying text. - * @summary Toggle the visibility of helpful or contextual information. - * @alias Tooltip - */ -@customElement('pf-v5-tooltip') -export class PfV5Tooltip extends LitElement { - static readonly styles: CSSStyleSheet[] = [styles]; - - /** The position of the tooltip, relative to the invoking content */ - @property() position: Placement = 'top'; - - /** Tooltip content. Overridden by the content slot */ - @property() content?: string; - - /** If false, prevents the tooltip from trying to remain in view by flipping itself when necessary */ - @property({ type: Boolean, attribute: 'no-flip' }) noFlip = false; - - @property() trigger?: string | Element; - - /** - * The flip order when flip is enabled and the initial position is not possible. - * There are 12 options: `top`, `bottom`, `left`, `right`, `top-start`, `top-end`, - * `bottom-start`, `bottom-end`, `left-start`, `left-end`,`right-start`, `right-end`. - * The default is [oppositePlacement], where only the opposite placement is tried. - */ - @property({ - attribute: 'flip-behavior', - converter: StringListConverter, - }) flipBehavior?: Placement[]; - - get #invoker(): HTMLSlotElement | null { - return this.shadowRoot?.querySelector?.('#invoker') ?? null; - } - - get #content(): HTMLElement | null { - return this.shadowRoot?.querySelector?.('#tooltip') ?? null; - } - - #referenceTrigger?: HTMLElement | null; - - #float = new FloatingDOMController(this, { - content: (): HTMLElement | null | undefined => this.#content, - invoker: (): HTMLElement | null | undefined => { - if (this.#referenceTrigger) { - return this.#referenceTrigger; - } else if (this.#invoker instanceof HTMLSlotElement - && this.#invoker.assignedElements().length > 0) { - return this.#invoker.assignedElements().at(0) as HTMLElement; - } else { - return this.#invoker; - } - }, - }); - - override connectedCallback(): void { - super.connectedCallback(); - this.#invokerChanged(); - this.#updateTrigger(); - } - - /** - * Removes event listeners from the old trigger element and attaches - * them to the new trigger element. - * @param changed changed properties - */ - override willUpdate(changed: PropertyValues): void { - if (changed.has('trigger')) { - this.#updateTrigger(); - } - } - - override render(): TemplateResult<1> { - const { alignment, anchor, open, styles } = this.#float; - - const blockInvoker = - this.#invoker?.assignedElements().length === 0 - && this.#invoker?.assignedNodes().length > 0; - const display = blockInvoker ? 'block' : 'contents'; - - return html` -
-
- - -
-
- - ${this.content} -
-
- `; - } - - /** the invoker slot should render at block level if it only has text nodes */ - #invokerChanged() { - this.requestUpdate(); - } - - #getReferenceTrigger() { - return (this.getRootNode() as Document | ShadowRoot) - .getElementById(this.trigger?.normalize() ?? ''); - } - - #updateTrigger() { - if (!isServer) { - const oldReferenceTrigger = this.#referenceTrigger; - this.#referenceTrigger = - this.trigger instanceof HTMLElement ? this.trigger - : typeof this.trigger === 'string' ? this.#getReferenceTrigger() - : null; - for (const evt of EnterEvents) { - if (this.#referenceTrigger) { - this.removeEventListener(evt, this.show); - this.#referenceTrigger.addEventListener(evt, this.show); - } else { - oldReferenceTrigger?.removeEventListener(evt, this.show); - this.addEventListener(evt, this.show); - } - } - for (const evt of ExitEvents) { - if (this.#referenceTrigger) { - this.removeEventListener(evt, this.hide); - this.#referenceTrigger.addEventListener(evt, this.hide); - } else { - oldReferenceTrigger?.removeEventListener(evt, this.hide); - this.addEventListener(evt, this.hide); - } - } - } - } - - @bound async show(): Promise { - await this.updateComplete; - const placement = this.position; - const offset = - !placement?.match(/top|bottom/) ? 15 - : { mainAxis: 15, alignmentAxis: -4 }; - await this.#float.show({ - offset, - placement, - flip: !this.noFlip, - fallbackPlacements: this.flipBehavior, - }); - } - - @bound async hide(): Promise { - await this.#float.hide(); - } -} - -declare global { - interface HTMLElementTagNameMap { - 'pf-v5-tooltip': PfV5Tooltip; - } -} diff --git a/elements/pf-v5-tooltip/test/pf-tooltip.e2e.ts b/elements/pf-v5-tooltip/test/pf-tooltip.e2e.ts deleted file mode 100644 index e51a80a7fb..0000000000 --- a/elements/pf-v5-tooltip/test/pf-tooltip.e2e.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { test } from '@playwright/test'; -import { PfeDemoPage } from '@patternfly/pfe-tools/test/playwright/PfeDemoPage.js'; -import { SSRPage } from '@patternfly/pfe-tools/test/playwright/SSRPage.js'; - -const tagName = 'pf-v5-tooltip'; - -test.describe(tagName, () => { - test('snapshot', async ({ page }) => { - const componentPage = new PfeDemoPage(page, tagName); - await componentPage.navigate(); - await componentPage.snapshot(); - }); - - test('ssr', async ({ browser }) => { - const fixture = new SSRPage({ - tagName, - browser, - demoDir: new URL('../demo/', import.meta.url), - importSpecifiers: [ - `@patternfly/elements/${tagName}/${tagName}.js`, - ], - }); - await fixture.snapshots(); - }); -}); diff --git a/elements/pf-v5-tooltip/test/pf-tooltip.spec.ts b/elements/pf-v5-tooltip/test/pf-tooltip.spec.ts deleted file mode 100644 index 48482ce25b..0000000000 --- a/elements/pf-v5-tooltip/test/pf-tooltip.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { expect, html, fixture } from '@open-wc/testing'; - -import { PfV5Tooltip } from '../pf-v5-tooltip.js'; -import { setViewport, sendMouse } from '@web/test-runner-commands'; -import { a11ySnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; - -describe('', function() { - let element: PfV5Tooltip; - - beforeEach(async function() { - await setViewport({ width: 1000, height: 1000 }); - }); - - it('imperatively instantiates', function() { - expect(document.createElement('pf-v5-tooltip')).to.be.an.instanceof(PfV5Tooltip); - }); - - it('should upgrade', async function() { - element = await fixture(html``); - const klass = customElements.get('pf-v5-tooltip'); - expect(element) - .to.be.an.instanceOf(klass) - .and - .to.be.an.instanceOf(PfV5Tooltip); - }); - - describe('typical usage', function() { - beforeEach(async function() { - element = await fixture(html` - Tooltip - `); - }); - - it('should be accessible', async function() { - await expect(element).shadowDom.to.be.accessible(); - }); - - it('should hide tooltip content from assistive technology', async function() { - const snapshot = await a11ySnapshot(); - expect(snapshot).to.axContainName('Tooltip'); - expect(snapshot).to.not.axContainName('Content'); - }); - - describe('hovering the element', function() { - beforeEach(async function() { - const { x, y } = element.getBoundingClientRect(); - await sendMouse({ position: [x + 5, y + 5], type: 'move' }); - await element.updateComplete; - }); - it('should show tooltip content to assistive technology', async function() { - const snapshot = await a11ySnapshot(); - expect(snapshot).to.axContainName('Tooltip'); - expect(snapshot).to.axContainName('Content'); - }); - }); - }); -}); diff --git a/elements/pf-v6-tooltip/demo/color-scheme.html b/elements/pf-v6-tooltip/demo/color-scheme.html new file mode 100644 index 0000000000..20b40f4b21 --- /dev/null +++ b/elements/pf-v6-tooltip/demo/color-scheme.html @@ -0,0 +1,44 @@ +--- +description: Tooltip inverts to contrast with the page color scheme. +--- +
+

Light mode

+ + Light page trigger + +
+ +
+

Dark mode

+ + Dark page trigger + +
+ + + + diff --git a/elements/pf-v6-tooltip/demo/dynamic-content.html b/elements/pf-v6-tooltip/demo/dynamic-content.html new file mode 100644 index 0000000000..d9c53f9886 --- /dev/null +++ b/elements/pf-v6-tooltip/demo/dynamic-content.html @@ -0,0 +1,39 @@ +--- +description: Tooltip content that updates dynamically. +--- +
+ + + +
+ + + + diff --git a/elements/pf-v6-tooltip/demo/index.html b/elements/pf-v6-tooltip/demo/index.html new file mode 100644 index 0000000000..6aa1df171a --- /dev/null +++ b/elements/pf-v6-tooltip/demo/index.html @@ -0,0 +1,20 @@ +--- +description: A tooltip wrapping a button, shown on hover or focus. +--- +
+ + I have a tooltip! + +
+ + + + diff --git a/elements/pf-v6-tooltip/demo/left-aligned.html b/elements/pf-v6-tooltip/demo/left-aligned.html new file mode 100644 index 0000000000..eeec46d9b8 --- /dev/null +++ b/elements/pf-v6-tooltip/demo/left-aligned.html @@ -0,0 +1,21 @@ +--- +description: Tooltip text can be left-aligned instead of centered. +--- +
+ + Left-aligned tooltip + +
+ + + + diff --git a/elements/pf-v6-tooltip/demo/options.html b/elements/pf-v6-tooltip/demo/options.html new file mode 100644 index 0000000000..fed5ef159f --- /dev/null +++ b/elements/pf-v6-tooltip/demo/options.html @@ -0,0 +1,101 @@ +--- +description: Interactive playground for tooltip options. +--- +
+
+ Position + +
+ +
+ Alignment + +
+ +
+ Delays + + +
+ +
+ Flip + +
+
+ +
+ + Tooltip + +
+ + + + diff --git a/elements/pf-v6-tooltip/demo/placement.html b/elements/pf-v6-tooltip/demo/placement.html new file mode 100644 index 0000000000..54e3fd3a1a --- /dev/null +++ b/elements/pf-v6-tooltip/demo/placement.html @@ -0,0 +1,61 @@ +--- +description: Tooltips can be positioned on any side of the trigger element. +--- +
+
+ + Top + + + Top Start + + + Top End + + + Bottom + + + Bottom Start + + + Bottom End + + + Left + + + Left Start + + + Left End + + + Right + + + Right Start + + + Right End + +
+
+ + + + diff --git a/elements/pf-v6-tooltip/demo/slotted-content.html b/elements/pf-v6-tooltip/demo/slotted-content.html new file mode 100644 index 0000000000..aaef1bf457 --- /dev/null +++ b/elements/pf-v6-tooltip/demo/slotted-content.html @@ -0,0 +1,28 @@ +--- +description: Tooltip content can include formatted HTML via the content slot. +--- +
+

A tooltip may contain + + HTML content + + Slotted content can be formatted, using HTML + tags like em, strong, or code. + + by using the content slot. +

+
+ + + + diff --git a/elements/pf-v6-tooltip/demo/trigger-element.html b/elements/pf-v6-tooltip/demo/trigger-element.html new file mode 100644 index 0000000000..7aafa98ebc --- /dev/null +++ b/elements/pf-v6-tooltip/demo/trigger-element.html @@ -0,0 +1,25 @@ +--- +description: Setting the trigger property to an Element reference directly. +--- +
+ Tooltip via element reference + + +
+ + + + diff --git a/elements/pf-v6-tooltip/demo/trigger-ref.html b/elements/pf-v6-tooltip/demo/trigger-ref.html new file mode 100644 index 0000000000..1ca0b91e65 --- /dev/null +++ b/elements/pf-v6-tooltip/demo/trigger-ref.html @@ -0,0 +1,21 @@ +--- +description: A tooltip referencing an external element as its trigger. +--- +
+ Tooltip attached via trigger ref + + +
+ + + + diff --git a/elements/pf-v6-tooltip/pf-v6-tooltip.css b/elements/pf-v6-tooltip/pf-v6-tooltip.css new file mode 100644 index 0000000000..538469fd82 --- /dev/null +++ b/elements/pf-v6-tooltip/pf-v6-tooltip.css @@ -0,0 +1,71 @@ +:host { + display: inline; + color-scheme: light dark; +} + +* { + box-sizing: border-box; +} + +#tooltip { + position: absolute; + user-select: none; + opacity: 0; + pointer-events: none; + z-index: 10000; + transition: opacity 300ms cubic-bezier(0.54, 1.5, 0.38, 1.11); + translate: var(--_floating-content-translate); + max-width: var(--pf-v6-c-tooltip--MaxWidth, 18.75rem); + box-shadow: var(--pf-v6-c-tooltip--BoxShadow, + var(--pf-t--global--box-shadow--md, + 0 0.25rem 0.5625rem 0 rgba(0, 0, 0, 0.5))); + width: max-content; + inset-block-start: 0; + inset-inline-start: 0; + will-change: opacity; + + &.open { + opacity: 1; + user-select: initial; + pointer-events: auto; + } +} + +#content { + position: relative; + text-align: center; + word-break: break-word; + line-height: 1.5; + padding-block-start: var(--pf-v6-c-tooltip__content--PaddingBlockStart, + var(--pf-t--global--spacer--sm, 0.5rem)); + padding-inline-end: var(--pf-v6-c-tooltip__content--PaddingInlineEnd, + var(--pf-t--global--spacer--md, 1rem)); + padding-block-end: var(--pf-v6-c-tooltip__content--PaddingBlockEnd, + var(--pf-t--global--spacer--sm, 0.5rem)); + padding-inline-start: var(--pf-v6-c-tooltip__content--PaddingInlineStart, + var(--pf-t--global--spacer--md, 1rem)); + font-size: var(--pf-v6-c-tooltip__content--FontSize, + var(--pf-t--global--font--size--body--sm, 0.75rem)); + color: var(--pf-v6-c-tooltip__content--Color, + var(--pf-t--global--text--color--inverse, + light-dark(#fff, #1f1f1f))); + background-color: var(--pf-v6-c-tooltip__content--BackgroundColor, + var(--pf-t--global--background--color--inverse--default, + light-dark(#1b1d21, #f2f2f2))); + border-radius: var(--pf-v6-c-tooltip__content--BorderRadius, + var(--pf-t--global--border--radius--small, 6px)); +} + +#arrow { + position: absolute; + width: var(--pf-v6-c-tooltip__arrow--Width, 0.9375rem); + height: var(--pf-v6-c-tooltip__arrow--Height, 0.9375rem); + pointer-events: none; + background-color: var(--pf-v6-c-tooltip__arrow--BackgroundColor, + var(--pf-t--global--background--color--inverse--default, + light-dark(#1b1d21, #f2f2f2))); + box-shadow: var(--pf-v6-c-tooltip__arrow--BoxShadow, + var(--pf-t--global--box-shadow--md, + 0 0.25rem 0.5625rem 0 rgba(0, 0, 0, 0.5))); + rotate: 45deg; +} diff --git a/elements/pf-v6-tooltip/pf-v6-tooltip.ts b/elements/pf-v6-tooltip/pf-v6-tooltip.ts new file mode 100644 index 0000000000..cf0423936e --- /dev/null +++ b/elements/pf-v6-tooltip/pf-v6-tooltip.ts @@ -0,0 +1,311 @@ +/* eslint-disable lit-a11y/accessible-name -- tooltip content text IS the accessible name */ +import type { PropertyValues, TemplateResult } from 'lit'; +import { LitElement, html, isServer } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { property } from 'lit/decorators/property.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import { + FloatingDOMController, + type Placement, +} from '@patternfly/pfe-core/controllers/floating-dom-controller.js'; + +import { StringListConverter } from '@patternfly/pfe-core'; + +import styles from './pf-v6-tooltip.css'; + +export type { Placement }; + +export type TooltipAlignment = 'start' | 'end' | 'left' | 'right'; + +export type TooltipTriggerReason = + | 'mouseenter' + | 'focusin' + | 'mouseleave' + | 'focusout'; + +export class TooltipShowEvent extends Event { + constructor(public reason: TooltipTriggerReason) { + super('show', { bubbles: true, cancelable: true }); + } +} + +export class TooltipHideEvent extends Event { + constructor(public reason: TooltipTriggerReason) { + super('hide', { bubbles: true, cancelable: true }); + } +} + +const ENTRY_EVENTS: readonly string[] = ['focusin', 'mouseenter']; +const EXIT_EVENTS: readonly string[] = ['focusout', 'mouseleave']; + +/** + * A tooltip is in-app messaging used to identify elements on a page with + * short, clarifying text. + * @summary Supplementary text popup on hover or focus. + * @slot - Trigger element that invokes the tooltip on hover or focus. + * @slot content - Rich tooltip content. Overrides the `content` attribute. + * @cssprop {} [--pf-v6-c-tooltip--MaxWidth=18.75rem] - Maximum width of the tooltip. + * @cssprop {} [--pf-v6-c-tooltip__content--Color] - Tooltip text color. + * @cssprop {} [--pf-v6-c-tooltip__content--BackgroundColor] - Tooltip background color. + * @cssprop {} [--pf-v6-c-tooltip__content--FontSize] - Tooltip font size. + * @cssprop {} [--pf-v6-c-tooltip__content--BorderRadius] - Tooltip border radius. + * @cssprop {} [--pf-v6-c-tooltip__content--PaddingBlockStart] - Block start padding. + * @cssprop {} [--pf-v6-c-tooltip__content--PaddingBlockEnd] - Block end padding. + * @cssprop {} [--pf-v6-c-tooltip__content--PaddingInlineStart] - Inline start padding. + * @cssprop {} [--pf-v6-c-tooltip__content--PaddingInlineEnd] - Inline end padding. + * @cssprop [--pf-v6-c-tooltip--BoxShadow] - Tooltip box shadow. + * @cssprop {} [--pf-v6-c-tooltip__arrow--Width=0.9375rem] - Arrow width. + * @cssprop {} [--pf-v6-c-tooltip__arrow--Height=0.9375rem] - Arrow height. + * @cssprop {} [--pf-v6-c-tooltip__arrow--BackgroundColor] - Arrow background color. + * @fires {TooltipShowEvent} show - Cancelable event fired before the tooltip shows. The `reason` field indicates what triggered it. + * @fires {TooltipHideEvent} hide - Cancelable event fired before the tooltip hides. The `reason` field indicates what triggered it. + */ +@customElement('pf-v6-tooltip') +export class PfV6Tooltip extends LitElement { + static readonly styles: CSSStyleSheet[] = [styles]; + + /** Position of the tooltip relative to the trigger element */ + @property() position: Placement = 'top'; + + /** Tooltip content text. Overridden by the content slot. */ + @property() content?: string; + + /** Disables automatic repositioning when the tooltip would overflow */ + @property({ type: Boolean, attribute: 'no-flip' }) noFlip = false; + + /** + * Fallback positions when flip is enabled and the initial position + * is not possible. Comma-separated list of placements. + */ + @property({ + attribute: 'flip-behavior', + converter: StringListConverter, + }) flipBehavior?: Placement[]; + + /** + * External trigger element. As an attribute, accepts the ID of an element + * in the same root. As a property, also accepts an Element reference directly. + */ + @property() trigger?: string | Element; + + /** Delay in ms before the tooltip appears */ + @property({ type: Number, attribute: 'entry-delay' }) entryDelay = 300; + + /** Delay in ms before the tooltip disappears */ + @property({ type: Number, attribute: 'exit-delay' }) exitDelay = 300; + + /** Text alignment within the tooltip content */ + @property() alignment?: TooltipAlignment; + + #entryTimeout?: ReturnType; + #exitTimeout?: ReturnType; + #triggerElement?: HTMLElement | null; + + get #invoker(): HTMLSlotElement | null { + return this.shadowRoot?.querySelector('#invoker') ?? null; + } + + get #invokerElement(): HTMLElement | null { + if (this.#triggerElement) { + return this.#triggerElement; + } + const slot = this.#invoker; + if (slot instanceof HTMLSlotElement) { + return slot.assignedElements()[0] as HTMLElement ?? null; + } + return null; + } + + get #tooltipEl(): HTMLElement | null { + return this.shadowRoot?.querySelector('#tooltip') ?? null; + } + + get #arrowEl(): HTMLElement | null { + return this.shadowRoot?.querySelector('#arrow') ?? null; + } + + #float = new FloatingDOMController(this, { + content: (): HTMLElement | null | undefined => this.#tooltipEl, + invoker: (): HTMLElement | null | undefined => { + if (this.#triggerElement) { + return this.#triggerElement; + } + const slot = this.#invoker; + if (slot instanceof HTMLSlotElement + && slot.assignedElements().length > 0) { + return slot.assignedElements()[0] as HTMLElement; + } + return slot; + }, + arrow: (): HTMLElement | null | undefined => this.#arrowEl, + }); + + override connectedCallback(): void { + super.connectedCallback(); + if (!isServer) { + this.#updateTriggerListeners(); + this.addEventListener('keydown', this.#onKeydown); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#clearTimers(); + this.#removeTriggerListeners(); + this.removeEventListener('keydown', this.#onKeydown); + } + + override willUpdate(changed: PropertyValues): void { + if (changed.has('trigger')) { + this.#updateTriggerListeners(); + } + } + + override render(): TemplateResult { + const { alignment: floatAlignment, anchor, open, styles: floatStyles } = this.#float; + return html` + +
+
+ +
+ `; + } + + /** Show the tooltip programmatically */ + async show(): Promise { + await this.updateComplete; + const placement = this.position; + const offset = + !placement?.match(/top|bottom/) ? 15 + : { mainAxis: 15, alignmentAxis: -4 }; + await this.#float.show({ + offset, + placement, + flip: !this.noFlip, + fallbackPlacements: this.flipBehavior, + }); + this.#setAriaDescribedBy(true); + } + + /** Hide the tooltip programmatically */ + async hide(): Promise { + this.#clearTimers(); + this.#setAriaDescribedBy(false); + await this.#float.hide(); + } + + #setAriaDescribedBy(add: boolean): void { + const trigger = this.#invokerElement; + const content = this.shadowRoot?.querySelector('#content') ?? null; + if (!trigger || !content) { + return; + } + if ('ariaDescribedByElements' in trigger) { + (trigger as unknown as { ariaDescribedByElements: Element[] }) + .ariaDescribedByElements = add ? [content] : []; + } + } + + #clearTimers(): void { + clearTimeout(this.#entryTimeout); + clearTimeout(this.#exitTimeout); + } + + #onSlotChange(): void { + if (!isServer) { + this.#updateTriggerListeners(); + } + this.requestUpdate(); + } + + #getTriggerElement(): HTMLElement | null { + if (!this.trigger) { + return null; + } + if (typeof this.trigger !== 'string') { + return this.trigger instanceof HTMLElement ? this.trigger : null; + } + return (this.getRootNode() as Document | ShadowRoot) + .getElementById(this.trigger); + } + + #updateTriggerListeners(): void { + if (isServer) { + return; + } + this.#removeTriggerListeners(); + this.#triggerElement = this.#getTriggerElement(); + const target = this.#triggerElement ?? this; + for (const evt of ENTRY_EVENTS) { + target.addEventListener(evt, this.#onEntry); + } + for (const evt of EXIT_EVENTS) { + target.addEventListener(evt, this.#onExit); + } + if (this.#triggerElement) { + this.addEventListener('mouseenter', this.#onEntry); + this.addEventListener('mouseleave', this.#onExit); + } + } + + #removeTriggerListeners(): void { + const target = this.#triggerElement ?? this; + for (const evt of ENTRY_EVENTS) { + target.removeEventListener(evt, this.#onEntry); + } + for (const evt of EXIT_EVENTS) { + target.removeEventListener(evt, this.#onExit); + } + if (this.#triggerElement) { + this.removeEventListener('mouseenter', this.#onEntry); + this.removeEventListener('mouseleave', this.#onExit); + } + } + + #onEntry = (event: Event): void => { + const reason = event.type as TooltipTriggerReason; + if (!this.dispatchEvent(new TooltipShowEvent(reason))) { + return; + } + this.#clearTimers(); + this.#entryTimeout = setTimeout(() => this.show(), this.entryDelay); + }; + + #onExit = (event: Event): void => { + const reason = event.type as TooltipTriggerReason; + if (!this.dispatchEvent(new TooltipHideEvent(reason))) { + return; + } + this.#clearTimers(); + this.#exitTimeout = setTimeout(() => this.hide(), this.exitDelay); + }; + + #onKeydown = (event: KeyboardEvent): void => { + if (event.key === 'Escape' && this.#float.open) { + this.hide(); + } + }; +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-v6-tooltip': PfV6Tooltip; + } +} diff --git a/elements/pf-v6-tooltip/test/pf-v6-tooltip.spec.ts b/elements/pf-v6-tooltip/test/pf-v6-tooltip.spec.ts new file mode 100644 index 0000000000..96e1002d55 --- /dev/null +++ b/elements/pf-v6-tooltip/test/pf-v6-tooltip.spec.ts @@ -0,0 +1,284 @@ +import { expect, html, fixture } from '@open-wc/testing'; + +import { PfV6Tooltip, TooltipShowEvent, TooltipHideEvent } from '../pf-v6-tooltip.js'; +import { setViewport, sendMouse } from '@web/test-runner-commands'; +import { a11ySnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; + +describe('', function() { + let element: PfV6Tooltip; + + beforeEach(async function() { + await setViewport({ width: 1000, height: 1000 }); + }); + + it('imperatively instantiates', function() { + expect(document.createElement('pf-v6-tooltip')).to.be.an.instanceof(PfV6Tooltip); + }); + + it('should upgrade', async function() { + element = await fixture(html``); + const klass = customElements.get('pf-v6-tooltip'); + expect(element) + .to.be.an.instanceOf(klass) + .and + .to.be.an.instanceOf(PfV6Tooltip); + }); + + describe('with content attribute', function() { + beforeEach(async function() { + element = await fixture(html` + + + + `); + }); + + it('should be accessible', async function() { + await expect(element).shadowDom.to.be.accessible(); + }); + + it('should hide tooltip content from assistive technology when closed', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot).to.axContainName('Trigger'); + expect(snapshot).to.not.axContainName('Tooltip text'); + }); + + describe('after calling show()', function() { + beforeEach(async function() { + await element.show(); + await element.updateComplete; + }); + + it('should show tooltip content to assistive technology', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot).to.axContainName('Trigger'); + expect(snapshot).to.axContainName('Tooltip text'); + }); + }); + + describe('after calling hide()', function() { + beforeEach(async function() { + await element.show(); + await element.updateComplete; + await element.hide(); + await element.updateComplete; + }); + + it('should hide tooltip content from assistive technology', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot).to.axContainName('Trigger'); + expect(snapshot).to.not.axContainName('Tooltip text'); + }); + }); + + describe('hovering the element', function() { + beforeEach(async function() { + const { x, y } = element.getBoundingClientRect(); + await sendMouse({ position: [x + 5, y + 5], type: 'move' }); + await new Promise(r => setTimeout(r, 400)); + await element.updateComplete; + }); + + it('should show tooltip content to assistive technology', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot).to.axContainName('Trigger'); + expect(snapshot).to.axContainName('Tooltip text'); + }); + }); + }); + + describe('with content slot', function() { + beforeEach(async function() { + element = await fixture(html` + + + Rich tooltip content + + `); + }); + + describe('after calling show()', function() { + beforeEach(async function() { + await element.show(); + await element.updateComplete; + }); + + it('should show slotted content', async function() { + const snapshot = await a11ySnapshot(); + const text = JSON.stringify(snapshot); + expect(text).to.include('Rich'); + expect(text).to.include('tooltip'); + }); + }); + }); + + describe('with position attribute', function() { + beforeEach(async function() { + element = await fixture(html` + + + + `); + }); + + it('should accept position attribute', function() { + expect(element.position).to.equal('bottom'); + }); + }); + + describe('with no-flip attribute', function() { + beforeEach(async function() { + element = await fixture(html` + + + + `); + }); + + it('should accept no-flip attribute', function() { + expect(element.noFlip).to.be.true; + }); + }); + + describe('with entry-delay and exit-delay', function() { + beforeEach(async function() { + element = await fixture(html` + + + + `); + }); + + it('should accept delay attributes', function() { + expect(element.entryDelay).to.equal(100); + expect(element.exitDelay).to.equal(50); + }); + }); + + describe('with alignment attribute', function() { + beforeEach(async function() { + element = await fixture(html` + + + + `); + }); + + it('should accept alignment attribute', function() { + expect(element.alignment).to.equal('start'); + }); + }); + + describe('with trigger attribute', function() { + let triggerButton: HTMLButtonElement; + + beforeEach(async function() { + const container = await fixture(html` +
+ + +
+ `); + triggerButton = container.querySelector('#ext-trigger')!; + element = container.querySelector('pf-v6-tooltip')!; + await element.updateComplete; + }); + + it('should accept trigger attribute', function() { + expect(element.trigger).to.equal('ext-trigger'); + }); + + describe('after calling show()', function() { + beforeEach(async function() { + await element.show(); + await element.updateComplete; + }); + + it('should show tooltip content', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot).to.axContainName('External tooltip'); + }); + }); + }); + + describe('show event', function() { + let showEvent: TooltipShowEvent | null; + + beforeEach(async function() { + showEvent = null; + element = await fixture(html` + + + + `); + element.addEventListener('show', function(e) { + showEvent = e as TooltipShowEvent; + }); + }); + + describe('hovering the element', function() { + beforeEach(async function() { + const { x, y } = element.getBoundingClientRect(); + await sendMouse({ position: [x + 5, y + 5], type: 'move' }); + await new Promise(r => setTimeout(r, 50)); + }); + + it('should fire show event with reason', function() { + expect(showEvent).to.be.an.instanceOf(TooltipShowEvent); + expect(showEvent!.reason).to.equal('mouseenter'); + }); + }); + }); + + describe('cancelling show event', function() { + beforeEach(async function() { + element = await fixture(html` + + + + `); + element.addEventListener('show', function(e) { + e.preventDefault(); + }); + }); + + describe('hovering the element', function() { + beforeEach(async function() { + const { x, y } = element.getBoundingClientRect(); + await sendMouse({ position: [x + 5, y + 5], type: 'move' }); + await new Promise(r => setTimeout(r, 100)); + await element.updateComplete; + }); + + it('should not show tooltip', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot).to.not.axContainName('Prevented'); + }); + }); + }); + + describe('Escape key', function() { + beforeEach(async function() { + element = await fixture(html` + + + + `); + await element.show(); + await element.updateComplete; + }); + + describe('pressing Escape', function() { + beforeEach(async function() { + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await element.updateComplete; + await new Promise(r => setTimeout(r, 50)); + }); + + it('should hide tooltip', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot).to.not.axContainName('Escapable'); + }); + }); + }); +});