From d48bff7d70ef63f2ce10cc39fdc1b5c7c1ee2bcb Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 18 Mar 2025 14:42:40 +0200 Subject: [PATCH 01/48] feat: Added tooltip component PoC implementation --- src/animations/player.ts | 9 + src/components/common/util.ts | 4 +- src/components/common/utils.spec.ts | 8 + src/components/popover/popover.ts | 68 ++++- .../tooltip/themes/tooltip.base.scss | 30 ++ .../tooltip/tooltip-event-controller.ts | 78 ++++++ src/components/tooltip/tooltip-service.ts | 67 +++++ src/components/tooltip/tooltip.spec.ts | 263 ++++++++++++++++++ src/components/tooltip/tooltip.ts | 248 +++++++++++++++++ src/index.ts | 2 + stories/tooltip.stories.ts | 258 +++++++++++++++++ 11 files changed, 1028 insertions(+), 7 deletions(-) create mode 100644 src/components/tooltip/themes/tooltip.base.scss create mode 100644 src/components/tooltip/tooltip-event-controller.ts create mode 100644 src/components/tooltip/tooltip-service.ts create mode 100644 src/components/tooltip/tooltip.spec.ts create mode 100644 src/components/tooltip/tooltip.ts create mode 100644 stories/tooltip.stories.ts diff --git a/src/animations/player.ts b/src/animations/player.ts index 9bb856aaf..9d1041c57 100644 --- a/src/animations/player.ts +++ b/src/animations/player.ts @@ -71,6 +71,15 @@ class AnimationController implements ReactiveController { ); } + public async playExclusive(animation: AnimationReferenceMetadata) { + const [_, event] = await Promise.all([ + this.stopAll(), + this.play(animation), + ]); + + return event.type === 'finish'; + } + public hostConnected() {} } diff --git a/src/components/common/util.ts b/src/components/common/util.ts index 740ba03fd..73ccdff13 100644 --- a/src/components/common/util.ts +++ b/src/components/common/util.ts @@ -293,9 +293,9 @@ export function isString(value: unknown): value is string { } /** - * Returns whether a given collection has at least one member. + * Returns whether a given collection is empty. */ -export function isEmpty( +export function isEmpty( x: ArrayLike | Set | Map ): boolean { return 'length' in x ? x.length < 1 : x.size < 1; diff --git a/src/components/common/utils.spec.ts b/src/components/common/utils.spec.ts index e185bc0b7..120c32e8b 100644 --- a/src/components/common/utils.spec.ts +++ b/src/components/common/utils.spec.ts @@ -202,6 +202,14 @@ export function simulatePointerLeave( ); } +export function simulateFocus(node: Element) { + node.dispatchEvent(new FocusEvent('focus')); +} + +export function simulateBlur(node: Element) { + node.dispatchEvent(new FocusEvent('blur')); +} + export function simulatePointerDown( node: Element, options?: PointerEventInit, diff --git a/src/components/popover/popover.ts b/src/components/popover/popover.ts index a2c3a0f5e..ee6d5d42e 100644 --- a/src/components/popover/popover.ts +++ b/src/components/popover/popover.ts @@ -1,8 +1,12 @@ import { type Middleware, + type MiddlewareData, + type Placement, + arrow, autoUpdate, computePosition, flip, + inline, limitShift, offset, shift, @@ -14,6 +18,7 @@ import { property, query, queryAssignedElements } from 'lit/decorators.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import { + first, getElementByIdFromRoot, isEmpty, isString, @@ -72,6 +77,19 @@ export default class IgcPopoverComponent extends LitElement { @property() public anchor?: Element | string; + /** + * Element to render as an "arrow" element for the current popover. + */ + @property({ attribute: false }) + public arrow: HTMLElement | null = null; + + /** + * Improves positioning for inline reference elements that span over multiple lines. + * Useful for tooltips or similar components. + */ + @property({ type: Boolean, reflect: true }) + public inline = false; + /** * When enabled this changes the placement of the floating element in order to keep it * in view along the main axis. @@ -127,7 +145,9 @@ export default class IgcPopoverComponent extends LitElement { this.open ? this.show() : this.hide(); } + @watch('arrow', { waitUntilFirstUpdate: true }) @watch('flip', { waitUntilFirstUpdate: true }) + @watch('inline', { waitUntilFirstUpdate: true }) @watch('offset', { waitUntilFirstUpdate: true }) @watch('placement', { waitUntilFirstUpdate: true }) @watch('sameWidth', { waitUntilFirstUpdate: true }) @@ -187,6 +207,10 @@ export default class IgcPopoverComponent extends LitElement { middleware.push(offset(this.offset)); } + if (this.inline) { + middleware.push(inline()); + } + if (this.shift) { middleware.push( shift({ @@ -195,6 +219,10 @@ export default class IgcPopoverComponent extends LitElement { ); } + if (this.arrow) { + middleware.push(arrow({ element: this.arrow })); + } + if (this.flip) { middleware.push(flip()); } @@ -226,17 +254,47 @@ export default class IgcPopoverComponent extends LitElement { return; } - const { x, y } = await computePosition(this.target, this._container, { - placement: this.placement ?? 'bottom-start', - middleware: this._createMiddleware(), - strategy: 'fixed', - }); + const { x, y, middlewareData, placement } = await computePosition( + this.target, + this._container, + { + placement: this.placement ?? 'bottom-start', + middleware: this._createMiddleware(), + strategy: 'fixed', + } + ); Object.assign(this._container.style, { left: 0, top: 0, transform: `translate(${roundByDPR(x)}px,${roundByDPR(y)}px)`, }); + + this._positionArrow(placement, middlewareData); + } + + private _positionArrow(placement: Placement, data: MiddlewareData) { + if (!data.arrow) { + return; + } + + const { x, y } = data.arrow; + + const staticSide = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right', + }[first(placement.split('-'))]!; + + // TODO: Clean-up this stuff + Object.assign(this.arrow!.style, { + left: x !== null ? `${roundByDPR(x!)}px` : '', + top: y !== null ? `${roundByDPR(y!)}px` : '', + right: '', + bottom: '', + [staticSide]: '-4px', + }); } private _anchorSlotChange() { diff --git a/src/components/tooltip/themes/tooltip.base.scss b/src/components/tooltip/themes/tooltip.base.scss new file mode 100644 index 000000000..c53952395 --- /dev/null +++ b/src/components/tooltip/themes/tooltip.base.scss @@ -0,0 +1,30 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + position: fixed; + display: flex; + align-items: center; + text-align: center; +} + +[part="base"] { + @include type-style('body-2'); + + background: linear-gradient(to bottom, #323232 0%, #3F3F3F 40%, #1C1C1C 150%), linear-gradient(to top, rgb(255 255 255 / 40%) 0%, rgb(0 0 0 / 25%) 200%); + background-blend-mode: multiply; + padding: 0.25rem; + color: #fff; +} + +#arrow { + position: absolute; + width: 8px; + height: 8px; + transform: rotate(45deg); + background: inherit; +} + +igc-popover::part(container) { + background-color: transparent; +} diff --git a/src/components/tooltip/tooltip-event-controller.ts b/src/components/tooltip/tooltip-event-controller.ts new file mode 100644 index 000000000..6c5ca1176 --- /dev/null +++ b/src/components/tooltip/tooltip-event-controller.ts @@ -0,0 +1,78 @@ +import type { ReactiveController } from 'lit'; +import type IgcTooltipComponent from './tooltip.js'; + +type TooltipAnchor = Element | null | undefined; +type TooltipTriggers = { + show: string[]; + hide: string[]; +}; + +class TooltipController implements ReactiveController { + private readonly _host: IgcTooltipComponent; + private _showTriggers: string[] = []; + private _hideTriggers: string[] = []; + + constructor(host: IgcTooltipComponent) { + this._host = host; + this._host.addController(this); + } + + /** + * Sets the current collections of show/hide triggers on the given anchor for the tooltip. + * Removes any previously set triggers. + */ + public set(anchor: TooltipAnchor, triggers: TooltipTriggers) { + if (!anchor) { + return; + } + + const { show, hide } = triggers; + this._showTriggers = show; + this._hideTriggers = hide; + + this.remove(anchor); + + for (const trigger of show) { + anchor.addEventListener(trigger, this._host.show); + } + + for (const trigger of hide) { + anchor.addEventListener(trigger, this._host[hideOnTrigger]); + } + } + + /** Removes all tooltip trigger events from the given anchor */ + public remove(anchor?: TooltipAnchor) { + if (!anchor) { + return; + } + + for (const trigger of this._showTriggers) { + anchor.removeEventListener(trigger, this._host.show); + } + + for (const trigger of this._hideTriggers) { + anchor.removeEventListener(trigger, this._host[hideOnTrigger]); + } + } + + /** @internal */ + public hostConnected(): void { + this._host.addEventListener('pointerenter', this._host.show); + this._host.addEventListener('pointerleave', this._host[hideOnTrigger]); + } + + /** @internal */ + public hostDisconnected(): void { + this._host.removeEventListener('pointerenter', this._host.show); + this._host.removeEventListener('pointerleave', this._host[hideOnTrigger]); + } +} + +export const hideOnTrigger = Symbol(); + +export function addTooltipController( + host: IgcTooltipComponent +): TooltipController { + return new TooltipController(host); +} diff --git a/src/components/tooltip/tooltip-service.ts b/src/components/tooltip/tooltip-service.ts new file mode 100644 index 000000000..f7b073f0d --- /dev/null +++ b/src/components/tooltip/tooltip-service.ts @@ -0,0 +1,67 @@ +import { isServer } from 'lit'; +import { escapeKey } from '../common/controllers/key-bindings.js'; +import { isEmpty, last } from '../common/util.js'; +import { hideOnTrigger } from './tooltip-event-controller.js'; +import type IgcTooltipComponent from './tooltip.js'; + +class TooltipEscapeCallbacks { + private _collection: Map = new Map(); + + /** + * Sets the global Escape key handler for closing any open igc-tooltip instances. + * + */ + private _setListeners(): void { + if (isServer) { + return; + } + + if (isEmpty(this._collection)) { + document.documentElement.addEventListener('keydown', this); + } + } + + /** + * Removes the global Escape key handler for closing any open igc-tooltip instances. + */ + private _removeListeners(): void { + if (isServer) { + return; + } + + if (isEmpty(this._collection)) { + document.documentElement.removeEventListener('keydown', this); + } + } + + public add(instance: IgcTooltipComponent): void { + if (this._collection.has(instance)) { + return; + } + + this._setListeners(); + this._collection.set(instance, instance[hideOnTrigger]); + } + + public remove(instance: IgcTooltipComponent): void { + if (!this._collection.has(instance)) { + return; + } + + this._collection.delete(instance); + this._removeListeners(); + } + + /** @internal */ + public handleEvent(event: KeyboardEvent): void { + if (event.key !== escapeKey) { + return; + } + + const callback = last(Array.from(this._collection.values())); + callback?.(); + } +} + +const service = new TooltipEscapeCallbacks(); +export default service; diff --git a/src/components/tooltip/tooltip.spec.ts b/src/components/tooltip/tooltip.spec.ts new file mode 100644 index 000000000..74e5fac6c --- /dev/null +++ b/src/components/tooltip/tooltip.spec.ts @@ -0,0 +1,263 @@ +import { + elementUpdated, + expect, + fixture, + html, + nextFrame, +} from '@open-wc/testing'; +import { type SinonFakeTimers, useFakeTimers } from 'sinon'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { + finishAnimationsFor, + simulateBlur, + simulateClick, + simulateFocus, + simulatePointerEnter, + simulatePointerLeave, +} from '../common/utils.spec.js'; +import IgcTooltipComponent from './tooltip.js'; + +// TODO: Add more tests + +describe('Tooltip', () => { + let tooltip: IgcTooltipComponent; + let clock: SinonFakeTimers; + + before(() => { + defineComponents(IgcTooltipComponent); + }); + + async function showComplete(instance: IgcTooltipComponent = tooltip) { + finishAnimationsFor(instance.shadowRoot!); + await elementUpdated(instance); + } + + async function hideComplete(instance: IgcTooltipComponent = tooltip) { + await elementUpdated(instance); + await clock.runAllAsync(); + finishAnimationsFor(instance.shadowRoot!); + await nextFrame(); + } + + describe('DOM (with explicit target)', () => { + beforeEach(async () => { + const container = await fixture(createTooltipWithTarget()); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + }); + + it('is defined', async () => { + expect(tooltip).to.exist; + }); + }); + + describe('DOM', () => { + beforeEach(async () => { + const container = await fixture(createDefaultTooltip()); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + }); + + it('is defined', async () => { + expect(tooltip).to.exist; + }); + + it('is accessible', async () => { + await expect(tooltip).to.be.accessible(); + await expect(tooltip).shadowDom.to.be.accessible(); + }); + }); + + describe('Initially open on first render', () => { + beforeEach(async () => { + const container = await fixture(createDefaultTooltip(true)); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + }); + + it('', async () => { + expect(tooltip).to.exist; + expect(tooltip.open).to.be.true; + }); + }); + + describe('Initially open on first render with target', () => { + beforeEach(async () => { + const container = await fixture(createTooltipWithTarget(true)); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + }); + + it('', async () => { + expect(tooltip).to.exist; + expect(tooltip.open).to.be.true; + }); + }); + + describe('Behaviors', () => { + let anchor: HTMLButtonElement; + + beforeEach(async () => { + clock = useFakeTimers({ toFake: ['setTimeout'] }); + const container = await fixture(createDefaultTooltip()); + anchor = container.querySelector('button')!; + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + }); + + afterEach(() => { + clock.restore(); + }); + + it('default triggers', async () => { + expect(tooltip.showTriggers).to.equal('pointerenter'); + expect(tooltip.hideTriggers).to.equal('pointerleave'); + + simulatePointerEnter(anchor); + await showComplete(); + expect(tooltip.open).to.be.true; + + simulatePointerLeave(anchor); + await hideComplete(); + expect(tooltip.open).to.be.false; + }); + + it('custom triggers', async () => { + tooltip.showTriggers = 'focus,click'; + tooltip.hideTriggers = 'blur'; + + simulateFocus(anchor); + await showComplete(); + expect(tooltip.open).to.be.true; + + simulateBlur(anchor); + await hideComplete(); + expect(tooltip.open).to.be.false; + + simulateClick(anchor); + await showComplete(); + expect(tooltip.open).to.be.true; + + simulateBlur(anchor); + await hideComplete(); + expect(tooltip.open).to.be.false; + }); + + it('pointerenter over tooltip prevents hiding and pointerleave triggers hiding', async () => { + simulatePointerEnter(anchor); + await showComplete(); + expect(tooltip.open).to.be.true; + + // Move cursor from anchor to tooltip + simulatePointerLeave(anchor); + await nextFrame(); + simulatePointerEnter(tooltip); + await nextFrame(); + + expect(tooltip.open).to.be.true; + + // Move cursor outside the tooltip + simulatePointerLeave(tooltip); + await hideComplete(); + + expect(tooltip.open).to.be.false; + }); + }); + + describe('Keyboard interactions', () => { + beforeEach(async () => { + clock = useFakeTimers({ toFake: ['setTimeout'] }); + }); + + afterEach(() => { + clock.restore(); + }); + + it('pressing Escape in an active page hides the tooltip', async () => { + const container = await fixture(createTooltips()); + const [anchor, _] = container.querySelectorAll('button'); + const [tooltip, __] = container.querySelectorAll( + IgcTooltipComponent.tagName + ); + + simulatePointerEnter(anchor); + await showComplete(tooltip); + + document.documentElement.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + composed: true, + }) + ); + await hideComplete(tooltip); + + expect(tooltip.open).to.be.false; + }); + + it('pressing Escape in an active page with multiple opened tooltips hides the last shown', async () => { + const container = await fixture(createTooltips()); + const [first, last] = container.querySelectorAll( + IgcTooltipComponent.tagName + ); + + first.show(); + last.show(); + await showComplete(first); + await showComplete(last); + + document.documentElement.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + composed: true, + }) + ); + + await hideComplete(first); + await hideComplete(last); + + expect(last.open).to.be.false; + expect(first.open).to.be.true; + + document.documentElement.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + composed: true, + }) + ); + + await hideComplete(first); + await hideComplete(last); + + expect(last.open).to.be.false; + expect(first.open).to.be.false; + }); + }); +}); + +function createDefaultTooltip(isOpen = false) { + return html` +
+ + It works! +
+ `; +} + +function createTooltipWithTarget(isOpen = false) { + return html` +
+ + + It works! +
+ `; +} + +function createTooltips() { + return html` +
+ + First + + Second +
+ `; +} diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts new file mode 100644 index 000000000..3d428fcd6 --- /dev/null +++ b/src/components/tooltip/tooltip.ts @@ -0,0 +1,248 @@ +import { LitElement, html, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { type Ref, createRef, ref } from 'lit/directives/ref.js'; +import { addAnimationController } from '../../animations/player.js'; +import { fadeIn, fadeOut } from '../../animations/presets/fade/index.js'; +import { watch } from '../common/decorators/watch.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { getElementByIdFromRoot, isString } from '../common/util.js'; +import IgcPopoverComponent, { type IgcPlacement } from '../popover/popover.js'; +import { styles } from './themes/tooltip.base.css.js'; +import { + addTooltipController, + hideOnTrigger, +} from './tooltip-event-controller.js'; +import service from './tooltip-service.js'; + +// TODO: Expose events + +function parseTriggers(string: string): string[] { + return (string ?? '').split(',').map((part) => part.trim()); +} + +/** + * @element igc-tooltip + * + * @slot - default slot + */ +export default class IgcTooltipComponent extends LitElement { + public static readonly tagName = 'igc-tooltip'; + + public static override styles = styles; + + /* blazorSuppress */ + public static register() { + registerComponent(IgcTooltipComponent, IgcPopoverComponent); + } + private _internals: ElementInternals; + private _controller = addTooltipController(this); + private _target?: Element | null; + private _containerRef: Ref = createRef(); + private _animationPlayer = addAnimationController(this, this._containerRef); + + private _autoHideTimeout?: number; + private _open = false; + private _showTriggers = ['pointerenter']; + private _hideTriggers = ['pointerleave']; + + @query('#arrow') + protected _arrowElement!: HTMLElement; + + /** + * Whether the tooltip is showing. + * + * @attr + */ + @property({ type: Boolean, reflect: true }) + public set open(value: boolean) { + this._open = value; + this._open ? service.add(this) : service.remove(this); + } + + public get open(): boolean { + return this._open; + } + + /** + * Whether to disable the rendering of the arrow indicator for the tooltip. + * + * @attr disable-arrow + */ + @property({ attribute: 'disable-arrow', type: Boolean, reflect: true }) + public disableArrow = false; + + /** + * Improves positioning for inline based elements, such as links. + * + * @attr inline + */ + @property({ type: Boolean, reflect: true }) + public inline = false; + + /** + * The offset of the tooltip from the anchor. + * + * @attr offset + */ + @property({ type: Number }) + public offset = 4; + + /** + * Where to place the floating element relative to the parent anchor element. + * + * @attr placement + */ + @property() + public placement: IgcPlacement = 'top'; + + /** + * An element instance or an IDREF to use as the anchor for the tooltip. + * + * @attr anchor + */ + @property() + public anchor?: Element | string; + + /** + * Which event triggers will show the tooltip. + * Expects a comma separate string of different event triggers. + * + * @attr show-triggers + */ + @property({ attribute: 'show-triggers' }) + public set showTriggers(value: string) { + this._showTriggers = parseTriggers(value); + this._controller.set(this._target, { + show: this._showTriggers, + hide: this._hideTriggers, + }); + } + + public get showTriggers(): string { + return this._showTriggers.join(); + } + + /** + * Which event triggers will hide the tooltip. + * Expects a comma separate string of different event triggers. + * + * @attr hide-triggers + */ + @property({ attribute: 'hide-triggers' }) + public set hideTriggers(value: string) { + this._hideTriggers = parseTriggers(value); + this._controller.set(this._target, { + show: this._showTriggers, + hide: this._showTriggers, + }); + } + + public get hideTriggers(): string { + return this._hideTriggers.join(); + } + + constructor() { + super(); + + this._internals = this.attachInternals(); + this._internals.role = 'tooltip'; + } + + protected override async firstUpdated() { + if (!this._target) { + this._setTarget(this.previousElementSibling); + } + + if (this.open) { + await this.updateComplete; + this.requestUpdate(); + } + } + + /** @internal */ + public override disconnectedCallback() { + this._controller.remove(this._target); + service.remove(this); + + super.disconnectedCallback(); + } + + @watch('anchor') + protected _anchorChanged() { + const target = isString(this.anchor) + ? getElementByIdFromRoot(this, this.anchor) + : this.anchor; + + this._setTarget(target); + } + + private _setTarget(target?: Element | null) { + if (!target) { + return; + } + + this._target = target; + this._controller.set(target, { + show: this._showTriggers, + hide: this._hideTriggers, + }); + } + + private async _toggleAnimation(dir: 'open' | 'close') { + const animation = dir === 'open' ? fadeIn : fadeOut; + return this._animationPlayer.playExclusive(animation()); + } + + /** Shows the tooltip if not already showing. */ + public show = async () => { + clearTimeout(this._autoHideTimeout); + if (this.open) { + return false; + } + + this.open = true; + return await this._toggleAnimation('open'); + }; + + /** Hides the tooltip if not already hidden. */ + public hide = async () => { + if (!this.open) { + return false; + } + + await this._toggleAnimation('close'); + this.open = false; + clearTimeout(this._autoHideTimeout); + return true; + }; + + protected [hideOnTrigger] = () => { + this._autoHideTimeout = setTimeout(() => this.hide(), 180); + }; + + protected override render() { + return html` + +
+ + ${this.disableArrow ? nothing : html`
`} +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-tooltip': IgcTooltipComponent; + } +} diff --git a/src/index.ts b/src/index.ts index d6d209fa5..cf3447dd8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,7 @@ export { default as IgcTreeComponent } from './components/tree/tree.js'; export { default as IgcTreeItemComponent } from './components/tree/tree-item.js'; export { default as IgcStepperComponent } from './components/stepper/stepper.js'; export { default as IgcStepComponent } from './components/stepper/step.js'; +export { default as IgcTooltipComponent } from './components/tooltip/tooltip.js'; // definitions export { defineComponents } from './components/common/definitions/defineComponents.js'; @@ -144,3 +145,4 @@ export type { IgcComboChangeEventArgs, } from './components/combo/types.js'; export type { IconMeta } from './components/icon/registry/types.js'; +export type { IgcPlacement } from './components/popover/popover.js'; diff --git a/stories/tooltip.stories.ts b/stories/tooltip.stories.ts new file mode 100644 index 000000000..067184b0e --- /dev/null +++ b/stories/tooltip.stories.ts @@ -0,0 +1,258 @@ +import type { Meta, StoryObj } from '@storybook/web-components'; + +import { + IgcAvatarComponent, + IgcButtonComponent, + IgcCardComponent, + IgcInputComponent, + IgcTooltipComponent, + defineComponents, +} from 'igniteui-webcomponents'; +import { html } from 'lit'; + +defineComponents( + IgcButtonComponent, + IgcInputComponent, + IgcTooltipComponent, + IgcCardComponent, + IgcAvatarComponent +); + +// region default +const metadata: Meta = { + title: 'Tooltip', + component: 'igc-tooltip', + parameters: { docs: { description: { component: '' } } }, + argTypes: { + open: { + type: 'boolean', + description: 'Whether the tooltip is showing.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + disableArrow: { + type: 'boolean', + description: + 'Whether to disable the rendering of the arrow indicator for the tooltip.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + inline: { + type: 'boolean', + description: + 'Improves positioning for inline based elements, such as links.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + offset: { + type: 'number', + description: 'The offset of the tooltip from the anchor.', + control: 'number', + table: { defaultValue: { summary: '4' } }, + }, + placement: { + type: '"top" | "top-start" | "top-end" | "bottom" | "bottom-start" | "bottom-end" | "right" | "right-start" | "right-end" | "left" | "left-start" | "left-end"', + description: + 'Where to place the floating element relative to the parent anchor element.', + options: [ + 'top', + 'top-start', + 'top-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'right', + 'right-start', + 'right-end', + 'left', + 'left-start', + 'left-end', + ], + control: { type: 'select' }, + table: { defaultValue: { summary: 'top' } }, + }, + anchor: { + type: 'Element | string', + description: + 'An element instance or an IDREF to use as the anchor for the tooltip.', + options: ['Element', 'string'], + control: { type: 'inline-radio' }, + }, + showTriggers: { + type: 'string', + description: + 'Which event triggers will show the tooltip.\nExpects a comma separate string of different event triggers.', + control: 'text', + }, + hideTriggers: { + type: 'string', + description: + 'Which event triggers will hide the tooltip.\nExpects a comma separate string of different event triggers.', + control: 'text', + }, + }, + args: { + open: false, + disableArrow: false, + inline: false, + offset: 4, + placement: 'top', + }, +}; + +export default metadata; + +interface IgcTooltipArgs { + /** Whether the tooltip is showing. */ + open: boolean; + /** Whether to disable the rendering of the arrow indicator for the tooltip. */ + disableArrow: boolean; + /** Improves positioning for inline based elements, such as links. */ + inline: boolean; + /** The offset of the tooltip from the anchor. */ + offset: number; + /** Where to place the floating element relative to the parent anchor element. */ + placement: + | 'top' + | 'top-start' + | 'top-end' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'right' + | 'right-start' + | 'right-end' + | 'left' + | 'left-start' + | 'left-end'; + /** An element instance or an IDREF to use as the anchor for the tooltip. */ + anchor: Element | string; + /** + * Which event triggers will show the tooltip. + * Expects a comma separate string of different event triggers. + */ + showTriggers: string; + /** + * Which event triggers will hide the tooltip. + * Expects a comma separate string of different event triggers. + */ + hideTriggers: string; +} +type Story = StoryObj; + +// endregion + +export const Basic: Story = { + render: (args) => html` + +
+ + With an IDREF reference... +
+
+ + Focus me + + + I will be shown until you blur the button above. + + + + Minimum of 12 characters + + TOP KEK + Initial open state + +

+ Here is some text with a + element + 😎 + that has a tooltip. +

+ + + +
+
+ + + +

HERE

+
By Mellow D
+
+ +

+ Far far away, behind the word mountains, far from the countries + Vokalia and Consonantia, there live the blind texts. +

+
+ + PLAY ALBUM + +

Look mom, tooltip inception!

+ +

I can't stop...

+ HELP! +
+
+
+
+ + + +
+
+
+

Hover for more!

+ + + Congrats you've changed the value! + `, +}; + +function getValue() { + return document.querySelector('igc-input')?.value; +} + +export const Triggers: Story = { + render: () => html` + Pointerenter/Pointerleave (default) + + I will show on pointerenter and hide on pointerleave + + + Focus/Blur + + I will show on focus and hide on blur + + + Click + + I will show on click and hide on pointerleave or blur + + + Keydown + + I will show on keydown and hide on blur + + + + + You've changed the value to ${getValue()} + + `, +}; From 94b25ed560572106c3b1bc876561be8dc17f1f5c Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Mon, 24 Mar 2025 15:41:47 +0200 Subject: [PATCH 02/48] feat(tooltip): Add show and hide delay properties with event handling --- .../tooltip/tooltip-event-controller.ts | 11 +- src/components/tooltip/tooltip.ts | 140 ++++++++++++++++-- stories/tooltip.stories.ts | 25 +++- 3 files changed, 155 insertions(+), 21 deletions(-) diff --git a/src/components/tooltip/tooltip-event-controller.ts b/src/components/tooltip/tooltip-event-controller.ts index 6c5ca1176..563c95558 100644 --- a/src/components/tooltip/tooltip-event-controller.ts +++ b/src/components/tooltip/tooltip-event-controller.ts @@ -33,11 +33,11 @@ class TooltipController implements ReactiveController { this.remove(anchor); for (const trigger of show) { - anchor.addEventListener(trigger, this._host.show); + anchor.addEventListener(trigger, this._host[showOnTrigger]); } for (const trigger of hide) { - anchor.addEventListener(trigger, this._host[hideOnTrigger]); + anchor.addEventListener(trigger, (ev) => this._host[hideOnTrigger](ev)); } } @@ -48,7 +48,7 @@ class TooltipController implements ReactiveController { } for (const trigger of this._showTriggers) { - anchor.removeEventListener(trigger, this._host.show); + anchor.removeEventListener(trigger, this._host[showOnTrigger]); } for (const trigger of this._hideTriggers) { @@ -58,17 +58,18 @@ class TooltipController implements ReactiveController { /** @internal */ public hostConnected(): void { - this._host.addEventListener('pointerenter', this._host.show); + // this._host.addEventListener('pointerenter', this._host[showOnTrigger]); this._host.addEventListener('pointerleave', this._host[hideOnTrigger]); } /** @internal */ public hostDisconnected(): void { - this._host.removeEventListener('pointerenter', this._host.show); + // this._host.removeEventListener('pointerenter', this._host[showOnTrigger]); this._host.removeEventListener('pointerleave', this._host[hideOnTrigger]); } } +export const showOnTrigger = Symbol(); export const hideOnTrigger = Symbol(); export function addTooltipController( diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index 3d428fcd6..46fc4164b 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -5,16 +5,25 @@ import { addAnimationController } from '../../animations/player.js'; import { fadeIn, fadeOut } from '../../animations/presets/fade/index.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; +import type { Constructor } from '../common/mixins/constructor.js'; +import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { getElementByIdFromRoot, isString } from '../common/util.js'; import IgcPopoverComponent, { type IgcPlacement } from '../popover/popover.js'; import { styles } from './themes/tooltip.base.css.js'; import { addTooltipController, hideOnTrigger, + showOnTrigger, } from './tooltip-event-controller.js'; import service from './tooltip-service.js'; // TODO: Expose events +export interface IgcTooltipComponentEventMap { + igcOpening: CustomEvent; + igcOpened: CustomEvent; + igcClosing: CustomEvent; + igcClosed: CustomEvent; +} function parseTriggers(string: string): string[] { return (string ?? '').split(',').map((part) => part.trim()); @@ -24,11 +33,19 @@ function parseTriggers(string: string): string[] { * @element igc-tooltip * * @slot - default slot + * + * @fires igcOpening - Emitted before the tooltip begins to open. Can be canceled to prevent opening. + * @fires igcOpened - Emitted after the tooltip has successfully opened and is visible. + * @fires igcClosing - Emitted before the tooltip begins to close. Can be canceled to prevent closing. + * @fires igcClosed - Emitted after the tooltip has been fully removed from view. */ -export default class IgcTooltipComponent extends LitElement { +export default class IgcTooltipComponent extends EventEmitterMixin< + IgcTooltipComponentEventMap, + Constructor +>(LitElement) { public static readonly tagName = 'igc-tooltip'; - public static override styles = styles; + public static styles = styles; /* blazorSuppress */ public static register() { @@ -40,10 +57,14 @@ export default class IgcTooltipComponent extends LitElement { private _containerRef: Ref = createRef(); private _animationPlayer = addAnimationController(this, this._containerRef); - private _autoHideTimeout?: number; + private _timeoutId?: number; + private toBeShown = false; + private toBeHidden = false; private _open = false; private _showTriggers = ['pointerenter']; private _hideTriggers = ['pointerleave']; + private _showDelay = 500; + private _hideDelay = 500; @query('#arrow') protected _arrowElement!: HTMLElement; @@ -133,7 +154,7 @@ export default class IgcTooltipComponent extends LitElement { this._hideTriggers = parseTriggers(value); this._controller.set(this._target, { show: this._showTriggers, - hide: this._showTriggers, + hide: this._hideTriggers, }); } @@ -141,6 +162,34 @@ export default class IgcTooltipComponent extends LitElement { return this._hideTriggers.join(); } + /** + * Specifies the number of milliseconds that should pass before showing the tooltip. + * + * @attr show-delay + */ + @property({ attribute: 'show-delay', type: Number }) + public set showDelay(value: number) { + this._showDelay = Math.max(0, value); + } + + public get showDelay(): number { + return this._showDelay; + } + + /** + * Specifies the number of milliseconds that should pass before hiding the tooltip. + * + * @attr hide-delay + */ + @property({ attribute: 'hide-delay', type: Number }) + public set hideDelay(value: number) { + this._hideDelay = Math.max(0, value); + } + + public get hideDelay(): number { + return this._hideDelay; + } + constructor() { super(); @@ -193,31 +242,92 @@ export default class IgcTooltipComponent extends LitElement { return this._animationPlayer.playExclusive(animation()); } + private async _forceAnimationStop() { + this.toBeShown = false; + this.toBeHidden = false; + this.open = !this.open; + this._animationPlayer.stopAll(); + } + /** Shows the tooltip if not already showing. */ - public show = async () => { - clearTimeout(this._autoHideTimeout); + public show = async (): Promise => { + clearTimeout(this._timeoutId); if (this.open) { return false; } - this.open = true; - return await this._toggleAnimation('open'); + return new Promise((resolve) => { + this._timeoutId = setTimeout(async () => { + this.open = true; + this.toBeShown = true; + resolve(await this._toggleAnimation('open')); + this.toBeShown = false; + }, this.showDelay); + }); }; /** Hides the tooltip if not already hidden. */ - public hide = async () => { + public hide = async (): Promise => { + clearTimeout(this._timeoutId); if (!this.open) { return false; } - await this._toggleAnimation('close'); - this.open = false; - clearTimeout(this._autoHideTimeout); - return true; + return new Promise((resolve) => { + this._timeoutId = setTimeout(async () => { + this.open = false; + this.toBeHidden = true; + resolve(await this._toggleAnimation('close')); + this.toBeHidden = false; + }, this.hideDelay); + }); + }; + + public showWithEvent = async () => { + clearTimeout(this._timeoutId); + if (this.toBeHidden) { + await this._forceAnimationStop(); + return; + } + if ( + this.open || + !this.emitEvent('igcOpening', { cancelable: true, detail: this._target }) + ) { + return; + } + if (await this.show()) { + this.emitEvent('igcOpened', { detail: this._target }); + } }; - protected [hideOnTrigger] = () => { - this._autoHideTimeout = setTimeout(() => this.hide(), 180); + public hideWithEvent = async () => { + clearTimeout(this._timeoutId); + if (this.toBeShown) { + await this._forceAnimationStop(); + return; + } + if ( + !this.open || + !this.emitEvent('igcClosing', { cancelable: true, detail: this._target }) + ) { + return; + } + if (await this.hide()) { + this.emitEvent('igcClosed', { detail: this._target }); + } + }; + + protected [showOnTrigger] = () => { + this.showWithEvent(); + }; + + protected [hideOnTrigger] = (ev: Event) => { + const related = (ev as PointerEvent).relatedTarget as Node | null; + // If the pointer moved into the tooltip element, don't hide + if (related && (this.contains(related) || this._target?.contains(related))) + return; + + this.hideWithEvent(); }; protected override render() { diff --git a/stories/tooltip.stories.ts b/stories/tooltip.stories.ts index 067184b0e..3a00f8c80 100644 --- a/stories/tooltip.stories.ts +++ b/stories/tooltip.stories.ts @@ -22,7 +22,12 @@ defineComponents( const metadata: Meta = { title: 'Tooltip', component: 'igc-tooltip', - parameters: { docs: { description: { component: '' } } }, + parameters: { + docs: { description: { component: '' } }, + actions: { + handles: ['igcOpening', 'igcOpened', 'igcClosing', 'igcClosed'], + }, + }, argTypes: { open: { type: 'boolean', @@ -90,6 +95,18 @@ const metadata: Meta = { 'Which event triggers will hide the tooltip.\nExpects a comma separate string of different event triggers.', control: 'text', }, + showDelay: { + type: 'number', + description: + 'Specifies the number of milliseconds that should pass before showing the tooltip.', + control: 'number', + }, + hideDelay: { + type: 'number', + description: + 'Specifies the number of milliseconds that should pass before hiding the tooltip.', + control: 'number', + }, }, args: { open: false, @@ -137,6 +154,10 @@ interface IgcTooltipArgs { * Expects a comma separate string of different event triggers. */ hideTriggers: string; + /** Specifies the number of milliseconds that should pass before showing the tooltip. */ + showDelay: number; + /** Specifies the number of milliseconds that should pass before hiding the tooltip. */ + hideDelay: number; } type Story = StoryObj; @@ -159,6 +180,8 @@ export const Basic: Story = { Date: Mon, 24 Mar 2025 16:43:00 +0200 Subject: [PATCH 03/48] feat(tooltip): Add toggle method for showing/hiding tooltip with button interaction --- src/components/tooltip/tooltip.ts | 5 +++++ stories/tooltip.stories.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index 46fc4164b..f9fbd727c 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -283,6 +283,11 @@ export default class IgcTooltipComponent extends EventEmitterMixin< }); }; + /** Toggles the tooltip between shown/hidden state after the appropriate delay. */ + public toggle = async (): Promise => { + return this.open ? this.hide() : this.show(); + }; + public showWithEvent = async () => { clearTimeout(this._timeoutId); if (this.toBeHidden) { diff --git a/stories/tooltip.stories.ts b/stories/tooltip.stories.ts index 3a00f8c80..3a31a4702 100644 --- a/stories/tooltip.stories.ts +++ b/stories/tooltip.stories.ts @@ -279,3 +279,33 @@ export const Triggers: Story = { `, }; + +export const Toggle: Story = { + render: () => { + // Use a template ref id to target the tooltip instance + const tooltipId = 'toggle-tooltip'; + const buttonId = 'toggle-button'; + + // Hook into the rendered DOM to attach click listener + setTimeout(() => { + const tooltip = document.getElementById(tooltipId) as IgcTooltipComponent; + const button = document.getElementById(buttonId) as HTMLButtonElement; + + if (tooltip && button) { + button.addEventListener('click', () => tooltip.toggle()); + } + }); + + return html` + Toggle Tooltip + + This tooltip toggles on button click! + + `; + }, +}; From c7c418284ee6d3697702eb61d7ca110e7dfa9958 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Mon, 24 Mar 2025 16:51:33 +0200 Subject: [PATCH 04/48] feat(tooltip): Add message property for plain text tooltip content --- src/components/tooltip/tooltip.ts | 10 +++++++++- stories/tooltip.stories.ts | 10 ++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index f9fbd727c..40976d966 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -190,6 +190,14 @@ export default class IgcTooltipComponent extends EventEmitterMixin< return this._hideDelay; } + /** + * Specifies a plain text as tooltip content. + * + * @attr + */ + @property({ type: String }) + public message = ''; + constructor() { super(); @@ -348,7 +356,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< shift >
- + ${this.message ? html`${this.message}` : html``} ${this.disableArrow ? nothing : html`
`}
diff --git a/stories/tooltip.stories.ts b/stories/tooltip.stories.ts index 3a31a4702..79a737981 100644 --- a/stories/tooltip.stories.ts +++ b/stories/tooltip.stories.ts @@ -107,6 +107,12 @@ const metadata: Meta = { 'Specifies the number of milliseconds that should pass before hiding the tooltip.', control: 'number', }, + message: { + type: 'string', + description: 'Specifies a plain text as tooltip content.', + control: 'text', + table: { defaultValue: { summary: '' } }, + }, }, args: { open: false, @@ -114,6 +120,7 @@ const metadata: Meta = { inline: false, offset: 4, placement: 'top', + message: '', }, }; @@ -158,6 +165,8 @@ interface IgcTooltipArgs { showDelay: number; /** Specifies the number of milliseconds that should pass before hiding the tooltip. */ hideDelay: number; + /** Specifies a plain text as tooltip content. */ + message: string; } type Story = StoryObj; @@ -303,6 +312,7 @@ export const Toggle: Story = { placement="bottom" show-delay="500" hide-delay="500" + message="Simple tooltip content" > This tooltip toggles on button click! From 8ae5c7e615e3f9b182c2845bfdbf3c1b7975fbe2 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Tue, 25 Mar 2025 09:33:40 +0200 Subject: [PATCH 05/48] feat(tooltip): Enhance accessibility with ARIA attributes for tooltip component --- src/components/tooltip/tooltip.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index 40976d966..ce854116c 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -355,7 +355,13 @@ export default class IgcTooltipComponent extends EventEmitterMixin< flip shift > -
+
${this.message ? html`${this.message}` : html``} ${this.disableArrow ? nothing : html`
`}
From 5273de216b4bce6557a21f78ff76413ef04dba17 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Tue, 25 Mar 2025 09:58:23 +0200 Subject: [PATCH 06/48] feat(tooltip): Refactor show and hide methods to use a delay helper function --- src/components/tooltip/tooltip.ts | 49 ++++++++++++++++--------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index ce854116c..04a740f15 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -257,38 +257,39 @@ export default class IgcTooltipComponent extends EventEmitterMixin< this._animationPlayer.stopAll(); } + private _setDelay(ms: number): Promise { + clearTimeout(this._timeoutId); + return new Promise((resolve) => { + this._timeoutId = setTimeout(resolve, ms); + }); + } + /** Shows the tooltip if not already showing. */ public show = async (): Promise => { - clearTimeout(this._timeoutId); - if (this.open) { - return false; - } + if (this.open) return false; - return new Promise((resolve) => { - this._timeoutId = setTimeout(async () => { - this.open = true; - this.toBeShown = true; - resolve(await this._toggleAnimation('open')); - this.toBeShown = false; - }, this.showDelay); - }); + await this._setDelay(this.showDelay); + + this.open = true; + this.toBeShown = true; + const result = await this._toggleAnimation('open'); + this.toBeShown = false; + + return result; }; /** Hides the tooltip if not already hidden. */ public hide = async (): Promise => { - clearTimeout(this._timeoutId); - if (!this.open) { - return false; - } + if (!this.open) return false; - return new Promise((resolve) => { - this._timeoutId = setTimeout(async () => { - this.open = false; - this.toBeHidden = true; - resolve(await this._toggleAnimation('close')); - this.toBeHidden = false; - }, this.hideDelay); - }); + await this._setDelay(this.hideDelay); + + this.open = false; + this.toBeHidden = true; + const result = await this._toggleAnimation('close'); + this.toBeHidden = false; + + return result; }; /** Toggles the tooltip between shown/hidden state after the appropriate delay. */ From c8584b9f6eba6676e074f6aa1c88dacc3f490111 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Tue, 25 Mar 2025 10:35:52 +0200 Subject: [PATCH 07/48] feat(animation): Add scaleInCenter animation --- src/animations/presets/scale/index.ts | 18 ++++++++++++++++++ src/components/tooltip/tooltip.ts | 5 +++-- 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 src/animations/presets/scale/index.ts diff --git a/src/animations/presets/scale/index.ts b/src/animations/presets/scale/index.ts new file mode 100644 index 000000000..3e5d6a890 --- /dev/null +++ b/src/animations/presets/scale/index.ts @@ -0,0 +1,18 @@ +import { EaseOut } from '../../easings.js'; +import { animation } from '../../types.js'; + +const baseOptions: KeyframeAnimationOptions = { + duration: 350, + easing: EaseOut.Quad, +}; + +const scaleInCenter = (options = baseOptions) => + animation( + [ + { transform: 'scale(0)', opacity: 0 }, + { transform: 'scale(1)', opacity: 1 }, + ], + options + ); + +export { scaleInCenter }; diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index 04a740f15..f27b29b74 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -2,7 +2,8 @@ import { LitElement, html, nothing } from 'lit'; import { property, query } from 'lit/decorators.js'; import { type Ref, createRef, ref } from 'lit/directives/ref.js'; import { addAnimationController } from '../../animations/player.js'; -import { fadeIn, fadeOut } from '../../animations/presets/fade/index.js'; +import { fadeOut } from '../../animations/presets/fade/index.js'; +import { scaleInCenter } from '../../animations/presets/scale/index.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import type { Constructor } from '../common/mixins/constructor.js'; @@ -246,7 +247,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< } private async _toggleAnimation(dir: 'open' | 'close') { - const animation = dir === 'open' ? fadeIn : fadeOut; + const animation = dir === 'open' ? scaleInCenter : fadeOut; return this._animationPlayer.playExclusive(animation()); } From fb87359c218f894db56acbcff0b156f374141087 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Tue, 25 Mar 2025 10:54:01 +0200 Subject: [PATCH 08/48] fix(tooltip): restore pointerEnter event for tooltip --- src/components/tooltip/tooltip-event-controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/tooltip/tooltip-event-controller.ts b/src/components/tooltip/tooltip-event-controller.ts index 563c95558..8b92bd56e 100644 --- a/src/components/tooltip/tooltip-event-controller.ts +++ b/src/components/tooltip/tooltip-event-controller.ts @@ -58,13 +58,13 @@ class TooltipController implements ReactiveController { /** @internal */ public hostConnected(): void { - // this._host.addEventListener('pointerenter', this._host[showOnTrigger]); + this._host.addEventListener('pointerenter', this._host[showOnTrigger]); this._host.addEventListener('pointerleave', this._host[hideOnTrigger]); } /** @internal */ public hostDisconnected(): void { - // this._host.removeEventListener('pointerenter', this._host[showOnTrigger]); + this._host.removeEventListener('pointerenter', this._host[showOnTrigger]); this._host.removeEventListener('pointerleave', this._host[hideOnTrigger]); } } From 14f798a6af9e0ad6b5c7badfe89d9b4ef98f5eb6 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Tue, 25 Mar 2025 13:52:41 +0200 Subject: [PATCH 09/48] fix(tooltip): correct tooltip visibility state management for fadeOut animation --- src/components/tooltip/tooltip.ts | 2 +- stories/tooltip.stories.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index f27b29b74..428ce1bec 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -285,9 +285,9 @@ export default class IgcTooltipComponent extends EventEmitterMixin< await this._setDelay(this.hideDelay); - this.open = false; this.toBeHidden = true; const result = await this._toggleAnimation('close'); + this.open = false; this.toBeHidden = false; return result; diff --git a/stories/tooltip.stories.ts b/stories/tooltip.stories.ts index 79a737981..e8f542604 100644 --- a/stories/tooltip.stories.ts +++ b/stories/tooltip.stories.ts @@ -294,11 +294,14 @@ export const Toggle: Story = { // Use a template ref id to target the tooltip instance const tooltipId = 'toggle-tooltip'; const buttonId = 'toggle-button'; + const buttonIdToggler = 'toggler-button'; // Hook into the rendered DOM to attach click listener setTimeout(() => { const tooltip = document.getElementById(tooltipId) as IgcTooltipComponent; - const button = document.getElementById(buttonId) as HTMLButtonElement; + const button = document.getElementById( + buttonIdToggler + ) as HTMLButtonElement; if (tooltip && button) { button.addEventListener('click', () => tooltip.toggle()); @@ -306,6 +309,7 @@ export const Toggle: Story = { }); return html` + Toggle Toggle Tooltip Date: Tue, 25 Mar 2025 14:09:39 +0200 Subject: [PATCH 10/48] feat(tooltip): enhance toggle animation with customizable duration and easing functions --- src/components/tooltip/tooltip.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index 428ce1bec..7aa33873d 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -1,6 +1,7 @@ import { LitElement, html, nothing } from 'lit'; import { property, query } from 'lit/decorators.js'; import { type Ref, createRef, ref } from 'lit/directives/ref.js'; +import { EaseOut } from '../../animations/easings.js'; import { addAnimationController } from '../../animations/player.js'; import { fadeOut } from '../../animations/presets/fade/index.js'; import { scaleInCenter } from '../../animations/presets/scale/index.js'; @@ -247,8 +248,11 @@ export default class IgcTooltipComponent extends EventEmitterMixin< } private async _toggleAnimation(dir: 'open' | 'close') { - const animation = dir === 'open' ? scaleInCenter : fadeOut; - return this._animationPlayer.playExclusive(animation()); + const animation = + dir === 'open' + ? scaleInCenter({ duration: 150, easing: EaseOut.Quad }) + : fadeOut({ duration: 75, easing: EaseOut.Sine }); + return this._animationPlayer.playExclusive(animation); } private async _forceAnimationStop() { From b45ba2171036de7ad4a619071e05a0bc7eec8a63 Mon Sep 17 00:00:00 2001 From: RivaIvanova Date: Wed, 26 Mar 2025 13:57:53 +0200 Subject: [PATCH 11/48] test(tooltip): add more tests --- src/components/tooltip/tooltip.spec.ts | 448 +++++++++++++++++++++++-- 1 file changed, 419 insertions(+), 29 deletions(-) diff --git a/src/components/tooltip/tooltip.spec.ts b/src/components/tooltip/tooltip.spec.ts index 74e5fac6c..47728997b 100644 --- a/src/components/tooltip/tooltip.spec.ts +++ b/src/components/tooltip/tooltip.spec.ts @@ -5,7 +5,7 @@ import { html, nextFrame, } from '@open-wc/testing'; -import { type SinonFakeTimers, useFakeTimers } from 'sinon'; +import { type SinonFakeTimers, spy, useFakeTimers } from 'sinon'; import { defineComponents } from '../common/definitions/defineComponents.js'; import { finishAnimationsFor, @@ -17,12 +17,17 @@ import { } from '../common/utils.spec.js'; import IgcTooltipComponent from './tooltip.js'; -// TODO: Add more tests - describe('Tooltip', () => { + let anchor: HTMLButtonElement; let tooltip: IgcTooltipComponent; let clock: SinonFakeTimers; + const DIFF_OPTIONS = { + ignoreAttributes: ['anchor'], + }; + + const endTick = (tick: number) => tick + 180; + before(() => { defineComponents(IgcTooltipComponent); }); @@ -30,69 +35,304 @@ describe('Tooltip', () => { async function showComplete(instance: IgcTooltipComponent = tooltip) { finishAnimationsFor(instance.shadowRoot!); await elementUpdated(instance); + await nextFrame(); } async function hideComplete(instance: IgcTooltipComponent = tooltip) { await elementUpdated(instance); - await clock.runAllAsync(); finishAnimationsFor(instance.shadowRoot!); await nextFrame(); + await nextFrame(); } - describe('DOM (with explicit target)', () => { + describe('Initialization Tests', () => { beforeEach(async () => { - const container = await fixture(createTooltipWithTarget()); + const container = await fixture(createDefaultTooltip()); tooltip = container.querySelector(IgcTooltipComponent.tagName)!; }); - it('is defined', async () => { + it('is defined', () => { expect(tooltip).to.exist; }); - }); - describe('DOM', () => { - beforeEach(async () => { - const container = await fixture(createDefaultTooltip()); - tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + it('is accessible', async () => { + await expect(tooltip).to.be.accessible(); + await expect(tooltip).shadowDom.to.be.accessible(); + }); + + it('is correctly initialized with its default component state', () => { + expect(tooltip.dir).to.be.empty; + expect(tooltip.open).to.be.false; + expect(tooltip.disableArrow).to.be.false; + expect(tooltip.inline).to.be.false; + expect(tooltip.offset).to.equal(4); + expect(tooltip.placement).to.equal('top'); + expect(tooltip.anchor).to.be.undefined; + expect(tooltip.showTriggers).to.equal('pointerenter'); + expect(tooltip.hideTriggers).to.equal('pointerleave'); + expect(tooltip.showDelay).to.equal(500); + expect(tooltip.hideDelay).to.equal(500); + expect(tooltip.message).to.be.undefined; + }); + + it('should render a default arrow', () => { + const arrow = tooltip.shadowRoot!.querySelector('#arrow'); + expect(arrow).not.to.be.null; + }); + + it('is correctly rendered both in shown/hidden states', async () => { + expect(tooltip.open).to.be.false; + + expect(tooltip).dom.to.equal('It works!'); + expect(tooltip).shadowDom.to.equal( + ` +
+ +
+
+
` + ); + + tooltip.open = true; + await elementUpdated(tooltip); + + expect(tooltip).dom.to.equal('It works!'); + expect(tooltip).shadowDom.to.equal( + ` +
+ +
+
+
` + ); }); - it('is defined', async () => { + it('is initially open on first render', async () => { + const container = await fixture(createDefaultTooltip(true)); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + expect(tooltip).to.exist; + expect(tooltip.open).to.be.true; }); - it('is accessible', async () => { - await expect(tooltip).to.be.accessible(); - await expect(tooltip).shadowDom.to.be.accessible(); + it('is initially open on first render with target', async () => { + const container = await fixture(createTooltipWithTarget(true)); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + + expect(tooltip).to.exist; + expect(tooltip.open).to.be.true; }); }); - describe('Initially open on first render', () => { + describe('Properties Tests', () => { beforeEach(async () => { - const container = await fixture(createDefaultTooltip(true)); + clock = useFakeTimers({ toFake: ['setTimeout'] }); + const container = await fixture(createTooltipWithTarget()); tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + anchor = container.querySelector('button')!; }); - it('', async () => { - expect(tooltip).to.exist; + afterEach(() => { + clock.restore(); + }); + + it('should set target via `anchor` property', async () => { + const template = html` +
+ + + + I am a tooltip +
+ `; + const container = await fixture(template); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + + const first = document.querySelector('#first') as HTMLButtonElement; + const second = document.querySelector('#second') as HTMLButtonElement; + const third = document.querySelector('#third') as HTMLButtonElement; + + // If no anchor is provided. + // Considers the first preceding sibling that is an element as the target. + simulatePointerEnter(third); + await clock.tickAsync(500); + await showComplete(); + expect(tooltip.open).to.be.true; + + simulatePointerLeave(third); + await clock.tickAsync(endTick(500)); + await hideComplete(); + expect(tooltip.open).to.be.false; + + simulatePointerEnter(first); + await clock.tickAsync(500); + await showComplete(); + expect(tooltip.open).to.be.false; + + // By providing an IDREF + tooltip.anchor = first.id; + await elementUpdated(tooltip); + + simulatePointerEnter(first); + await clock.tickAsync(500); + await showComplete(); + expect(tooltip.open).to.be.true; + + simulatePointerLeave(first); + await clock.tickAsync(endTick(500)); + await hideComplete(); + expect(tooltip.open).to.be.false; + + // By providing an Element + simulatePointerEnter(second); + await clock.tickAsync(500); + await showComplete(); + expect(tooltip.open).to.be.false; + + tooltip.anchor = second; + await elementUpdated(tooltip); + + simulatePointerEnter(second); + await clock.tickAsync(500); + await showComplete(); expect(tooltip.open).to.be.true; }); + + it('should show/hide the arrow via the `disableArrow` property', async () => { + expect(tooltip.disableArrow).to.be.false; + expect(tooltip.shadowRoot!.querySelector('#arrow')).to.exist; + + tooltip.disableArrow = true; + await elementUpdated(tooltip); + + expect(tooltip.disableArrow).to.be.true; + expect(tooltip.shadowRoot!.querySelector('#arrow')).to.be.null; + }); + + it('should show/hide the arrow via the `disable-arrow` attribute', async () => { + const template = html` +
+ + I am a tooltip +
+ `; + const container = await fixture(template); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + + expect(tooltip.disableArrow).to.be.true; + expect(tooltip.shadowRoot!.querySelector('#arrow')).to.be.null; + }); + + it('should set the tooltip content as plain text if the `message` property is set', async () => { + expect(tooltip.message).to.be.undefined; + expect(tooltip).dom.to.equal( + 'It works!', + DIFF_OPTIONS + ); + expect(tooltip).shadowDom.to.equal( + ` +
+ +
+
+
` + ); + + const message = 'Hello!'; + tooltip.message = message; + await elementUpdated(tooltip); + + expect(tooltip.message).to.equal(message); + expect(tooltip).dom.to.equal( + 'It works!', + DIFF_OPTIONS + ); + expect(tooltip).shadowDom.to.equal( + ` +
+ ${message} +
+
+
` + ); + }); }); - describe('Initially open on first render with target', () => { + describe('Methods` Tests', () => { beforeEach(async () => { - const container = await fixture(createTooltipWithTarget(true)); + clock = useFakeTimers({ toFake: ['setTimeout'] }); + const container = await fixture(createTooltipWithTarget()); tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + anchor = container.querySelector('button')!; }); - it('', async () => { - expect(tooltip).to.exist; + afterEach(() => { + clock.restore(); + }); + + it('calls `show` and `hide` methods successfully and returns proper values', async () => { + expect(tooltip.open).to.be.false; + + tooltip.show().then((x) => expect(x).to.be.true); + await clock.tickAsync(500); + await showComplete(); + expect(tooltip.open).to.be.true; + expect(tooltip).dom.to.equal( + 'It works!', + DIFF_OPTIONS + ); + + tooltip.hide().then((x) => expect(x).to.be.true); + await clock.tickAsync(endTick(500)); + await hideComplete(); + + expect(tooltip.open).to.be.false; + expect(tooltip).dom.to.equal( + 'It works!', + DIFF_OPTIONS + ); + }); + + it('calls `toggle` method successfully and returns proper values', async () => { + expect(tooltip.open).to.be.false; + + tooltip.toggle().then((x) => expect(x).to.be.true); + await clock.tickAsync(500); + await showComplete(); + + expect(tooltip.open).to.be.true; + expect(tooltip).dom.to.equal( + 'It works!', + DIFF_OPTIONS + ); + + tooltip.toggle().then((x) => expect(x).to.be.true); + await clock.tickAsync(endTick(500)); + await hideComplete(); + + expect(tooltip.open).to.be.false; + expect(tooltip).dom.to.equal( + 'It works!', + DIFF_OPTIONS + ); }); }); describe('Behaviors', () => { - let anchor: HTMLButtonElement; - beforeEach(async () => { clock = useFakeTimers({ toFake: ['setTimeout'] }); const container = await fixture(createDefaultTooltip()); @@ -109,37 +349,78 @@ describe('Tooltip', () => { expect(tooltip.hideTriggers).to.equal('pointerleave'); simulatePointerEnter(anchor); + await clock.tickAsync(500); await showComplete(); expect(tooltip.open).to.be.true; simulatePointerLeave(anchor); + await clock.tickAsync(endTick(500)); await hideComplete(); expect(tooltip.open).to.be.false; }); - it('custom triggers', async () => { + it('custom triggers via property', async () => { tooltip.showTriggers = 'focus,click'; tooltip.hideTriggers = 'blur'; simulateFocus(anchor); + await clock.tickAsync(500); await showComplete(); expect(tooltip.open).to.be.true; simulateBlur(anchor); + await clock.tickAsync(endTick(500)); await hideComplete(); expect(tooltip.open).to.be.false; simulateClick(anchor); + await clock.tickAsync(500); await showComplete(); expect(tooltip.open).to.be.true; simulateBlur(anchor); + await clock.tickAsync(endTick(500)); + await hideComplete(); + expect(tooltip.open).to.be.false; + }); + + it('custom triggers via attribute', async () => { + const template = html` +
+ + I am a tooltip +
+ `; + const container = await fixture(template); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + anchor = container.querySelector('button')!; + + simulateFocus(anchor); + await clock.tickAsync(500); + await showComplete(); + expect(tooltip.open).to.be.true; + + simulateBlur(anchor); + await clock.tickAsync(endTick(500)); + await hideComplete(); + expect(tooltip.open).to.be.false; + + simulateClick(anchor); + await clock.tickAsync(500); + await showComplete(); + expect(tooltip.open).to.be.true; + + simulateBlur(anchor); + await clock.tickAsync(endTick(500)); await hideComplete(); expect(tooltip.open).to.be.false; }); it('pointerenter over tooltip prevents hiding and pointerleave triggers hiding', async () => { simulatePointerEnter(anchor); + await clock.tickAsync(500); await showComplete(); expect(tooltip.open).to.be.true; @@ -153,12 +434,111 @@ describe('Tooltip', () => { // Move cursor outside the tooltip simulatePointerLeave(tooltip); + await clock.tickAsync(endTick(500)); await hideComplete(); + expect(tooltip.open).to.be.false; + }); + + it('should show/hide the tooltip based on `showDelay` and `hideDelay`', async () => { + tooltip.showDelay = tooltip.hideDelay = 400; + simulatePointerEnter(anchor); + await clock.tickAsync(399); + expect(tooltip.open).to.be.false; + + await clock.tickAsync(1); + await showComplete(tooltip); + expect(tooltip.open).to.be.true; + simulatePointerLeave(anchor); + await clock.tickAsync(endTick(399)); + expect(tooltip.open).to.be.true; + + await clock.tickAsync(1); + await hideComplete(tooltip); expect(tooltip.open).to.be.false; }); }); + describe('Events', () => { + let eventSpy: ReturnType; + + beforeEach(async () => { + clock = useFakeTimers({ toFake: ['setTimeout'] }); + const container = await fixture(createDefaultTooltip()); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + anchor = container.querySelector('button')!; + eventSpy = spy(tooltip, 'emitEvent'); + }); + + afterEach(() => { + clock.restore(); + }); + + const verifyStateAndEventSequence = ( + state: { open: boolean } = { open: false } + ) => { + expect(tooltip.open).to.equal(state.open); + expect(eventSpy.callCount).to.equal(2); + expect(eventSpy.firstCall).calledWith( + state.open ? 'igcOpening' : 'igcClosing', + { cancelable: true, detail: anchor } + ); + expect(eventSpy.secondCall).calledWith( + state.open ? 'igcOpened' : 'igcClosed', + { detail: anchor } + ); + }; + + it('events are correctly emitted on user interaction', async () => { + simulatePointerEnter(anchor); + await clock.tickAsync(500); + await showComplete(tooltip); + verifyStateAndEventSequence({ open: true }); + + eventSpy.resetHistory(); + + simulatePointerLeave(anchor); + await clock.tickAsync(endTick(500)); + await hideComplete(tooltip); + verifyStateAndEventSequence({ open: false }); + }); + + it('can cancel -ing events', async () => { + tooltip.addEventListener('igcOpening', (e) => e.preventDefault(), { + once: true, + }); + + simulatePointerEnter(anchor); + await clock.tickAsync(500); + await showComplete(tooltip); + + expect(tooltip.open).to.be.false; + expect(eventSpy).calledOnceWith('igcOpening', { + cancelable: true, + detail: anchor, + }); + + eventSpy.resetHistory(); + + tooltip.open = true; + await elementUpdated(tooltip); + + tooltip.addEventListener('igcClosing', (e) => e.preventDefault(), { + once: true, + }); + + simulatePointerLeave(anchor); + await clock.tickAsync(endTick(500)); + await hideComplete(tooltip); + + expect(tooltip.open).to.be.true; + expect(eventSpy).calledOnceWith('igcClosing', { + cancelable: true, + detail: anchor, + }); + }); + }); + describe('Keyboard interactions', () => { beforeEach(async () => { clock = useFakeTimers({ toFake: ['setTimeout'] }); @@ -176,8 +556,11 @@ describe('Tooltip', () => { ); simulatePointerEnter(anchor); + await clock.tickAsync(500); await showComplete(tooltip); + expect(tooltip.open).to.be.true; + document.documentElement.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', @@ -185,6 +568,7 @@ describe('Tooltip', () => { composed: true, }) ); + await clock.tickAsync(endTick(500)); await hideComplete(tooltip); expect(tooltip.open).to.be.false; @@ -196,11 +580,15 @@ describe('Tooltip', () => { IgcTooltipComponent.tagName ); - first.show(); - last.show(); + first.show().then((x) => expect(x).to.be.true); + last.show().then((x) => expect(x).to.be.true); + await clock.tickAsync(500); await showComplete(first); await showComplete(last); + expect(first.open).to.be.true; + expect(last.open).to.be.true; + document.documentElement.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', @@ -209,6 +597,7 @@ describe('Tooltip', () => { }) ); + await clock.tickAsync(endTick(500)); await hideComplete(first); await hideComplete(last); @@ -223,6 +612,7 @@ describe('Tooltip', () => { }) ); + await clock.tickAsync(endTick(500)); await hideComplete(first); await hideComplete(last); From a95ca8d38fe38157a8625239f3260897eafef812 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Wed, 26 Mar 2025 17:14:15 +0200 Subject: [PATCH 12/48] fix(tooltip): rename internal flags for clarity and enhance accessibility attributes, also add a timeout before calling hideWithEvent --- src/components/tooltip/tooltip.ts | 55 ++++++++++++++++++------------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index 7aa33873d..5bdab1a4b 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -19,7 +19,6 @@ import { } from './tooltip-event-controller.js'; import service from './tooltip-service.js'; -// TODO: Expose events export interface IgcTooltipComponentEventMap { igcOpening: CustomEvent; igcOpened: CustomEvent; @@ -60,8 +59,8 @@ export default class IgcTooltipComponent extends EventEmitterMixin< private _animationPlayer = addAnimationController(this, this._containerRef); private _timeoutId?: number; - private toBeShown = false; - private toBeHidden = false; + private _toBeShown = false; + private _toBeHidden = false; private _open = false; private _showTriggers = ['pointerenter']; private _hideTriggers = ['pointerleave']; @@ -205,6 +204,8 @@ export default class IgcTooltipComponent extends EventEmitterMixin< this._internals = this.attachInternals(); this._internals.role = 'tooltip'; + this._internals.ariaAtomic = 'true'; + this._internals.ariaLive = 'polite'; } protected override async firstUpdated() { @@ -255,10 +256,23 @@ export default class IgcTooltipComponent extends EventEmitterMixin< return this._animationPlayer.playExclusive(animation); } + /** + * Immediately stops any ongoing animation and resets the tooltip state. + * + * This method is used in edge cases when a transition needs to be forcefully interrupted, + * such as when a tooltip is in the middle of showing or hiding and the user suddenly + * triggers the opposite action (e.g., hovers in and out rapidly). + * + * It: + * - Reverts `open` based on whether it was mid-hide or mid-show. + * - Clears internal transition flags (`_toBeShown`, `_toBeHidden`). + * - Stops any active animations, causing `_toggleAnimation()` to return false. + * + */ private async _forceAnimationStop() { - this.toBeShown = false; - this.toBeHidden = false; - this.open = !this.open; + this.open = this._toBeHidden; + this._toBeShown = false; + this._toBeHidden = false; this._animationPlayer.stopAll(); } @@ -276,9 +290,9 @@ export default class IgcTooltipComponent extends EventEmitterMixin< await this._setDelay(this.showDelay); this.open = true; - this.toBeShown = true; + this._toBeShown = true; const result = await this._toggleAnimation('open'); - this.toBeShown = false; + this._toBeShown = false; return result; }; @@ -289,10 +303,10 @@ export default class IgcTooltipComponent extends EventEmitterMixin< await this._setDelay(this.hideDelay); - this.toBeHidden = true; + this._toBeHidden = true; const result = await this._toggleAnimation('close'); - this.open = false; - this.toBeHidden = false; + this.open = !result; + this._toBeHidden = false; return result; }; @@ -303,8 +317,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< }; public showWithEvent = async () => { - clearTimeout(this._timeoutId); - if (this.toBeHidden) { + if (this._toBeHidden) { await this._forceAnimationStop(); return; } @@ -320,8 +333,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< }; public hideWithEvent = async () => { - clearTimeout(this._timeoutId); - if (this.toBeShown) { + if (this._toBeShown) { await this._forceAnimationStop(); return; } @@ -337,6 +349,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< }; protected [showOnTrigger] = () => { + clearTimeout(this._timeoutId); this.showWithEvent(); }; @@ -346,12 +359,14 @@ export default class IgcTooltipComponent extends EventEmitterMixin< if (related && (this.contains(related) || this._target?.contains(related))) return; - this.hideWithEvent(); + clearTimeout(this._timeoutId); + this._timeoutId = setTimeout(() => this.hideWithEvent(), 180); }; protected override render() { return html` -
+
${this.message ? html`${this.message}` : html``} ${this.disableArrow ? nothing : html`
`}
From 6eab57452657b0c7047fc165580f2b3b81400e71 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Wed, 26 Mar 2025 17:35:23 +0200 Subject: [PATCH 13/48] feat(tooltip): enhance tooltip parameters and add new properties to stories --- stories/tooltip.stories.ts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/stories/tooltip.stories.ts b/stories/tooltip.stories.ts index 067184b0e..b83e49bf3 100644 --- a/stories/tooltip.stories.ts +++ b/stories/tooltip.stories.ts @@ -22,7 +22,12 @@ defineComponents( const metadata: Meta = { title: 'Tooltip', component: 'igc-tooltip', - parameters: { docs: { description: { component: '' } } }, + parameters: { + docs: { description: { component: '' } }, + actions: { + handles: ['igcOpening', 'igcOpened', 'igcClosing', 'igcClosed'], + }, + }, argTypes: { open: { type: 'boolean', @@ -90,6 +95,24 @@ const metadata: Meta = { 'Which event triggers will hide the tooltip.\nExpects a comma separate string of different event triggers.', control: 'text', }, + showDelay: { + type: 'number', + description: + 'Specifies the number of milliseconds that should pass before showing the tooltip.', + control: 'number', + }, + hideDelay: { + type: 'number', + description: + 'Specifies the number of milliseconds that should pass before hiding the tooltip.', + control: 'number', + }, + message: { + type: 'string', + description: 'Specifies a plain text as tooltip content.', + control: 'text', + table: { defaultValue: { summary: '' } }, + }, }, args: { open: false, @@ -97,6 +120,7 @@ const metadata: Meta = { inline: false, offset: 4, placement: 'top', + message: '', }, }; @@ -137,6 +161,12 @@ interface IgcTooltipArgs { * Expects a comma separate string of different event triggers. */ hideTriggers: string; + /** Specifies the number of milliseconds that should pass before showing the tooltip. */ + showDelay: number; + /** Specifies the number of milliseconds that should pass before hiding the tooltip. */ + hideDelay: number; + /** Specifies a plain text as tooltip content. */ + message: string; } type Story = StoryObj; From 31245b7f42f20d3724a6ea850eef6f94b90108c9 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Thu, 27 Mar 2025 11:05:36 +0200 Subject: [PATCH 14/48] fix(tooltip): simplify event handling and fix tests --- src/components/tooltip/tooltip-event-controller.ts | 2 +- src/components/tooltip/tooltip.spec.ts | 8 ++++++-- src/components/tooltip/tooltip.ts | 7 +------ 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/components/tooltip/tooltip-event-controller.ts b/src/components/tooltip/tooltip-event-controller.ts index 8b92bd56e..8d92a812e 100644 --- a/src/components/tooltip/tooltip-event-controller.ts +++ b/src/components/tooltip/tooltip-event-controller.ts @@ -37,7 +37,7 @@ class TooltipController implements ReactiveController { } for (const trigger of hide) { - anchor.addEventListener(trigger, (ev) => this._host[hideOnTrigger](ev)); + anchor.addEventListener(trigger, this._host[hideOnTrigger]); } } diff --git a/src/components/tooltip/tooltip.spec.ts b/src/components/tooltip/tooltip.spec.ts index 47728997b..73f293409 100644 --- a/src/components/tooltip/tooltip.spec.ts +++ b/src/components/tooltip/tooltip.spec.ts @@ -72,7 +72,7 @@ describe('Tooltip', () => { expect(tooltip.hideTriggers).to.equal('pointerleave'); expect(tooltip.showDelay).to.equal(500); expect(tooltip.hideDelay).to.equal(500); - expect(tooltip.message).to.be.undefined; + expect(tooltip.message).to.equal(''); }); it('should render a default arrow', () => { @@ -86,6 +86,7 @@ describe('Tooltip', () => { expect(tooltip).dom.to.equal('It works!'); expect(tooltip).shadowDom.to.equal( `
`, From 6849d7aef5a7ce5559ce8842b36bfc11e611e648 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Fri, 4 Apr 2025 16:05:18 +0300 Subject: [PATCH 22/48] refactor(tooltip): _applyTooltipStat --- src/components/tooltip/tooltip.ts | 105 +++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 30 deletions(-) diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index a57454b68..8cc6b39cf 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -274,6 +274,61 @@ export default class IgcTooltipComponent extends EventEmitterMixin< return this._animationPlayer.playExclusive(animation); } + private async _applyTooltipState({ + show, + withDelay = false, + withEvents = false, + }: { + show: boolean; + withDelay?: boolean; + withEvents?: boolean; + }): Promise { + if (show === this.open) return false; + + const delay = show ? this.showDelay : this.hideDelay; + + if (withEvents) { + const eventName = show ? 'igcOpening' : 'igcClosing'; + const allowed = this.emitEvent(eventName, { + cancelable: true, + detail: this._target, + }); + + if (!allowed) return false; + } + + const _commitStateChange = async () => { + if (show) { + this.open = true; + } + + const result = await this._toggleAnimation(show ? 'open' : 'close'); + this.open = result ? show : !show; + + if (!result) { + return false; + } + + if (withEvents) { + const eventName = show ? 'igcOpened' : 'igcClosed'; + this.emitEvent(eventName, { detail: this._target }); + } + + return result; + }; + + if (withDelay) { + clearTimeout(this._timeoutId); + return new Promise((resolve) => { + this._timeoutId = setTimeout(() => { + _commitStateChange().then(resolve); + }, delay); + }); + } + + return _commitStateChange(); + } + private _setDelay(ms: number, action: 'open' | 'close'): Promise { clearTimeout(this._timeoutId); return new Promise((resolve) => { @@ -293,45 +348,35 @@ export default class IgcTooltipComponent extends EventEmitterMixin< } /** Shows the tooltip if not already showing. */ - public show = async (): Promise => { - if (this.open) return false; - return this._setDelay(this.showDelay, 'open'); - }; + public show(): Promise { + return this._applyTooltipState({ show: true, withDelay: false }); + } /** Hides the tooltip if not already hidden. */ - public hide = async (): Promise => { - if (!this.open) return false; - return this._setDelay(this.hideDelay, 'close'); - }; + public hide(): Promise { + return this._applyTooltipState({ show: false, withDelay: false }); + } /** Toggles the tooltip between shown/hidden state after the appropriate delay. */ public toggle = async (): Promise => { return this.open ? this.hide() : this.show(); }; - public showWithEvent = async () => { - if ( - this.open || - !this.emitEvent('igcOpening', { cancelable: true, detail: this._target }) - ) { - return; - } - if (await this.show()) { - this.emitEvent('igcOpened', { detail: this._target }); - } - }; + public showWithEvent(): Promise { + return this._applyTooltipState({ + show: true, + withDelay: true, + withEvents: true, + }); + } - public hideWithEvent = async () => { - if ( - !this.open || - !this.emitEvent('igcClosing', { cancelable: true, detail: this._target }) - ) { - return; - } - if (await this.hide()) { - this.emitEvent('igcClosed', { detail: this._target }); - } - }; + public hideWithEvent(): Promise { + return this._applyTooltipState({ + show: false, + withDelay: true, + withEvents: true, + }); + } protected [showOnTrigger] = () => { this._animationPlayer.stopAll(); From a32e69179176e46620c50aa6873b86544de0e6d2 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Fri, 4 Apr 2025 16:55:39 +0300 Subject: [PATCH 23/48] refactor(tooltip): add comment for escape key handling in hideOnTrigger method --- src/components/tooltip/tooltip.ts | 42 ++++++------------- stories/tooltip.stories.ts | 69 ------------------------------- 2 files changed, 12 insertions(+), 99 deletions(-) diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index 8cc6b39cf..42f13ee13 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -30,6 +30,12 @@ export interface IgcTooltipComponentEventMap { igcClosed: CustomEvent; } +type TooltipStateOptions = { + show: boolean; + withDelay?: boolean; + withEvents?: boolean; +}; + function parseTriggers(string: string): string[] { return (string ?? '').split(',').map((part) => part.trim()); } @@ -278,11 +284,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< show, withDelay = false, withEvents = false, - }: { - show: boolean; - withDelay?: boolean; - withEvents?: boolean; - }): Promise { + }: TooltipStateOptions): Promise { if (show === this.open) return false; const delay = show ? this.showDelay : this.hideDelay; @@ -329,32 +331,14 @@ export default class IgcTooltipComponent extends EventEmitterMixin< return _commitStateChange(); } - private _setDelay(ms: number, action: 'open' | 'close'): Promise { - clearTimeout(this._timeoutId); - return new Promise((resolve) => { - this._timeoutId = setTimeout(async () => { - if (action === 'open') { - this.open = true; - } - - const result = await this._toggleAnimation(action); - // Update `open` after the animation to reflect the correct state based on event.type: - // - Close → false if succeeded - // - Open → true if succeeded - this.open = action === 'close' ? !result : result; - resolve(result); - }, ms); - }); - } - /** Shows the tooltip if not already showing. */ public show(): Promise { - return this._applyTooltipState({ show: true, withDelay: false }); + return this._applyTooltipState({ show: true }); } /** Hides the tooltip if not already hidden. */ public hide(): Promise { - return this._applyTooltipState({ show: false, withDelay: false }); + return this._applyTooltipState({ show: false }); } /** Toggles the tooltip between shown/hidden state after the appropriate delay. */ @@ -385,11 +369,9 @@ export default class IgcTooltipComponent extends EventEmitterMixin< }; protected [hideOnTrigger] = (event?: Event) => { - // Return if is sticky and the event does not explicitly indicate a forced hide - if ( - this.sticky && - !(event instanceof CustomEvent && event.detail?.forceHide) - ) { + //TODO: IF NEEDED CHECK FOR ESCAPE KEY => + // Return if is sticky + if (this.sticky && event) { return; } diff --git a/stories/tooltip.stories.ts b/stories/tooltip.stories.ts index 737f4525b..259e7af91 100644 --- a/stories/tooltip.stories.ts +++ b/stories/tooltip.stories.ts @@ -315,72 +315,3 @@ export const Triggers: Story = {
`, }; - -export const Toggle: Story = { - render: (args) => { - const tooltipId = 'toggle-tooltip'; - const buttonIdToggler = 'toggler-button'; - - // Hook into the rendered DOM to attach click listener - setTimeout(() => { - const tooltip = document.getElementById(tooltipId) as IgcTooltipComponent; - const button = document.getElementById( - buttonIdToggler - ) as HTMLButtonElement; - - if (tooltip && button) { - button.addEventListener('click', () => tooltip.toggle()); - } - }); - - return html` - Toggle - Toggle Tooltip - - This tooltip toggles on button click! - - `; - }, -}; - -export const ReallyBasic: Story = { - render: (args) => html` - - Show - Hide - Toggle -
- Hover me - -
- `, -}; From e21a021a38f15396e8740e9ab0414f87cd877603 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Fri, 4 Apr 2025 17:31:55 +0300 Subject: [PATCH 24/48] fix(tooltip): fix toggle method documentation --- src/components/tooltip/tooltip.ts | 2 +- stories/tooltip.stories.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index 42f13ee13..6361ea157 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -341,7 +341,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< return this._applyTooltipState({ show: false }); } - /** Toggles the tooltip between shown/hidden state after the appropriate delay. */ + /** Toggles the tooltip between shown/hidden state */ public toggle = async (): Promise => { return this.open ? this.hide() : this.show(); }; diff --git a/stories/tooltip.stories.ts b/stories/tooltip.stories.ts index 259e7af91..6a471203b 100644 --- a/stories/tooltip.stories.ts +++ b/stories/tooltip.stories.ts @@ -221,7 +221,7 @@ export const Basic: Story = {
TOP KEK - Initial open state Right Tooltip From 4f38286541bc62ac9b57fefb92b3c25607943006 Mon Sep 17 00:00:00 2001 From: RivaIvanova Date: Mon, 7 Apr 2025 18:13:57 +0300 Subject: [PATCH 25/48] test(tooltip): refactor methods tests --- src/components/tooltip/tooltip.spec.ts | 52 ++++++++++++++------------ 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/src/components/tooltip/tooltip.spec.ts b/src/components/tooltip/tooltip.spec.ts index 273296f65..7bdc6d78d 100644 --- a/src/components/tooltip/tooltip.spec.ts +++ b/src/components/tooltip/tooltip.spec.ts @@ -396,33 +396,44 @@ describe('Tooltip', () => { describe('Methods` Tests', () => { beforeEach(async () => { - clock = useFakeTimers({ toFake: ['setTimeout'] }); const container = await fixture(createTooltipWithTarget()); tooltip = container.querySelector(IgcTooltipComponent.tagName)!; anchor = container.querySelector('button')!; }); - afterEach(() => { - clock.restore(); - }); - it('calls `show` and `hide` methods successfully and returns proper values', async () => { expect(tooltip.open).to.be.false; - tooltip.show().then((x) => expect(x).to.be.true); - await clock.tickAsync(DEFAULT_SHOW_DELAY); - await showComplete(); + // hide tooltip when already hidden + let animation = await tooltip.hide(); + expect(animation).to.be.false; + expect(tooltip.open).to.be.false; + expect(tooltip).dom.to.equal( + 'It works!', + DIFF_OPTIONS + ); + // show tooltip when hidden + animation = await tooltip.show(); + expect(animation).to.be.true; expect(tooltip.open).to.be.true; expect(tooltip).dom.to.equal( 'It works!', DIFF_OPTIONS ); - tooltip.hide().then((x) => expect(x).to.be.true); - await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); - await hideComplete(); + // show tooltip when already shown + animation = await tooltip.show(); + expect(animation).to.be.false; + expect(tooltip.open).to.be.true; + expect(tooltip).dom.to.equal( + 'It works!', + DIFF_OPTIONS + ); + // hide tooltip when shown + animation = await tooltip.hide(); + expect(animation).to.be.true; expect(tooltip.open).to.be.false; expect(tooltip).dom.to.equal( 'It works!', @@ -433,20 +444,16 @@ describe('Tooltip', () => { it('calls `toggle` method successfully and returns proper values', async () => { expect(tooltip.open).to.be.false; - tooltip.toggle().then((x) => expect(x).to.be.true); - await clock.tickAsync(DEFAULT_SHOW_DELAY); - await showComplete(); - + let animation = await tooltip.toggle(); + expect(animation).to.be.true; expect(tooltip.open).to.be.true; expect(tooltip).dom.to.equal( 'It works!', DIFF_OPTIONS ); - tooltip.toggle().then((x) => expect(x).to.be.true); - await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); - await hideComplete(); - + animation = await tooltip.toggle(); + expect(animation).to.be.true; expect(tooltip.open).to.be.false; expect(tooltip).dom.to.equal( 'It works!', @@ -703,11 +710,8 @@ describe('Tooltip', () => { IgcTooltipComponent.tagName ); - first.show().then((x) => expect(x).to.be.true); - last.show().then((x) => expect(x).to.be.true); - await clock.tickAsync(DEFAULT_SHOW_DELAY); - await showComplete(first); - await showComplete(last); + first.show(); + last.show(); expect(first.open).to.be.true; expect(last.open).to.be.true; From e89175c8ccf66e514e0bfcf0126f7bff64db734a Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 7 Apr 2025 19:02:02 +0300 Subject: [PATCH 26/48] refactor: Tooltip event handlers * Don't create separate event handlers per tooltip instance; move the invoke logic inside the controller. * Move the triggers parsing inside the controller as well. * "Cache" the animation objects since they do not change between calls. * Run the opening animation on initial rendering when `open` is set. * JSDoc improvements. --- src/components/popover/popover.ts | 2 +- .../tooltip/tooltip-event-controller.ts | 180 +++++++++++++----- src/components/tooltip/tooltip-service.ts | 2 +- src/components/tooltip/tooltip.spec.ts | 2 +- src/components/tooltip/tooltip.ts | 155 +++++++-------- stories/tooltip.stories.ts | 50 ++++- 6 files changed, 243 insertions(+), 148 deletions(-) diff --git a/src/components/popover/popover.ts b/src/components/popover/popover.ts index 47f754c3a..c43c3feb5 100644 --- a/src/components/popover/popover.ts +++ b/src/components/popover/popover.ts @@ -64,7 +64,7 @@ export default class IgcPopoverComponent extends LitElement { private dispose?: ReturnType; private target?: Element; - @query('#container', true) + @query('#container') private _container!: HTMLElement; @queryAssignedElements({ slot: 'anchor', flatten: true }) diff --git a/src/components/tooltip/tooltip-event-controller.ts b/src/components/tooltip/tooltip-event-controller.ts index 15e95f725..ad4b2cb91 100644 --- a/src/components/tooltip/tooltip-event-controller.ts +++ b/src/components/tooltip/tooltip-event-controller.ts @@ -1,91 +1,173 @@ import type { ReactiveController } from 'lit'; +import service from './tooltip-service.js'; import type IgcTooltipComponent from './tooltip.js'; type TooltipAnchor = Element | null | undefined; -type TooltipTriggers = { - show: string[]; - hide: string[]; + +type TooltipCallbacks = { + onShow: (event?: Event) => unknown; + onHide: (event?: Event) => unknown; }; class TooltipController implements ReactiveController { private readonly _tooltip: IgcTooltipComponent; - private _showTriggers: string[] = []; - private _hideTriggers: string[] = []; + private _anchor: TooltipAnchor; - constructor(tooltip: IgcTooltipComponent) { - this._tooltip = tooltip; - this._tooltip.addController(this); + private _options!: TooltipCallbacks; + private _showTriggers = new Set(['pointerenter']); + private _hideTriggers = new Set(['pointerleave']); + + /** + * Returns the current tooltip anchor target if any. + */ + public get anchor(): TooltipAnchor { + return this._anchor; } /** - * Sets the current collections of show/hide triggers on the given anchor for the tooltip. - * Removes any previously set triggers. + * Removes all triggers from the previous `anchor` target and rebinds the current + * sets back to the new value if it exists. */ - public set(anchor: TooltipAnchor, triggers: TooltipTriggers): void { - if (!anchor) { - return; + public set anchor(value: TooltipAnchor) { + this._dispose(); + this._anchor = value; + + for (const each of this._showTriggers) { + this._anchor?.addEventListener(each, this); } - const { show, hide } = triggers; - this._showTriggers = show; - this._hideTriggers = hide; + for (const each of this._hideTriggers) { + this._anchor?.addEventListener(each, this); + } + } - this.remove(anchor); + /** + * Returns the current set of hide triggers as a comma-separated string. + */ + public get hideTriggers(): string { + return Array.from(this._hideTriggers).join(); + } - for (const trigger of show) { - anchor.addEventListener(trigger, this._tooltip[showOnTrigger]); + /** + * Sets a new set of hide triggers from a comma-separated string. + * + * @remarks + * If the tooltip already has an `anchor` bound it will remove the old + * set of triggers from it and rebind it with the new one. + */ + public set hideTriggers(value: string) { + const triggers = parseTriggers(value); + + if (this._anchor) { + this._toggleTriggers(this._hideTriggers, triggers); } - for (const trigger of hide) { - anchor.addEventListener(trigger, this._tooltip[hideOnTrigger]); + this._hideTriggers = triggers; + } + + /** + * Returns the current set of show triggers as a comma-separated string. + */ + public get showTriggers(): string { + return Array.from(this._showTriggers).join(); + } + + /** + * Sets a new set of show triggers from a comma-separated string. + * + * @remarks + * If the tooltip already has an `anchor` bound it will remove the old + * set of triggers from it and rebind it with the new one. + */ + public set showTriggers(value: string) { + const triggers = parseTriggers(value); + + if (this._anchor) { + this._toggleTriggers(this._showTriggers, triggers); } + + this._showTriggers = triggers; + } + + constructor(tooltip: IgcTooltipComponent, options: TooltipCallbacks) { + this._tooltip = tooltip; + this._options = options; + this._tooltip.addController(this); } - /** Removes all tooltip trigger events from the given anchor */ - public remove(anchor?: TooltipAnchor): void { - if (!anchor) { - return; + private _toggleTriggers(previous: Set, current: Set): void { + for (const each of previous) { + this._anchor?.removeEventListener(each, this); + } + + for (const each of current) { + this._anchor?.addEventListener(each, this); } + } - for (const trigger of this._showTriggers) { - anchor.removeEventListener(trigger, this._tooltip[showOnTrigger]); + private _dispose(): void { + for (const each of this._showTriggers) { + this._anchor?.removeEventListener(each, this); } - for (const trigger of this._hideTriggers) { - anchor.removeEventListener(trigger, this._tooltip[hideOnTrigger]); + for (const each of this._hideTriggers) { + this._anchor?.removeEventListener(each, this); } + + this._anchor = null; } /** @internal */ public hostConnected(): void { - this._tooltip.addEventListener( - 'pointerenter', - this._tooltip[showOnTrigger] - ); - this._tooltip.addEventListener( - 'pointerleave', - this._tooltip[hideOnTrigger] - ); + this._tooltip.addEventListener('pointerenter', this); + this._tooltip.addEventListener('pointerleave', this); } /** @internal */ public hostDisconnected(): void { - this._tooltip.removeEventListener( - 'pointerenter', - this._tooltip[showOnTrigger] - ); - this._tooltip.removeEventListener( - 'pointerleave', - this._tooltip[hideOnTrigger] - ); + // console.log('disconnected callback'); + this._tooltip.hide(); + service.remove(this._tooltip); + this._tooltip.removeEventListener('pointerenter', this); + this._tooltip.removeEventListener('pointerleave', this); + } + + /** @internal */ + public handleEvent(event: Event): void { + // Tooltip handlers + if (event.target === this._tooltip) { + switch (event.type) { + case 'pointerenter': + this._options.onShow.call(this._tooltip, event); + break; + case 'pointerleave': + this._options.onHide.call(this._tooltip, event); + break; + default: + return; + } + } + + // Anchor handlers + if (event.target === this._anchor) { + if (this._showTriggers.has(event.type)) { + this._options.onShow.call(this._tooltip, event); + } + + if (this._hideTriggers.has(event.type)) { + this._options.onHide.call(this._tooltip, event); + } + } } } -export const showOnTrigger = Symbol(); -export const hideOnTrigger = Symbol(); +function parseTriggers(string: string): Set { + return new Set((string ?? '').split(',').map((part) => part.trim())); +} export function addTooltipController( - host: IgcTooltipComponent + host: IgcTooltipComponent, + options: TooltipCallbacks ): TooltipController { - return new TooltipController(host); + return new TooltipController(host, options); } diff --git a/src/components/tooltip/tooltip-service.ts b/src/components/tooltip/tooltip-service.ts index 28bbf9921..cc0f5e487 100644 --- a/src/components/tooltip/tooltip-service.ts +++ b/src/components/tooltip/tooltip-service.ts @@ -3,7 +3,7 @@ import { escapeKey } from '../common/controllers/key-bindings.js'; import { isEmpty, last } from '../common/util.js'; import type IgcTooltipComponent from './tooltip.js'; -type TooltipHideCallback = () => unknown | Promise; +type TooltipHideCallback = () => unknown; class TooltipEscapeCallbacks { private _collection = new Map(); diff --git a/src/components/tooltip/tooltip.spec.ts b/src/components/tooltip/tooltip.spec.ts index 7bdc6d78d..5409a1fd6 100644 --- a/src/components/tooltip/tooltip.spec.ts +++ b/src/components/tooltip/tooltip.spec.ts @@ -284,7 +284,7 @@ describe('Tooltip', () => { await elementUpdated(tooltip); expect(tooltip).dom.to.equal( - 'It works!', + 'It works!', DIFF_OPTIONS ); expect(tooltip).shadowDom.to.equal( diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index 6361ea157..dd5569459 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -10,17 +10,13 @@ import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; -import { getElementByIdFromRoot, isString } from '../common/util.js'; +import { asNumber, getElementByIdFromRoot, isString } from '../common/util.js'; import IgcIconComponent from '../icon/icon.js'; import IgcPopoverComponent, { type IgcPlacement } from '../popover/popover.js'; import { styles as shared } from './themes/shared/tooltip.common.css'; import { all } from './themes/themes.js'; import { styles } from './themes/tooltip.base.css.js'; -import { - addTooltipController, - hideOnTrigger, - showOnTrigger, -} from './tooltip-event-controller.js'; +import { addTooltipController } from './tooltip-event-controller.js'; import service from './tooltip-service.js'; export interface IgcTooltipComponentEventMap { @@ -36,10 +32,6 @@ type TooltipStateOptions = { withEvents?: boolean; }; -function parseTriggers(string: string): string[] { - return (string ?? '').split(',').map((part) => part.trim()); -} - /** * @element igc-tooltip * @@ -68,18 +60,24 @@ export default class IgcTooltipComponent extends EventEmitterMixin< ); } private readonly _internals: ElementInternals; - private _controller = addTooltipController(this); - private _target?: Element | null; + private readonly _controller = addTooltipController(this, { + onShow: this._showOnInteraction, + onHide: this._hideOnInteraction, + }); private readonly _containerRef: Ref = createRef(); - private readonly _animationPlayer = addAnimationController( - this, - this._containerRef - ); + private readonly _player = addAnimationController(this, this._containerRef); + + private readonly _showAnimation = scaleInCenter({ + duration: 150, + easing: EaseOut.Quad, + }); + private readonly _hideAnimation = fadeOut({ + duration: 75, + easing: EaseOut.Sine, + }); private _timeoutId?: number; private _open = false; - private _showTriggers = ['pointerenter']; - private _hideTriggers = ['pointerleave']; private _showDelay = 200; private _hideDelay = 300; @@ -89,12 +87,15 @@ export default class IgcTooltipComponent extends EventEmitterMixin< /** * Whether the tooltip is showing. * - * @attr + * @attr open + * @default false */ @property({ type: Boolean, reflect: true }) public set open(value: boolean) { this._open = value; - this._open ? service.add(this, this[hideOnTrigger]) : service.remove(this); + this._open + ? service.add(this, this._hideOnInteraction) + : service.remove(this); } public get open(): boolean { @@ -105,6 +106,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< * Whether to disable the rendering of the arrow indicator for the tooltip. * * @attr disable-arrow + * @default false */ @property({ attribute: 'disable-arrow', type: Boolean, reflect: true }) public disableArrow = false; @@ -113,6 +115,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< * Improves positioning for inline based elements, such as links. * * @attr inline + * @default false */ @property({ type: Boolean, reflect: true }) public inline = false; @@ -121,6 +124,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< * The offset of the tooltip from the anchor. * * @attr offset + * @default 6 */ @property({ type: Number }) public offset = 6; @@ -129,6 +133,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< * Where to place the floating element relative to the parent anchor element. * * @attr placement + * @default top */ @property() public placement: IgcPlacement = 'top'; @@ -146,18 +151,15 @@ export default class IgcTooltipComponent extends EventEmitterMixin< * Expects a comma separate string of different event triggers. * * @attr show-triggers + * @default pointerenter */ @property({ attribute: 'show-triggers' }) public set showTriggers(value: string) { - this._showTriggers = parseTriggers(value); - this._controller.set(this._target, { - show: this._showTriggers, - hide: this._hideTriggers, - }); + this._controller.showTriggers = value; } public get showTriggers(): string { - return this._showTriggers.join(); + return this._controller.showTriggers; } /** @@ -165,28 +167,26 @@ export default class IgcTooltipComponent extends EventEmitterMixin< * Expects a comma separate string of different event triggers. * * @attr hide-triggers + * @default pointerleave */ @property({ attribute: 'hide-triggers' }) public set hideTriggers(value: string) { - this._hideTriggers = parseTriggers(value); - this._controller.set(this._target, { - show: this._showTriggers, - hide: this._hideTriggers, - }); + this._controller.hideTriggers = value; } public get hideTriggers(): string { - return this._hideTriggers.join(); + return this._controller.hideTriggers; } /** * Specifies the number of milliseconds that should pass before showing the tooltip. * * @attr show-delay + * @default 200 */ @property({ attribute: 'show-delay', type: Number }) public set showDelay(value: number) { - this._showDelay = Math.max(0, value); + this._showDelay = Math.max(0, asNumber(value)); } public get showDelay(): number { @@ -197,10 +197,11 @@ export default class IgcTooltipComponent extends EventEmitterMixin< * Specifies the number of milliseconds that should pass before hiding the tooltip. * * @attr hide-delay + * @default 300 */ @property({ attribute: 'hide-delay', type: Number }) public set hideDelay(value: number) { - this._hideDelay = Math.max(0, value); + this._hideDelay = Math.max(0, asNumber(value)); } public get hideDelay(): number { @@ -212,15 +213,16 @@ export default class IgcTooltipComponent extends EventEmitterMixin< * * @attr message */ - @property({ type: String }) + @property() public message = ''; /** * Specifies if the tooltip remains visible until the user closes it via the close button or Esc key. * * @attr sticky + * @default false */ - @property({ type: Boolean }) + @property({ type: Boolean, reflect: true }) public sticky = false; constructor() { @@ -232,52 +234,24 @@ export default class IgcTooltipComponent extends EventEmitterMixin< this._internals.ariaLive = 'polite'; } - protected override async firstUpdated() { - if (!this._target) { - this._setTarget(this.previousElementSibling); - } + protected override firstUpdated(): void { + this._controller.anchor ??= this.previousElementSibling; if (this.open) { - await this.updateComplete; - this.requestUpdate(); + this.updateComplete.then(() => { + this._player.playExclusive(this._showAnimation); + this.requestUpdate(); + }); } } - /** @internal */ - public override disconnectedCallback() { - this._controller.remove(this._target); - service.remove(this); - - super.disconnectedCallback(); - } - @watch('anchor') - protected _anchorChanged() { + protected _anchorChanged(): void { const target = isString(this.anchor) ? getElementByIdFromRoot(this, this.anchor) : this.anchor; - this._setTarget(target); - } - - private _setTarget(target?: Element | null) { - if (!target) { - return; - } - - this._target = target; - this._controller.set(target, { - show: this._showTriggers, - hide: this._hideTriggers, - }); - } - - private async _toggleAnimation(dir: 'open' | 'close') { - const animation = - dir === 'open' - ? scaleInCenter({ duration: 150, easing: EaseOut.Quad }) - : fadeOut({ duration: 75, easing: EaseOut.Sine }); - return this._animationPlayer.playExclusive(animation); + this._controller.anchor = target; } private async _applyTooltipState({ @@ -293,7 +267,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< const eventName = show ? 'igcOpening' : 'igcClosing'; const allowed = this.emitEvent(eventName, { cancelable: true, - detail: this._target, + detail: this._controller.anchor, }); if (!allowed) return false; @@ -304,7 +278,9 @@ export default class IgcTooltipComponent extends EventEmitterMixin< this.open = true; } - const result = await this._toggleAnimation(show ? 'open' : 'close'); + const result = await this._player.playExclusive( + show ? this._showAnimation : this._hideAnimation + ); this.open = result ? show : !show; if (!result) { @@ -313,7 +289,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< if (withEvents) { const eventName = show ? 'igcOpened' : 'igcClosed'; - this.emitEvent(eventName, { detail: this._target }); + this.emitEvent(eventName, { detail: this._controller.anchor }); } return result; @@ -342,11 +318,11 @@ export default class IgcTooltipComponent extends EventEmitterMixin< } /** Toggles the tooltip between shown/hidden state */ - public toggle = async (): Promise => { + public toggle(): Promise { return this.open ? this.hide() : this.show(); - }; + } - public showWithEvent(): Promise { + protected _showWithEvent(): Promise { return this._applyTooltipState({ show: true, withDelay: true, @@ -354,7 +330,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< }); } - public hideWithEvent(): Promise { + protected _hideWithEvent(): Promise { return this._applyTooltipState({ show: false, withDelay: true, @@ -362,23 +338,22 @@ export default class IgcTooltipComponent extends EventEmitterMixin< }); } - protected [showOnTrigger] = () => { - this._animationPlayer.stopAll(); + private _showOnInteraction(): void { + this._player.stopAll(); clearTimeout(this._timeoutId); - this.showWithEvent(); - }; + this._showWithEvent(); + } - protected [hideOnTrigger] = (event?: Event) => { + private _hideOnInteraction(event?: Event): void { //TODO: IF NEEDED CHECK FOR ESCAPE KEY => - // Return if is sticky if (this.sticky && event) { return; } - this._animationPlayer.stopAll(); + this._player.stopAll(); clearTimeout(this._timeoutId); - this._timeoutId = setTimeout(() => this.hideWithEvent(), 180); - }; + this._timeoutId = setTimeout(() => this._hideWithEvent(), 180); + } protected override render() { return html` @@ -386,7 +361,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< .inert=${!this.open} .placement=${this.placement} .offset=${this.offset} - .anchor=${this._target ?? undefined} + .anchor=${this._controller.anchor ?? undefined} .arrow=${this.disableArrow ? null : this._arrowElement} ?open=${this.open} ?inline=${this.inline} @@ -397,7 +372,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< ${this.message ? html`${this.message}` : nothing} ${this.sticky ? html` - + = { description: 'Which event triggers will show the tooltip.\nExpects a comma separate string of different event triggers.', control: 'text', + table: { defaultValue: { summary: 'pointerenter' } }, }, hideTriggers: { type: 'string', description: 'Which event triggers will hide the tooltip.\nExpects a comma separate string of different event triggers.', control: 'text', + table: { defaultValue: { summary: 'pointerleave' } }, }, showDelay: { type: 'number', description: 'Specifies the number of milliseconds that should pass before showing the tooltip.', control: 'number', + table: { defaultValue: { summary: '200' } }, }, hideDelay: { type: 'number', description: 'Specifies the number of milliseconds that should pass before hiding the tooltip.', control: 'number', + table: { defaultValue: { summary: '300' } }, }, message: { type: 'string', @@ -130,12 +134,12 @@ const metadata: Meta = { inline: false, offset: 6, placement: 'top', - message: '', - sticky: false, - showDelay: 200, - hideDelay: 300, showTriggers: 'pointerenter', hideTriggers: 'pointerleave', + showDelay: 200, + hideDelay: 300, + message: '', + sticky: false, }, }; @@ -196,6 +200,12 @@ registerIcon( export const Basic: Story = { render: (args) => html` + + With an IDREF reference... @@ -235,8 +245,7 @@ export const Basic: Story = { that has a tooltip.

- - +
@@ -315,3 +324,32 @@ export const Triggers: Story = { `, }; + +export const Default: Story = { + render: () => html` + Hover over me + +

Showing a tooltip!

+
+ `, +}; + +let tooltip!: IgcTooltipComponent; + +function createDynamicTooltip() { + tooltip ??= document.createElement('igc-tooltip'); + tooltip.message = `I'm created on demand at ${new Date().toLocaleTimeString()}`; + tooltip.anchor = 'dynamic-target'; + tooltip.id = 'dynamic'; + + tooltip.addEventListener('igcClosed', () => tooltip.remove()); + document.body.appendChild(tooltip); + tooltip.show(); +} + +export const DynamicTooltip: Story = { + render: () => html` + Create tooltip + Target of the dynamic tooltip + `, +}; From b14bea143d1f987c14737c9202123886e49135d6 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 8 Apr 2025 10:30:59 +0300 Subject: [PATCH 27/48] refactor: applyTooltipState logic --- .../tooltip/tooltip-event-controller.ts | 44 +++++++++----- src/components/tooltip/tooltip-service.ts | 1 + src/components/tooltip/tooltip.spec.ts | 2 +- src/components/tooltip/tooltip.ts | 59 ++++++++----------- stories/tooltip.stories.ts | 13 ++-- 5 files changed, 65 insertions(+), 54 deletions(-) diff --git a/src/components/tooltip/tooltip-event-controller.ts b/src/components/tooltip/tooltip-event-controller.ts index ad4b2cb91..34fc058ba 100644 --- a/src/components/tooltip/tooltip-event-controller.ts +++ b/src/components/tooltip/tooltip-event-controller.ts @@ -10,13 +10,28 @@ type TooltipCallbacks = { }; class TooltipController implements ReactiveController { - private readonly _tooltip: IgcTooltipComponent; + private readonly _host: IgcTooltipComponent; private _anchor: TooltipAnchor; private _options!: TooltipCallbacks; private _showTriggers = new Set(['pointerenter']); private _hideTriggers = new Set(['pointerleave']); + private _open = false; + + /** Whether the tooltip is in shown state. */ + public get open(): boolean { + return this._open; + } + + /** Sets the shown state of the current tooltip. */ + public set open(value: boolean) { + this._open = value; + this._open + ? service.add(this._host, this._options.onHide) + : service.remove(this._host); + } + /** * Returns the current tooltip anchor target if any. */ @@ -90,9 +105,9 @@ class TooltipController implements ReactiveController { } constructor(tooltip: IgcTooltipComponent, options: TooltipCallbacks) { - this._tooltip = tooltip; + this._host = tooltip; this._options = options; - this._tooltip.addController(this); + this._host.addController(this); } private _toggleTriggers(previous: Set, current: Set): void { @@ -119,29 +134,28 @@ class TooltipController implements ReactiveController { /** @internal */ public hostConnected(): void { - this._tooltip.addEventListener('pointerenter', this); - this._tooltip.addEventListener('pointerleave', this); + this._host.addEventListener('pointerenter', this); + this._host.addEventListener('pointerleave', this); } /** @internal */ public hostDisconnected(): void { - // console.log('disconnected callback'); - this._tooltip.hide(); - service.remove(this._tooltip); - this._tooltip.removeEventListener('pointerenter', this); - this._tooltip.removeEventListener('pointerleave', this); + this._dispose(); + service.remove(this._host); + this._host.removeEventListener('pointerenter', this); + this._host.removeEventListener('pointerleave', this); } /** @internal */ public handleEvent(event: Event): void { // Tooltip handlers - if (event.target === this._tooltip) { + if (event.target === this._host) { switch (event.type) { case 'pointerenter': - this._options.onShow.call(this._tooltip, event); + this._options.onShow.call(this._host, event); break; case 'pointerleave': - this._options.onHide.call(this._tooltip, event); + this._options.onHide.call(this._host, event); break; default: return; @@ -151,11 +165,11 @@ class TooltipController implements ReactiveController { // Anchor handlers if (event.target === this._anchor) { if (this._showTriggers.has(event.type)) { - this._options.onShow.call(this._tooltip, event); + this._options.onShow.call(this._host, event); } if (this._hideTriggers.has(event.type)) { - this._options.onHide.call(this._tooltip, event); + this._options.onHide.call(this._host, event); } } } diff --git a/src/components/tooltip/tooltip-service.ts b/src/components/tooltip/tooltip-service.ts index cc0f5e487..fabae7714 100644 --- a/src/components/tooltip/tooltip-service.ts +++ b/src/components/tooltip/tooltip-service.ts @@ -9,6 +9,7 @@ class TooltipEscapeCallbacks { private _collection = new Map(); private _setListener(state = true): void { + /* c8 ignore next 3 */ if (isServer) { return; } diff --git a/src/components/tooltip/tooltip.spec.ts b/src/components/tooltip/tooltip.spec.ts index 5409a1fd6..6eb486c1f 100644 --- a/src/components/tooltip/tooltip.spec.ts +++ b/src/components/tooltip/tooltip.spec.ts @@ -615,7 +615,7 @@ describe('Tooltip', () => { ); expect(eventSpy.secondCall).calledWith( state.open ? 'igcOpened' : 'igcClosed', - { detail: anchor } + { cancelable: false, detail: anchor } ); }; diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index dd5569459..b763ce194 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -17,7 +17,6 @@ import { styles as shared } from './themes/shared/tooltip.common.css'; import { all } from './themes/themes.js'; import { styles } from './themes/tooltip.base.css.js'; import { addTooltipController } from './tooltip-event-controller.js'; -import service from './tooltip-service.js'; export interface IgcTooltipComponentEventMap { igcOpening: CustomEvent; @@ -77,7 +76,6 @@ export default class IgcTooltipComponent extends EventEmitterMixin< }); private _timeoutId?: number; - private _open = false; private _showDelay = 200; private _hideDelay = 300; @@ -92,14 +90,11 @@ export default class IgcTooltipComponent extends EventEmitterMixin< */ @property({ type: Boolean, reflect: true }) public set open(value: boolean) { - this._open = value; - this._open - ? service.add(this, this._hideOnInteraction) - : service.remove(this); + this._controller.open = value; } public get open(): boolean { - return this._open; + return this._controller.open; } /** @@ -254,57 +249,55 @@ export default class IgcTooltipComponent extends EventEmitterMixin< this._controller.anchor = target; } + private _emitEvent(name: keyof IgcTooltipComponentEventMap): boolean { + return this.emitEvent(name, { + cancelable: name === 'igcOpening' || name === 'igcClosing', + detail: this._controller.anchor, + }); + } + private async _applyTooltipState({ show, withDelay = false, withEvents = false, }: TooltipStateOptions): Promise { - if (show === this.open) return false; - - const delay = show ? this.showDelay : this.hideDelay; - - if (withEvents) { - const eventName = show ? 'igcOpening' : 'igcClosing'; - const allowed = this.emitEvent(eventName, { - cancelable: true, - detail: this._controller.anchor, - }); + if (show === this.open) { + return false; + } - if (!allowed) return false; + if (withEvents && !this._emitEvent(show ? 'igcOpening' : 'igcClosing')) { + return false; } - const _commitStateChange = async () => { + const commitStateChange = async (): Promise => { if (show) { this.open = true; } - const result = await this._player.playExclusive( + const animationComplete = await this._player.playExclusive( show ? this._showAnimation : this._hideAnimation ); - this.open = result ? show : !show; - if (!result) { - return false; - } + this.open = show; - if (withEvents) { - const eventName = show ? 'igcOpened' : 'igcClosed'; - this.emitEvent(eventName, { detail: this._controller.anchor }); + if (animationComplete && withEvents) { + this._emitEvent(show ? 'igcOpened' : 'igcClosed'); } - return result; + return animationComplete; }; if (withDelay) { clearTimeout(this._timeoutId); - return new Promise((resolve) => { - this._timeoutId = setTimeout(() => { - _commitStateChange().then(resolve); - }, delay); + return new Promise(() => { + this._timeoutId = setTimeout( + async () => await commitStateChange(), + show ? this.showDelay : this.hideDelay + ); }); } - return _commitStateChange(); + return commitStateChange(); } /** Shows the tooltip if not already showing. */ diff --git a/stories/tooltip.stories.ts b/stories/tooltip.stories.ts index 944365f61..c382f7a98 100644 --- a/stories/tooltip.stories.ts +++ b/stories/tooltip.stories.ts @@ -334,16 +334,19 @@ export const Default: Story = { `, }; -let tooltip!: IgcTooltipComponent; - function createDynamicTooltip() { - tooltip ??= document.createElement('igc-tooltip'); + const tooltip = document.createElement('igc-tooltip'); tooltip.message = `I'm created on demand at ${new Date().toLocaleTimeString()}`; tooltip.anchor = 'dynamic-target'; tooltip.id = 'dynamic'; - tooltip.addEventListener('igcClosed', () => tooltip.remove()); - document.body.appendChild(tooltip); + const previousTooltip = document.querySelector('#dynamic'); + const target = document.querySelector('#dynamic-target')!; + + previousTooltip + ? previousTooltip.replaceWith(tooltip) + : target.after(tooltip); + tooltip.show(); } From 2bde4f2fb494e19393a29de2bcd4b3d462d9e60c Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 8 Apr 2025 12:25:44 +0300 Subject: [PATCH 28/48] feat: Added shift-padding to pass to igc-popover * igc-tooltip has an 8px padding when shown near viewport edges --- src/components/popover/popover.ts | 8 ++++++++ src/components/tooltip/tooltip.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/src/components/popover/popover.ts b/src/components/popover/popover.ts index c43c3feb5..39c64837b 100644 --- a/src/components/popover/popover.ts +++ b/src/components/popover/popover.ts @@ -128,6 +128,12 @@ export default class IgcPopoverComponent extends LitElement { @property({ type: Boolean, reflect: true }) public shift = false; + /** + * Virtual padding for the resolved overflow detection offsets in pixels. + */ + @property({ type: Number, attribute: 'shift-padding' }) + public shiftPadding = 0; + @watch('anchor') protected anchorChange() { const newTarget = isString(this.anchor) @@ -152,6 +158,7 @@ export default class IgcPopoverComponent extends LitElement { @watch('placement', { waitUntilFirstUpdate: true }) @watch('sameWidth', { waitUntilFirstUpdate: true }) @watch('shift', { waitUntilFirstUpdate: true }) + @watch('shiftPadding', { waitUntilFirstUpdate: true }) protected floatingPropChange() { this._updateState(); } @@ -217,6 +224,7 @@ export default class IgcPopoverComponent extends LitElement { if (this.shift) { middleware.push( shift({ + padding: this.shiftPadding, limiter: limitShift(), }) ); diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index b763ce194..9d6db6145 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -356,6 +356,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< .offset=${this.offset} .anchor=${this._controller.anchor ?? undefined} .arrow=${this.disableArrow ? null : this._arrowElement} + .shiftPadding=${8} ?open=${this.open} ?inline=${this.inline} flip From cddd79da16fbd6c233dc100771bb83cd2bb5e697 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Tue, 8 Apr 2025 16:01:41 +0300 Subject: [PATCH 29/48] fix(tooltip): fix tooltip interaction handling when the tooltip and the anchor overlap --- src/components/tooltip/tooltip.ts | 44 ++++++++++++++++++++++++++----- stories/tooltip.stories.ts | 4 +-- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index 9d6db6145..6d50be3b2 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -75,6 +75,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< easing: EaseOut.Sine, }); + private _closeButtonClick = false; private _timeoutId?: number; private _showDelay = 200; private _hideDelay = 300; @@ -116,7 +117,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< public inline = false; /** - * The offset of the tooltip from the anchor. + * The offset of the tooltip from the anchor in pixels. * * @attr offset * @default 6 @@ -278,7 +279,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< show ? this._showAnimation : this._hideAnimation ); - this.open = show; + this.open = animationComplete ? show : !show; if (animationComplete && withEvents) { this._emitEvent(show ? 'igcOpened' : 'igcClosed'); @@ -331,15 +332,46 @@ export default class IgcTooltipComponent extends EventEmitterMixin< }); } - private _showOnInteraction(): void { + private _hideWithCloseButton(): void { + this._closeButtonClick = true; + this._hideWithEvent(); + } + + private _showOnInteraction(event?: Event): void { + // Check for overlapping tooltip and anchor to prevent canceling an ongoing open animation + if ( + event instanceof PointerEvent && + event.target === this && + event.relatedTarget === this._controller.anchor + ) { + return; + } + this._player.stopAll(); clearTimeout(this._timeoutId); this._showWithEvent(); } private _hideOnInteraction(event?: Event): void { - //TODO: IF NEEDED CHECK FOR ESCAPE KEY => - if (this.sticky && event) { + // Check for overlapping tooltip and anchor to prevent flickering and being stuck in a show loop + if ( + this.open && + event instanceof PointerEvent && + event.relatedTarget === this + ) { + return; + } + + // When the close button is clicked we need a state to ensure the closing event does not emit twice + if (this._closeButtonClick) { + this._closeButtonClick = false; + return; + } + + // If triggered by ESCAPE the event is undefined and it will proceed to hide + if (this.open && this.sticky && event) { + this._player.stopAll(); + clearTimeout(this._timeoutId); return; } @@ -366,7 +398,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< ${this.message ? html`${this.message}` : nothing} ${this.sticky ? html` - + = { }, offset: { type: 'number', - description: 'The offset of the tooltip from the anchor.', + description: 'The offset of the tooltip from the anchor in pixels.', control: 'number', table: { defaultValue: { summary: '6' } }, }, @@ -152,7 +152,7 @@ interface IgcTooltipArgs { disableArrow: boolean; /** Improves positioning for inline based elements, such as links. */ inline: boolean; - /** The offset of the tooltip from the anchor. */ + /** The offset of the tooltip from the anchor in pixels. */ offset: number; /** Where to place the floating element relative to the parent anchor element. */ placement: From 41f3ea38cd61bb0cbd3e623f32cced34f46b5fe9 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 9 Apr 2025 10:55:49 +0300 Subject: [PATCH 30/48] fix: Popover overlapping tooltip anchor * Tooltip pointer event handlers are added/removed based on open state. * Moved Escape key handler logic in a separate call. * Some code restructuring. --- .../tooltip/tooltip-event-controller.ts | 41 ++++++---- src/components/tooltip/tooltip.ts | 80 ++++++++----------- 2 files changed, 60 insertions(+), 61 deletions(-) diff --git a/src/components/tooltip/tooltip-event-controller.ts b/src/components/tooltip/tooltip-event-controller.ts index 34fc058ba..dc10e2382 100644 --- a/src/components/tooltip/tooltip-event-controller.ts +++ b/src/components/tooltip/tooltip-event-controller.ts @@ -7,6 +7,7 @@ type TooltipAnchor = Element | null | undefined; type TooltipCallbacks = { onShow: (event?: Event) => unknown; onHide: (event?: Event) => unknown; + onEscape: (event?: Event) => unknown; }; class TooltipController implements ReactiveController { @@ -27,9 +28,14 @@ class TooltipController implements ReactiveController { /** Sets the shown state of the current tooltip. */ public set open(value: boolean) { this._open = value; - this._open - ? service.add(this._host, this._options.onHide) - : service.remove(this._host); + + if (this._open) { + this._addTooltipListeners(); + service.add(this._host, this._options.onEscape); + } else { + this._removeTooltipListeners(); + service.remove(this._host); + } } /** @@ -110,13 +116,23 @@ class TooltipController implements ReactiveController { this._host.addController(this); } + private _addTooltipListeners(): void { + this._host.addEventListener('pointerenter', this, { passive: true }); + this._host.addEventListener('pointerleave', this, { passive: true }); + } + + private _removeTooltipListeners(): void { + this._host.removeEventListener('pointerenter', this); + this._host.removeEventListener('pointerleave', this); + } + private _toggleTriggers(previous: Set, current: Set): void { for (const each of previous) { this._anchor?.removeEventListener(each, this); } for (const each of current) { - this._anchor?.addEventListener(each, this); + this._anchor?.addEventListener(each, this, { passive: true }); } } @@ -132,18 +148,11 @@ class TooltipController implements ReactiveController { this._anchor = null; } - /** @internal */ - public hostConnected(): void { - this._host.addEventListener('pointerenter', this); - this._host.addEventListener('pointerleave', this); - } - /** @internal */ public hostDisconnected(): void { this._dispose(); + this._removeTooltipListeners(); service.remove(this._host); - this._host.removeEventListener('pointerenter', this); - this._host.removeEventListener('pointerleave', this); } /** @internal */ @@ -152,10 +161,10 @@ class TooltipController implements ReactiveController { if (event.target === this._host) { switch (event.type) { case 'pointerenter': - this._options.onShow.call(this._host, event); + this._options.onShow.call(this._host); break; case 'pointerleave': - this._options.onHide.call(this._host, event); + this._options.onHide.call(this._host); break; default: return; @@ -165,11 +174,11 @@ class TooltipController implements ReactiveController { // Anchor handlers if (event.target === this._anchor) { if (this._showTriggers.has(event.type)) { - this._options.onShow.call(this._host, event); + this._options.onShow.call(this._host); } if (this._hideTriggers.has(event.type)) { - this._options.onHide.call(this._host, event); + this._options.onHide.call(this._host); } } } diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index 6d50be3b2..ffa7afe26 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -1,6 +1,6 @@ import { LitElement, html, nothing } from 'lit'; import { property, query } from 'lit/decorators.js'; -import { type Ref, createRef, ref } from 'lit/directives/ref.js'; +import { createRef, ref } from 'lit/directives/ref.js'; import { EaseOut } from '../../animations/easings.js'; import { addAnimationController } from '../../animations/player.js'; import { fadeOut } from '../../animations/presets/fade/index.js'; @@ -58,30 +58,35 @@ export default class IgcTooltipComponent extends EventEmitterMixin< IgcIconComponent ); } + private readonly _internals: ElementInternals; + private readonly _controller = addTooltipController(this, { onShow: this._showOnInteraction, onHide: this._hideOnInteraction, + onEscape: this._hideOnEscape, }); - private readonly _containerRef: Ref = createRef(); + + private readonly _containerRef = createRef(); private readonly _player = addAnimationController(this, this._containerRef); private readonly _showAnimation = scaleInCenter({ duration: 150, easing: EaseOut.Quad, }); + private readonly _hideAnimation = fadeOut({ duration: 75, easing: EaseOut.Sine, }); - private _closeButtonClick = false; private _timeoutId?: number; + private _autoHideDelay = 180; private _showDelay = 200; private _hideDelay = 300; @query('#arrow') - protected _arrowElement!: HTMLElement; + private _arrowElement!: HTMLElement; /** * Whether the tooltip is showing. @@ -270,16 +275,22 @@ export default class IgcTooltipComponent extends EventEmitterMixin< return false; } - const commitStateChange = async (): Promise => { + const commitStateChange = async () => { if (show) { this.open = true; } + // Make the tooltip ignore most interactions while the animation + // is running. In the rare case when the popover overlaps its anchor + // this will prevent looping between the anchor and tooltip handlers. + this.inert = true; + const animationComplete = await this._player.playExclusive( show ? this._showAnimation : this._hideAnimation ); - this.open = animationComplete ? show : !show; + this.inert = false; + this.open = show; if (animationComplete && withEvents) { this._emitEvent(show ? 'igcOpened' : 'igcClosed'); @@ -332,52 +343,31 @@ export default class IgcTooltipComponent extends EventEmitterMixin< }); } - private _hideWithCloseButton(): void { - this._closeButtonClick = true; - this._hideWithEvent(); - } - - private _showOnInteraction(event?: Event): void { - // Check for overlapping tooltip and anchor to prevent canceling an ongoing open animation - if ( - event instanceof PointerEvent && - event.target === this && - event.relatedTarget === this._controller.anchor - ) { - return; - } - - this._player.stopAll(); + private _showOnInteraction(): void { clearTimeout(this._timeoutId); + this._player.stopAll(); this._showWithEvent(); } - private _hideOnInteraction(event?: Event): void { - // Check for overlapping tooltip and anchor to prevent flickering and being stuck in a show loop - if ( - this.open && - event instanceof PointerEvent && - event.relatedTarget === this - ) { - return; - } + private _runAutoHide(): void { + clearTimeout(this._timeoutId); + this._player.stopAll(); - // When the close button is clicked we need a state to ensure the closing event does not emit twice - if (this._closeButtonClick) { - this._closeButtonClick = false; - return; - } + this._timeoutId = setTimeout( + () => this._hideWithEvent(), + this._autoHideDelay + ); + } - // If triggered by ESCAPE the event is undefined and it will proceed to hide - if (this.open && this.sticky && event) { - this._player.stopAll(); - clearTimeout(this._timeoutId); - return; + private _hideOnInteraction(): void { + if (!this.sticky) { + this._runAutoHide(); } + } - this._player.stopAll(); - clearTimeout(this._timeoutId); - this._timeoutId = setTimeout(() => this._hideWithEvent(), 180); + private async _hideOnEscape(): Promise { + await this.hide(); + this._emitEvent('igcClosed'); } protected override render() { @@ -398,7 +388,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< ${this.message ? html`${this.message}` : nothing} ${this.sticky ? html` - + Date: Fri, 11 Apr 2025 17:48:19 +0300 Subject: [PATCH 31/48] fix(tooltip): role switches to 'status' for sticky tooltips --- src/components/tooltip/tooltip.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index ffa7afe26..40d6fd6c2 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -230,7 +230,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< super(); this._internals = this.attachInternals(); - this._internals.role = 'tooltip'; + this._internals.role = this.sticky ? 'status' : 'tooltip'; this._internals.ariaAtomic = 'true'; this._internals.ariaLive = 'polite'; } @@ -255,6 +255,11 @@ export default class IgcTooltipComponent extends EventEmitterMixin< this._controller.anchor = target; } + @watch('sticky') + protected _onStickyChange(): void { + this._internals.role = this.sticky ? 'status' : 'tooltip'; + } + private _emitEvent(name: keyof IgcTooltipComponentEventMap): boolean { return this.emitEvent(name, { cancelable: name === 'igcOpening' || name === 'igcClosing', From 99f16f93ae9bcd44f7a899e74afa29141a0952f3 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Fri, 11 Apr 2025 17:58:11 +0300 Subject: [PATCH 32/48] refactor(tooltip): implement dynamic anchor change for the tooltip * Moved handling of anchor resolution and updates to the event controller. * Added 'click' to the default hide-triggers * Tooltip `show()` can now accept a target element to allow transient anchors. * Refactored tests and added a check to ensure `igcClosed` fires on Escape key. --- .../tooltip/tooltip-event-controller.ts | 31 ++++++++- src/components/tooltip/tooltip.spec.ts | 64 ++++++++++++------ src/components/tooltip/tooltip.ts | 26 ++++--- stories/tooltip.stories.ts | 67 ++++++++++++++++--- 4 files changed, 147 insertions(+), 41 deletions(-) diff --git a/src/components/tooltip/tooltip-event-controller.ts b/src/components/tooltip/tooltip-event-controller.ts index dc10e2382..88477562b 100644 --- a/src/components/tooltip/tooltip-event-controller.ts +++ b/src/components/tooltip/tooltip-event-controller.ts @@ -1,4 +1,5 @@ import type { ReactiveController } from 'lit'; +import { getElementByIdFromRoot } from '../common/util.js'; import service from './tooltip-service.js'; import type IgcTooltipComponent from './tooltip.js'; @@ -13,10 +14,11 @@ type TooltipCallbacks = { class TooltipController implements ReactiveController { private readonly _host: IgcTooltipComponent; private _anchor: TooltipAnchor; + private _isTransientAnchor = false; private _options!: TooltipCallbacks; private _showTriggers = new Set(['pointerenter']); - private _hideTriggers = new Set(['pointerleave']); + private _hideTriggers = new Set(['pointerleave', 'click']); private _open = false; @@ -35,6 +37,11 @@ class TooltipController implements ReactiveController { } else { this._removeTooltipListeners(); service.remove(this._host); + + if (this._isTransientAnchor) { + this._dispose(); + this._isTransientAnchor = false; + } } } @@ -49,9 +56,12 @@ class TooltipController implements ReactiveController { * Removes all triggers from the previous `anchor` target and rebinds the current * sets back to the new value if it exists. */ - public set anchor(value: TooltipAnchor) { + public setAnchor(value: TooltipAnchor, transient = false): void { + if (this._anchor === value) return; + this._dispose(); this._anchor = value; + this._isTransientAnchor = transient; for (const each of this._showTriggers) { this._anchor?.addEventListener(each, this); @@ -116,6 +126,15 @@ class TooltipController implements ReactiveController { this._host.addController(this); } + public resolveAnchor(value: Element | string | undefined): void { + const resolvedAnchor = + typeof value === 'string' + ? getElementByIdFromRoot(this._host, value) + : (value ?? null); + + this.setAnchor(resolvedAnchor); + } + private _addTooltipListeners(): void { this._host.addEventListener('pointerenter', this, { passive: true }); this._host.addEventListener('pointerleave', this, { passive: true }); @@ -148,6 +167,14 @@ class TooltipController implements ReactiveController { this._anchor = null; } + /** @internal */ + public hostConnected(): void { + const attr = this._host.getAttribute('anchor'); + if (attr) { + this.resolveAnchor(attr); + } + } + /** @internal */ public hostDisconnected(): void { this._dispose(); diff --git a/src/components/tooltip/tooltip.spec.ts b/src/components/tooltip/tooltip.spec.ts index 6eb486c1f..7df94b396 100644 --- a/src/components/tooltip/tooltip.spec.ts +++ b/src/components/tooltip/tooltip.spec.ts @@ -72,7 +72,7 @@ describe('Tooltip', () => { expect(tooltip.placement).to.equal('top'); expect(tooltip.anchor).to.be.undefined; expect(tooltip.showTriggers).to.equal('pointerenter'); - expect(tooltip.hideTriggers).to.equal('pointerleave'); + expect(tooltip.hideTriggers).to.equal('pointerleave,click'); expect(tooltip.showDelay).to.equal(200); expect(tooltip.hideDelay).to.equal(300); expect(tooltip.message).to.equal(''); @@ -168,18 +168,13 @@ describe('Tooltip', () => { simulatePointerEnter(third); await clock.tickAsync(DEFAULT_SHOW_DELAY); await showComplete(); - expect(tooltip.open).to.be.true; + expect(tooltip.open).to.be.false; simulatePointerLeave(third); await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); await hideComplete(); expect(tooltip.open).to.be.false; - simulatePointerEnter(first); - await clock.tickAsync(DEFAULT_SHOW_DELAY); - await showComplete(); - expect(tooltip.open).to.be.false; - // By providing an IDREF tooltip.anchor = first.id; await elementUpdated(tooltip); @@ -465,7 +460,7 @@ describe('Tooltip', () => { describe('Behaviors', () => { beforeEach(async () => { clock = useFakeTimers({ toFake: ['setTimeout'] }); - const container = await fixture(createDefaultTooltip()); + const container = await fixture(createTooltipWithTarget()); anchor = container.querySelector('button')!; tooltip = container.querySelector(IgcTooltipComponent.tagName)!; }); @@ -476,7 +471,7 @@ describe('Tooltip', () => { it('default triggers', async () => { expect(tooltip.showTriggers).to.equal('pointerenter'); - expect(tooltip.hideTriggers).to.equal('pointerleave'); + expect(tooltip.hideTriggers).to.equal('pointerleave,click'); simulatePointerEnter(anchor); await clock.tickAsync(DEFAULT_SHOW_DELAY); @@ -490,8 +485,8 @@ describe('Tooltip', () => { }); it('custom triggers via property', async () => { - tooltip.showTriggers = 'focus,click'; - tooltip.hideTriggers = 'blur'; + tooltip.showTriggers = 'focus, pointerenter'; + tooltip.hideTriggers = 'blur, click'; simulateFocus(anchor); await clock.tickAsync(DEFAULT_SHOW_DELAY); @@ -503,12 +498,12 @@ describe('Tooltip', () => { await hideComplete(); expect(tooltip.open).to.be.false; - simulateClick(anchor); + simulatePointerEnter(anchor); await clock.tickAsync(DEFAULT_SHOW_DELAY); await showComplete(); expect(tooltip.open).to.be.true; - simulateBlur(anchor); + simulateClick(anchor); await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); await hideComplete(); expect(tooltip.open).to.be.false; @@ -517,8 +512,11 @@ describe('Tooltip', () => { it('custom triggers via attribute', async () => { const template = html`
- - Hover over me + I am a tooltip
@@ -594,7 +592,7 @@ describe('Tooltip', () => { beforeEach(async () => { clock = useFakeTimers({ toFake: ['setTimeout'] }); - const container = await fixture(createDefaultTooltip()); + const container = await fixture(createTooltipWithTarget()); tooltip = container.querySelector(IgcTooltipComponent.tagName)!; anchor = container.querySelector('button')!; eventSpy = spy(tooltip, 'emitEvent'); @@ -667,6 +665,32 @@ describe('Tooltip', () => { detail: anchor, }); }); + + it('fires `igcClosed` when tooltip is hidden via Escape key', async () => { + simulatePointerEnter(anchor); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(tooltip); + + eventSpy.resetHistory(); + + document.documentElement.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + composed: true, + }) + ); + + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(tooltip); + + expect(tooltip.open).to.be.false; + expect(eventSpy.callCount).to.equal(1); + expect(eventSpy.firstCall).calledWith('igcClosed', { + cancelable: false, + detail: anchor, + }); + }); }); describe('Keyboard interactions', () => { @@ -771,10 +795,10 @@ function createTooltipWithTarget(isOpen = false) { function createTooltips() { return html`
- - First - - Second + + First + + Second
`; } diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index 40d6fd6c2..f0a2c754b 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -10,7 +10,7 @@ import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; -import { asNumber, getElementByIdFromRoot, isString } from '../common/util.js'; +import { asNumber } from '../common/util.js'; import IgcIconComponent from '../icon/icon.js'; import IgcPopoverComponent, { type IgcPlacement } from '../popover/popover.js'; import { styles as shared } from './themes/shared/tooltip.common.css'; @@ -168,7 +168,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< * Expects a comma separate string of different event triggers. * * @attr hide-triggers - * @default pointerleave + * @default "pointerleave, click" */ @property({ attribute: 'hide-triggers' }) public set hideTriggers(value: string) { @@ -236,8 +236,6 @@ export default class IgcTooltipComponent extends EventEmitterMixin< } protected override firstUpdated(): void { - this._controller.anchor ??= this.previousElementSibling; - if (this.open) { this.updateComplete.then(() => { this._player.playExclusive(this._showAnimation); @@ -247,12 +245,8 @@ export default class IgcTooltipComponent extends EventEmitterMixin< } @watch('anchor') - protected _anchorChanged(): void { - const target = isString(this.anchor) - ? getElementByIdFromRoot(this, this.anchor) - : this.anchor; - - this._controller.anchor = target; + protected _onAnchorChange() { + this._controller.resolveAnchor(this.anchor); } @watch('sticky') @@ -318,7 +312,17 @@ export default class IgcTooltipComponent extends EventEmitterMixin< } /** Shows the tooltip if not already showing. */ - public show(): Promise { + public show(target?: Element): Promise { + if (target) { + clearTimeout(this._timeoutId); + this._player.stopAll(); + + if (this._controller.anchor !== target) { + this.open = false; + } + this._controller.setAnchor(target, true); + } + return this._applyTooltipState({ show: true }); } diff --git a/stories/tooltip.stories.ts b/stories/tooltip.stories.ts index 9f4ec17a2..6885b3e05 100644 --- a/stories/tooltip.stories.ts +++ b/stories/tooltip.stories.ts @@ -98,7 +98,7 @@ const metadata: Meta = { description: 'Which event triggers will hide the tooltip.\nExpects a comma separate string of different event triggers.', control: 'text', - table: { defaultValue: { summary: 'pointerleave' } }, + table: { defaultValue: { summary: 'pointerleave, click' } }, }, showDelay: { type: 'number', @@ -135,7 +135,7 @@ const metadata: Meta = { offset: 6, placement: 'top', showTriggers: 'pointerenter', - hideTriggers: 'pointerleave', + hideTriggers: 'pointerleave, click', showDelay: 200, hideDelay: 300, message: '', @@ -218,8 +218,9 @@ export const Basic: Story = { - + TOP KEK - Initial open state Right Tooltip @@ -297,9 +298,9 @@ function getValue() { } export const Triggers: Story = { - render: () => html` + render: (args) => html` Pointerenter/Pointerleave (default) - + I will show on pointerenter and hide on pointerleave @@ -327,8 +328,8 @@ export const Triggers: Story = { export const Default: Story = { render: () => html` - Hover over me - + Hover over me +

Showing a tooltip!

`, @@ -338,6 +339,8 @@ function createDynamicTooltip() { const tooltip = document.createElement('igc-tooltip'); tooltip.message = `I'm created on demand at ${new Date().toLocaleTimeString()}`; tooltip.anchor = 'dynamic-target'; + tooltip.showTriggers = 'focus, click'; + tooltip.hideTriggers = 'blur'; tooltip.id = 'dynamic'; const previousTooltip = document.querySelector('#dynamic'); @@ -356,3 +359,51 @@ export const DynamicTooltip: Story = { Target of the dynamic tooltip `, }; + +export const SharedTooltipMultipleAnchors: Story = { + render: () => { + const tooltipId = 'shared-tooltip'; + + setTimeout(() => { + const tooltip = document.getElementById(tooltipId) as any; + const elementTooltip = document.querySelector( + 'igc-tooltip:not([id])' + ) as any; + const elementButton = document.getElementById('elementButton'); + + // Set anchor for second tooltip dynamically + if (elementTooltip && elementButton) { + elementTooltip.anchor = elementButton; + elementTooltip.show(); + } + + document.querySelectorAll('.tooltip-trigger').forEach((btn) => { + btn.addEventListener('click', async () => { + tooltip.show(btn); + }); + }); + }); + + return html` +
+ Default Anchor + Transient 1 + Transient 2 + Element Anchor +
+ + + This is a shared tooltip! + + + + `; + }, +}; From 2bd951925f69a9981eecc3340a10ad2d0248a73e Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 14 Apr 2025 10:49:25 +0300 Subject: [PATCH 33/48] feat: Support same trigger for show and hide --- .../tooltip/tooltip-event-controller.ts | 4 +- src/components/tooltip/tooltip.ts | 16 +- stories/tooltip.stories.ts | 197 +++++++++++++++--- 3 files changed, 178 insertions(+), 39 deletions(-) diff --git a/src/components/tooltip/tooltip-event-controller.ts b/src/components/tooltip/tooltip-event-controller.ts index b83b2e70e..42a594918 100644 --- a/src/components/tooltip/tooltip-event-controller.ts +++ b/src/components/tooltip/tooltip-event-controller.ts @@ -200,11 +200,11 @@ class TooltipController implements ReactiveController { // Anchor handlers if (event.target === this._anchor) { - if (this._showTriggers.has(event.type)) { + if (this._showTriggers.has(event.type) && !this._open) { this._options.onShow.call(this._host); } - if (this._hideTriggers.has(event.type)) { + if (this._hideTriggers.has(event.type) && this._open) { this._options.onHide.call(this._host); } } diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index 115a6bd8e..ca4a0a624 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -316,8 +316,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< /** Shows the tooltip if not already showing. */ public show(target?: Element): Promise { if (target) { - clearTimeout(this._timeoutId); - this._player.stopAll(); + this._stopTimeoutAndAnimation(); if (this._controller.anchor !== target) { this.open = false; @@ -355,14 +354,17 @@ export default class IgcTooltipComponent extends EventEmitterMixin< } private _showOnInteraction(): void { - clearTimeout(this._timeoutId); - this._player.stopAll(); + this._stopTimeoutAndAnimation(); this._showWithEvent(); } - private _runAutoHide(): void { + private _stopTimeoutAndAnimation(): void { clearTimeout(this._timeoutId); this._player.stopAll(); + } + + private _setAutoHide(): void { + this._stopTimeoutAndAnimation(); this._timeoutId = setTimeout( () => this._hideWithEvent(), @@ -372,7 +374,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< private _hideOnInteraction(): void { if (!this.sticky) { - this._runAutoHide(); + this._setAutoHide(); } } @@ -399,7 +401,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< ${this.message ? html`${this.message}` : nothing} ${this.sticky ? html` - + = { table: { defaultValue: { summary: '6' } }, }, placement: { - type: 'IgcPlacement', + type: '"top" | "top-start" | "top-end" | "bottom" | "bottom-start" | "bottom-end" | "right" | "right-start" | "right-end" | "left" | "left-start" | "left-end"', description: 'Where to place the floating element relative to the parent anchor element.', - control: 'IgcPlacement', + options: [ + 'top', + 'top-start', + 'top-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'right', + 'right-start', + 'right-end', + 'left', + 'left-start', + 'left-end', + ], + control: { type: 'select' }, table: { defaultValue: { summary: 'top' } }, }, anchor: { @@ -143,7 +157,19 @@ interface IgcTooltipArgs { /** The offset of the tooltip from the anchor in pixels. */ offset: number; /** Where to place the floating element relative to the parent anchor element. */ - placement: IgcPlacement; + placement: + | 'top' + | 'top-start' + | 'top-end' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'right' + | 'right-start' + | 'right-end' + | 'left' + | 'left-start' + | 'left-end'; /** An element instance or an IDREF to use as the anchor for the tooltip. */ anchor: Element | string; /** @@ -330,36 +356,147 @@ export const Inline: Story = { `, }; -function getValue() { - return document.querySelector('igc-input')?.value; -} - export const Triggers: Story = { - render: (args) => html` - Pointerenter/Pointerleave (default) - - I will show on pointerenter and hide on pointerleave - - - Focus/Blur - - I will show on focus and hide on blur - - - Click - - I will show on click and hide on pointerleave or blur - + render: () => html` + +
+ + +

Default triggers

+
+ +

+ Hovering over the button bellow will show the default configuration + of a tooltip component which is pointer enter for + showing the tooltip and pointer leave or + click for hiding once shown. +

+ + Hover over me + + + I am show on pointer enter and hidden on pointer leave and/or click. + +
+
+ + + +

Focus based

+
+ +

+ In this instance, the tooltip is bound to show on its anchor + focus and will hide when its anchor is + blurred. +

+

Try to navigate with a Tab key to the anchor to see the effect.

+ + Focus me + + + I am shown on focus and hidden on blur. + +
+
+ + + +

Same trigger(s) for showing and hiding

+
+ +

+ The same trigger can be bound to both show and hide the tooltip. The + button below has its tooltip bound to show/hide on + click. +

+ + Click + + + I am show on click and will hide on anchor click. + +
+
+ + + +

Keyboard interactions

+
+ +

+ Keyboard interactions are also supported. The button below has its + tooltip bound to show on a keypress and hide on a + keypress or blur. +

+ +

Try it out by focusing the button and pressing a key.

+ + Press a key + + + I am shown on a keypress and will hide on a keypress or blur. + +
+
+ + + +

Custom events

+
+ +

+ The tooltip supports any DOM event including custom ones. Try typing + a value in the input below and then "commit" it by blurring the + input. The tooltip will be shown when the + igcChange event is fired from the input. +

+ + + + + Value changed! + +
+
+
`, }; From 861c4530aaa14cb780fd5d06121fa14a52feed2e Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Mon, 14 Apr 2025 11:09:06 +0300 Subject: [PATCH 34/48] test(tooltip): add tests for role switching and transient anchor behavior --- src/components/tooltip/tooltip.spec.ts | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/components/tooltip/tooltip.spec.ts b/src/components/tooltip/tooltip.spec.ts index 7df94b396..7798f46d7 100644 --- a/src/components/tooltip/tooltip.spec.ts +++ b/src/components/tooltip/tooltip.spec.ts @@ -387,6 +387,21 @@ describe('Tooltip', () => { await hideComplete(); expect(tooltip.open).to.be.false; }); + + it('should switch role to "status" when `sticky` is true, and back to "tooltip" when false', async () => { + expect(tooltip.sticky).to.be.false; + tooltip.sticky = true; + await elementUpdated(tooltip); + + let internalsRole = (tooltip as any)._internals.role; + expect(internalsRole).to.equal('status'); + + tooltip.sticky = false; + await elementUpdated(tooltip); + + internalsRole = (tooltip as any)._internals.role; + expect(internalsRole).to.equal('tooltip'); + }); }); describe('Methods` Tests', () => { @@ -455,6 +470,30 @@ describe('Tooltip', () => { DIFF_OPTIONS ); }); + + it('calls `show` with a new target, switches anchor, and resets anchor on hide', async () => { + const buttons = Array.from( + tooltip.parentElement!.querySelectorAll('button') + ); + + const [firstAnchor, secondAnchor] = buttons; + + let result = await tooltip.show(firstAnchor); + expect(result).to.be.true; + expect(tooltip.open).to.be.true; + + result = await tooltip.show(secondAnchor); + expect(result).to.be.true; + expect(tooltip.open).to.be.true; + + result = await tooltip.hide(); + expect(result).to.be.true; + expect(tooltip.open).to.be.false; + + // The controller's anchor should be null since the anchor is transient + const controllerAnchor = (tooltip as any)._controller.anchor; + expect(controllerAnchor).to.be.null; + }); }); describe('Behaviors', () => { From 629052f54a4e4055614a1774f509085ca34bb91c Mon Sep 17 00:00:00 2001 From: didimmova Date: Mon, 14 Apr 2025 11:55:33 +0300 Subject: [PATCH 35/48] refactor(tooltip): use physical instead of logical properties for the borders --- .../tooltip/themes/shared/tooltip.common.scss | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/tooltip/themes/shared/tooltip.common.scss b/src/components/tooltip/themes/shared/tooltip.common.scss index a7b244d4b..b9afd3830 100644 --- a/src/components/tooltip/themes/shared/tooltip.common.scss +++ b/src/components/tooltip/themes/shared/tooltip.common.scss @@ -14,29 +14,29 @@ $color-border: rem(4px) solid var-get($theme, 'background'); #arrow { &[part='top'], &[part='bottom'] { - border-inline-start: $transparent-border; - border-inline-end: $transparent-border; + border-left: $transparent-border; + border-right: $transparent-border; } &[part='top'] { - border-block-start: $color-border; + border-top: $color-border; } &[part='bottom'] { - border-block-end: $color-border; + border-bottom: $color-border; } &[part='left'], &[part='right'] { - border-block-start: $transparent-border; - border-block-end: $transparent-border; + border-top: $transparent-border; + border-bottom: $transparent-border; } &[part='left'] { - border-inline-start: $color-border; + border-left: $color-border; } &[part='right'] { - border-inline-end: $color-border; + border-right: $color-border; } } From 740e230c4f2c32fb5d82f3699adb943d8e73c009 Mon Sep 17 00:00:00 2001 From: didimmova Date: Mon, 14 Apr 2025 12:52:53 +0300 Subject: [PATCH 36/48] feat(tooltip): align items to center and change igc-icon size for sticky tooltip --- src/components/tooltip/themes/tooltip.base.scss | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/tooltip/themes/tooltip.base.scss b/src/components/tooltip/themes/tooltip.base.scss index e49ccd0a1..edf11873c 100644 --- a/src/components/tooltip/themes/tooltip.base.scss +++ b/src/components/tooltip/themes/tooltip.base.scss @@ -17,7 +17,7 @@ text-align: start; max-width: 200px; display: flex; - align-items: flex-start; + align-items: center; gap: rem(8px); position: relative; } @@ -31,3 +31,9 @@ igc-popover::part(container) { background-color: transparent; } + +slot[name='close-button'] { + igc-icon { + --component-size: 1; + } +} From 9f31422cbf9ec5008c6588973167070b9bedfd55 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 14 Apr 2025 12:58:08 +0300 Subject: [PATCH 37/48] feat: Tooltip arrow offset for different placements and RTL mode --- src/components/popover/popover.ts | 9 +++++++-- src/components/tooltip/tooltip.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/components/popover/popover.ts b/src/components/popover/popover.ts index 4cbaa2660..18eaa5e41 100644 --- a/src/components/popover/popover.ts +++ b/src/components/popover/popover.ts @@ -83,6 +83,10 @@ export default class IgcPopoverComponent extends LitElement { @property({ attribute: false }) public arrow: HTMLElement | null = null; + /** Additional offset to apply to the arrow element if enabled. */ + @property({ type: Number, attribute: 'arrow-offset' }) + public arrowOffset = 0; + /** * Improves positioning for inline reference elements that span over multiple lines. * Useful for tooltips or similar components. @@ -152,6 +156,7 @@ export default class IgcPopoverComponent extends LitElement { } @watch('arrow', { waitUntilFirstUpdate: true }) + @watch('arrowOffset', { waitUntilFirstUpdate: true }) @watch('flip', { waitUntilFirstUpdate: true }) @watch('inline', { waitUntilFirstUpdate: true }) @watch('offset', { waitUntilFirstUpdate: true }) @@ -305,8 +310,8 @@ export default class IgcPopoverComponent extends LitElement { this.arrow.part = currentPlacement; Object.assign(this.arrow.style, { - left: x != null ? `${roundByDPR(x)}px` : '', - top: y != null ? `${roundByDPR(y)}px` : '', + left: x != null ? `${roundByDPR(x + this.arrowOffset)}px` : '', + top: y != null ? `${roundByDPR(y + this.arrowOffset)}px` : '', [staticSide]: '-4px', }); } diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index ca4a0a624..ae9e2a69d 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -10,7 +10,7 @@ import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; -import { asNumber } from '../common/util.js'; +import { asNumber, isLTR } from '../common/util.js'; import IgcIconComponent from '../icon/icon.js'; import IgcPopoverComponent, { type PopoverPlacement, @@ -90,6 +90,32 @@ export default class IgcTooltipComponent extends EventEmitterMixin< @query('#arrow') private _arrowElement!: HTMLElement; + private get _arrowOffset() { + if (/-/.test(this.placement)) { + // Horizontal start | end placement + + if (/^(left|right)-start$/.test(this.placement)) { + return -8; + } + + if (/^(left|right)-end$/.test(this.placement)) { + return 8; + } + + // Vertical start | end placement + + if (/start$/.test(this.placement)) { + return isLTR(this) ? -8 : 8; + } + + if (/end$/.test(this.placement)) { + return isLTR(this) ? 8 : -8; + } + } + + return 0; + } + /** * Whether the tooltip is showing. * @@ -391,6 +417,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< .offset=${this.offset} .anchor=${this._controller.anchor ?? undefined} .arrow=${this.disableArrow ? null : this._arrowElement} + .arrowOffset=${this._arrowOffset} .shiftPadding=${8} ?open=${this.open} ?inline=${this.inline} From ad250f4e04426bf0c34cc64c0ac4d8d9265b5680 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Mon, 14 Apr 2025 16:26:26 +0300 Subject: [PATCH 38/48] test(tooltip): add an a11y test for sticky mode and fix test for transient anchors --- src/components/tooltip/tooltip.spec.ts | 32 +++++++++++--------------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/src/components/tooltip/tooltip.spec.ts b/src/components/tooltip/tooltip.spec.ts index 7798f46d7..272b6f403 100644 --- a/src/components/tooltip/tooltip.spec.ts +++ b/src/components/tooltip/tooltip.spec.ts @@ -63,6 +63,15 @@ describe('Tooltip', () => { await expect(tooltip).shadowDom.to.be.accessible(); }); + it('is accessible in sticky mode', async () => { + tooltip.sticky = true; + tooltip.open = true; + await elementUpdated(tooltip); + + await expect(tooltip).to.be.accessible(); + await expect(tooltip).shadowDom.to.be.accessible(); + }); + it('is correctly initialized with its default component state', () => { expect(tooltip.dir).to.be.empty; expect(tooltip.open).to.be.false; @@ -387,21 +396,6 @@ describe('Tooltip', () => { await hideComplete(); expect(tooltip.open).to.be.false; }); - - it('should switch role to "status" when `sticky` is true, and back to "tooltip" when false', async () => { - expect(tooltip.sticky).to.be.false; - tooltip.sticky = true; - await elementUpdated(tooltip); - - let internalsRole = (tooltip as any)._internals.role; - expect(internalsRole).to.equal('status'); - - tooltip.sticky = false; - await elementUpdated(tooltip); - - internalsRole = (tooltip as any)._internals.role; - expect(internalsRole).to.equal('tooltip'); - }); }); describe('Methods` Tests', () => { @@ -490,9 +484,11 @@ describe('Tooltip', () => { expect(result).to.be.true; expect(tooltip.open).to.be.false; - // The controller's anchor should be null since the anchor is transient - const controllerAnchor = (tooltip as any)._controller.anchor; - expect(controllerAnchor).to.be.null; + // The anchor was transient and the tooltip should not be shown on pointerenter + simulatePointerEnter(secondAnchor); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(); + expect(tooltip.open).to.be.false; }); }); From b20fe4294682157fd7549e6129bb96f877e2a54a Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Mon, 14 Apr 2025 16:27:47 +0300 Subject: [PATCH 39/48] fix(tooltip): update default hide-triggers format and enhance show method documentation --- src/components/tooltip/tooltip.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index ae9e2a69d..1117685a5 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -196,7 +196,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< * Expects a comma separate string of different event triggers. * * @attr hide-triggers - * @default "pointerleave, click" + * @default pointerleave, click */ @property({ attribute: 'hide-triggers' }) public set hideTriggers(value: string) { @@ -339,7 +339,10 @@ export default class IgcTooltipComponent extends EventEmitterMixin< return commitStateChange(); } - /** Shows the tooltip if not already showing. */ + /** + * Shows the tooltip if not already showing. + * If a target is provided, sets it as a transient anchor. + */ public show(target?: Element): Promise { if (target) { this._stopTimeoutAndAnimation(); From 386ae7c31ae8cd86e041abf98c22c96b6efd694b Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Tue, 15 Apr 2025 09:43:43 +0300 Subject: [PATCH 40/48] fix(tooltip): fix anchor resolution in hostConnected method --- src/components/tooltip/tooltip-event-controller.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/tooltip/tooltip-event-controller.ts b/src/components/tooltip/tooltip-event-controller.ts index 42a594918..6af3412fe 100644 --- a/src/components/tooltip/tooltip-event-controller.ts +++ b/src/components/tooltip/tooltip-event-controller.ts @@ -169,10 +169,8 @@ class TooltipController implements ReactiveController { /** @internal */ public hostConnected(): void { - const attr = this._host.getAttribute('anchor'); - if (attr) { - this.resolveAnchor(attr); - } + const anchor = this._host.anchor; + this.resolveAnchor(anchor); } /** @internal */ From 3d166ea0f8a1fa54ea2b50c6d5e014a6a89e6c62 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 15 Apr 2025 13:22:39 +0300 Subject: [PATCH 41/48] refactor: Tooltip controller Code restructure and internal event listeners state are now handled with AbortController --- .../tooltip/tooltip-event-controller.ts | 213 ++++++++++-------- src/components/tooltip/tooltip.ts | 2 +- stories/tooltip.stories.ts | 51 ++--- 3 files changed, 134 insertions(+), 132 deletions(-) diff --git a/src/components/tooltip/tooltip-event-controller.ts b/src/components/tooltip/tooltip-event-controller.ts index 6af3412fe..8b8127a7f 100644 --- a/src/components/tooltip/tooltip-event-controller.ts +++ b/src/components/tooltip/tooltip-event-controller.ts @@ -1,27 +1,28 @@ import type { ReactiveController } from 'lit'; -import { getElementByIdFromRoot } from '../common/util.js'; +import { getElementByIdFromRoot, isString } from '../common/util.js'; import service from './tooltip-service.js'; import type IgcTooltipComponent from './tooltip.js'; -type TooltipAnchor = Element | null | undefined; - -type TooltipCallbacks = { - onShow: (event?: Event) => unknown; - onHide: (event?: Event) => unknown; - onEscape: (event?: Event) => unknown; -}; - class TooltipController implements ReactiveController { + //#region Internal properties and state + private readonly _host: IgcTooltipComponent; - private _anchor: TooltipAnchor; - private _isTransientAnchor = false; + private readonly _options: TooltipCallbacks; + + private _hostAbortController: AbortController | null = null; + private _anchorAbortController: AbortController | null = null; - private _options: TooltipCallbacks; private _showTriggers = new Set(['pointerenter']); private _hideTriggers = new Set(['pointerleave', 'click']); + private _anchor: TooltipAnchor; + private _isTransientAnchor = false; private _open = false; + //#endregion + + //#region Public properties + /** Whether the tooltip is in shown state. */ public get open(): boolean { return this._open; @@ -52,26 +53,6 @@ class TooltipController implements ReactiveController { return this._anchor; } - /** - * Removes all triggers from the previous `anchor` target and rebinds the current - * sets back to the new value if it exists. - */ - public setAnchor(value: TooltipAnchor, transient = false): void { - if (this._anchor === value) return; - - this._dispose(); - this._anchor = value; - this._isTransientAnchor = transient; - - for (const each of this._showTriggers) { - this._anchor?.addEventListener(each, this); - } - - for (const each of this._hideTriggers) { - this._anchor?.addEventListener(each, this); - } - } - /** * Returns the current set of hide triggers as a comma-separated string. */ @@ -87,13 +68,9 @@ class TooltipController implements ReactiveController { * set of triggers from it and rebind it with the new one. */ public set hideTriggers(value: string) { - const triggers = parseTriggers(value); - - if (this._anchor) { - this._toggleTriggers(this._hideTriggers, triggers); - } - - this._hideTriggers = triggers; + this._hideTriggers = parseTriggers(value); + this._removeAnchorListeners(); + this._addAnchorListeners(); } /** @@ -111,66 +88,128 @@ class TooltipController implements ReactiveController { * set of triggers from it and rebind it with the new one. */ public set showTriggers(value: string) { - const triggers = parseTriggers(value); - - if (this._anchor) { - this._toggleTriggers(this._showTriggers, triggers); - } - - this._showTriggers = triggers; + this._showTriggers = parseTriggers(value); + this._removeAnchorListeners(); + this._addAnchorListeners(); } + //#endregion + constructor(tooltip: IgcTooltipComponent, options: TooltipCallbacks) { this._host = tooltip; this._options = options; this._host.addController(this); } - public resolveAnchor(value: Element | string | undefined): void { - const resolvedAnchor = - typeof value === 'string' - ? getElementByIdFromRoot(this._host, value) - : (value ?? null); + //#region Internal event listeners state + + private _addAnchorListeners(): void { + if (!this._anchor) return; + + this._anchorAbortController = new AbortController(); + const signal = this._anchorAbortController.signal; + + for (const each of this._showTriggers) { + this._anchor.addEventListener(each, this, { passive: true, signal }); + } - this.setAnchor(resolvedAnchor); + for (const each of this._hideTriggers) { + this._anchor.addEventListener(each, this, { passive: true, signal }); + } + } + + private _removeAnchorListeners(): void { + this._anchorAbortController?.abort(); + this._anchorAbortController = null; } private _addTooltipListeners(): void { - this._host.addEventListener('pointerenter', this, { passive: true }); - this._host.addEventListener('pointerleave', this, { passive: true }); + this._hostAbortController = new AbortController(); + const signal = this._hostAbortController.signal; + + this._host.addEventListener('pointerenter', this, { + passive: true, + signal, + }); + this._host.addEventListener('pointerleave', this, { + passive: true, + signal, + }); } private _removeTooltipListeners(): void { - this._host.removeEventListener('pointerenter', this); - this._host.removeEventListener('pointerleave', this); + this._hostAbortController?.abort(); + this._hostAbortController = null; } - private _toggleTriggers(previous: Set, current: Set): void { - for (const each of previous) { - this._anchor?.removeEventListener(each, this); - } + //#endregion - for (const each of current) { - this._anchor?.addEventListener(each, this, { passive: true }); + //#region Event handlers + + private _handleTooltipEvent(event: Event): void { + switch (event.type) { + case 'pointerenter': + this._options.onShow.call(this._host); + break; + case 'pointerleave': + this._options.onHide.call(this._host); } } - private _dispose(): void { - for (const each of this._showTriggers) { - this._anchor?.removeEventListener(each, this); + private _handleAnchorEvent(event: Event): void { + if (!this._open && this._showTriggers.has(event.type)) { + this._options.onShow.call(this._host); } - for (const each of this._hideTriggers) { - this._anchor?.removeEventListener(each, this); + if (this._open && this._hideTriggers.has(event.type)) { + this._options.onHide.call(this._host); + } + } + + /** @internal */ + public handleEvent(event: Event): void { + if (event.target === this._host) { + this._handleTooltipEvent(event); + } else if (event.target === this._anchor) { + this._handleAnchorEvent(event); } + } + + //#endregion + private _dispose(): void { + this._removeAnchorListeners(); this._anchor = null; } + //#region Public API + + /** + * Removes all triggers from the previous `anchor` target and rebinds the current + * sets back to the new value if it exists. + */ + public setAnchor(value: TooltipAnchor, transient = false): void { + if (this._anchor === value) return; + + this._removeAnchorListeners(); + this._anchor = value; + this._isTransientAnchor = transient; + this._addAnchorListeners(); + } + + public resolveAnchor(value: Element | string | undefined): void { + this.setAnchor( + isString(value) ? getElementByIdFromRoot(this._host, value) : value + ); + } + + //#endregion + + //#region ReactiveController interface + /** @internal */ public hostConnected(): void { - const anchor = this._host.anchor; - this.resolveAnchor(anchor); + this.resolveAnchor(this._host.anchor); } /** @internal */ @@ -180,33 +219,7 @@ class TooltipController implements ReactiveController { service.remove(this._host); } - /** @internal */ - public handleEvent(event: Event): void { - // Tooltip handlers - if (event.target === this._host) { - switch (event.type) { - case 'pointerenter': - this._options.onShow.call(this._host); - break; - case 'pointerleave': - this._options.onHide.call(this._host); - break; - default: - return; - } - } - - // Anchor handlers - if (event.target === this._anchor) { - if (this._showTriggers.has(event.type) && !this._open) { - this._options.onShow.call(this._host); - } - - if (this._hideTriggers.has(event.type) && this._open) { - this._options.onHide.call(this._host); - } - } - } + //#endregion } function parseTriggers(string: string): Set { @@ -219,3 +232,11 @@ export function addTooltipController( ): TooltipController { return new TooltipController(host, options); } + +type TooltipAnchor = Element | null | undefined; + +type TooltipCallbacks = { + onShow: (event?: Event) => unknown; + onHide: (event?: Event) => unknown; + onEscape: (event?: Event) => unknown; +}; diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index 1117685a5..e8dfcb44e 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -428,7 +428,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< shift >
- ${this.message ? html`${this.message}` : nothing} + ${this.message} ${this.sticky ? html` diff --git a/stories/tooltip.stories.ts b/stories/tooltip.stories.ts index 2a249b075..035375a3f 100644 --- a/stories/tooltip.stories.ts +++ b/stories/tooltip.stories.ts @@ -536,48 +536,29 @@ export const DynamicTooltip: Story = { export const SharedTooltipMultipleAnchors: Story = { render: () => { - const tooltipId = 'shared-tooltip'; - - setTimeout(() => { - const tooltip = document.getElementById(tooltipId) as any; - const elementTooltip = document.querySelector( - 'igc-tooltip:not([id])' - ) as any; - const elementButton = document.getElementById('elementButton'); - - // Set anchor for second tooltip dynamically - if (elementTooltip && elementButton) { - elementTooltip.anchor = elementButton; - elementTooltip.show(); - } - - document.querySelectorAll('.tooltip-trigger').forEach((btn) => { - btn.addEventListener('click', async () => { - tooltip.show(btn); - }); - }); - }); - return html`
Default Anchor - Transient 1 - Transient 2 - Element Anchor + Transient 1 + Transient 2 + Transient 3
- + This is a shared tooltip! - - `; }, }; From 3ed7642a6266c82c97ed72782384b2a2c33bb2ff Mon Sep 17 00:00:00 2001 From: didimmova Date: Wed, 16 Apr 2025 16:06:15 +0300 Subject: [PATCH 42/48] styles(tooltip): refactor styles --- src/components/tooltip/themes/shared/tooltip.common.scss | 1 + src/components/tooltip/themes/shared/tooltip.indigo.scss | 5 +++++ src/components/tooltip/themes/themes.ts | 8 +++++--- src/components/tooltip/themes/tooltip.base.scss | 3 ++- stories/tooltip.stories.ts | 2 +- 5 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 src/components/tooltip/themes/shared/tooltip.indigo.scss diff --git a/src/components/tooltip/themes/shared/tooltip.common.scss b/src/components/tooltip/themes/shared/tooltip.common.scss index b9afd3830..e39e2eabe 100644 --- a/src/components/tooltip/themes/shared/tooltip.common.scss +++ b/src/components/tooltip/themes/shared/tooltip.common.scss @@ -9,6 +9,7 @@ $color-border: rem(4px) solid var-get($theme, 'background'); background: var-get($theme, 'background'); color: var-get($theme, 'text-color'); border-radius: var-get($theme, 'border-radius'); + box-shadow: var-get($theme, 'elevation'); } #arrow { diff --git a/src/components/tooltip/themes/shared/tooltip.indigo.scss b/src/components/tooltip/themes/shared/tooltip.indigo.scss new file mode 100644 index 000000000..a8cb9cad7 --- /dev/null +++ b/src/components/tooltip/themes/shared/tooltip.indigo.scss @@ -0,0 +1,5 @@ +@use 'styles/utilities' as *; + +[part="base"] { + font-size: rem(12px); +} diff --git a/src/components/tooltip/themes/themes.ts b/src/components/tooltip/themes/themes.ts index 0d6def532..73e80b6df 100644 --- a/src/components/tooltip/themes/themes.ts +++ b/src/components/tooltip/themes/themes.ts @@ -1,8 +1,10 @@ import { css } from 'lit'; import type { Themes } from '../../../theming/types.js'; -import { styles as bootstrapDark } from './dark/tooltip.bootstrap.css.js'; +import { styles as indigo } from './shared/tooltip.indigo.css.js'; + // Dark Overrides +import { styles as bootstrapDark } from './dark/tooltip.bootstrap.css.js'; import { styles as fluentDark } from './dark/tooltip.fluent.css.js'; import { styles as indigoDark } from './dark/tooltip.indigo.css.js'; import { styles as materialDark } from './dark/tooltip.material.css.js'; @@ -27,7 +29,7 @@ const light = { ${fluentLight} `, indigo: css` - ${indigoLight} + ${indigo} ${indigoLight} `, }; @@ -45,7 +47,7 @@ const dark = { ${fluentDark} `, indigo: css` - ${indigoDark} + ${indigo} ${indigoDark} `, }; diff --git a/src/components/tooltip/themes/tooltip.base.scss b/src/components/tooltip/themes/tooltip.base.scss index edf11873c..2fdb9be61 100644 --- a/src/components/tooltip/themes/tooltip.base.scss +++ b/src/components/tooltip/themes/tooltip.base.scss @@ -11,9 +11,10 @@ [part="base"] { @include type-style('body-2'); - padding: rem(3px) rem(8px); + padding: rem(4px) rem(8px); font-size: rem(10px); font-weight: 600; + line-height: rem(16px); text-align: start; max-width: 200px; display: flex; diff --git a/stories/tooltip.stories.ts b/stories/tooltip.stories.ts index 035375a3f..4e6240d75 100644 --- a/stories/tooltip.stories.ts +++ b/stories/tooltip.stories.ts @@ -556,7 +556,7 @@ export const SharedTooltipMultipleAnchors: Story = { >
- + This is a shared tooltip! `; From de0120a8708aee687fa1816d49a67d6ea6d65eb2 Mon Sep 17 00:00:00 2001 From: didimmova Date: Wed, 16 Apr 2025 16:08:08 +0300 Subject: [PATCH 43/48] styles(tooltip): use subtitle-2 type style for indigo --- src/components/tooltip/themes/shared/tooltip.indigo.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/tooltip/themes/shared/tooltip.indigo.scss b/src/components/tooltip/themes/shared/tooltip.indigo.scss index a8cb9cad7..de01c7105 100644 --- a/src/components/tooltip/themes/shared/tooltip.indigo.scss +++ b/src/components/tooltip/themes/shared/tooltip.indigo.scss @@ -1,5 +1,5 @@ @use 'styles/utilities' as *; [part="base"] { - font-size: rem(12px); + @include type-style('subtitle-2'); } From e9bc714c69521661058e9bc6e2c852726db4c839 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Thu, 17 Apr 2025 01:39:43 +0300 Subject: [PATCH 44/48] refactor(tooltip): reset transient anchor to the default anchor value * Introduces a private `_defaultAnchor` variable to store the initial anchor. This value is set via `resolveAnchor`, which is triggered by `hostConnected` or when the host's `anchor` property changes. This ensures there is a reference to the host's current anchor element. * When the tooltip is closed and the current anchor is transient, the anchor is set back to `_defaultAnchor`. * Updates the test to check for proper anchor reset --- .../tooltip/tooltip-event-controller.ts | 26 +++++++++++++------ src/components/tooltip/tooltip.spec.ts | 20 ++++++++++---- stories/tooltip.stories.ts | 3 +++ 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/components/tooltip/tooltip-event-controller.ts b/src/components/tooltip/tooltip-event-controller.ts index 8b8127a7f..65593066f 100644 --- a/src/components/tooltip/tooltip-event-controller.ts +++ b/src/components/tooltip/tooltip-event-controller.ts @@ -16,6 +16,7 @@ class TooltipController implements ReactiveController { private _hideTriggers = new Set(['pointerleave', 'click']); private _anchor: TooltipAnchor; + private _defaultAnchor: TooltipAnchor; private _isTransientAnchor = false; private _open = false; @@ -36,13 +37,13 @@ class TooltipController implements ReactiveController { this._addTooltipListeners(); service.add(this._host, this._options.onEscape); } else { - this._removeTooltipListeners(); - service.remove(this._host); - if (this._isTransientAnchor) { - this._dispose(); this._isTransientAnchor = false; + this.setAnchor(this._defaultAnchor); } + + this._removeTooltipListeners(); + service.remove(this._host); } } @@ -172,6 +173,9 @@ class TooltipController implements ReactiveController { this._handleTooltipEvent(event); } else if (event.target === this._anchor) { this._handleAnchorEvent(event); + } else if (event.target === this._defaultAnchor) { + this.open = false; + this._handleAnchorEvent(event); } } @@ -191,16 +195,22 @@ class TooltipController implements ReactiveController { public setAnchor(value: TooltipAnchor, transient = false): void { if (this._anchor === value) return; - this._removeAnchorListeners(); + if (this._anchor !== this._defaultAnchor) { + this._removeAnchorListeners(); + } + this._anchor = value; this._isTransientAnchor = transient; this._addAnchorListeners(); } public resolveAnchor(value: Element | string | undefined): void { - this.setAnchor( - isString(value) ? getElementByIdFromRoot(this._host, value) : value - ); + const resolvedElement = isString(value) + ? getElementByIdFromRoot(this._host, value) + : value; + + this._defaultAnchor = resolvedElement; + this.setAnchor(resolvedElement); } //#endregion diff --git a/src/components/tooltip/tooltip.spec.ts b/src/components/tooltip/tooltip.spec.ts index 272b6f403..bb3f25d63 100644 --- a/src/components/tooltip/tooltip.spec.ts +++ b/src/components/tooltip/tooltip.spec.ts @@ -400,11 +400,16 @@ describe('Tooltip', () => { describe('Methods` Tests', () => { beforeEach(async () => { + clock = useFakeTimers({ toFake: ['setTimeout'] }); const container = await fixture(createTooltipWithTarget()); tooltip = container.querySelector(IgcTooltipComponent.tagName)!; anchor = container.querySelector('button')!; }); + afterEach(() => { + clock.restore(); + }); + it('calls `show` and `hide` methods successfully and returns proper values', async () => { expect(tooltip.open).to.be.false; @@ -470,13 +475,13 @@ describe('Tooltip', () => { tooltip.parentElement!.querySelectorAll('button') ); - const [firstAnchor, secondAnchor] = buttons; + const [defaultAnchor, transientAnchor] = buttons; - let result = await tooltip.show(firstAnchor); + let result = await tooltip.show(defaultAnchor); expect(result).to.be.true; expect(tooltip.open).to.be.true; - result = await tooltip.show(secondAnchor); + result = await tooltip.show(transientAnchor); expect(result).to.be.true; expect(tooltip.open).to.be.true; @@ -484,11 +489,16 @@ describe('Tooltip', () => { expect(result).to.be.true; expect(tooltip.open).to.be.false; - // The anchor was transient and the tooltip should not be shown on pointerenter - simulatePointerEnter(secondAnchor); + // the transient anchor should not reopen the tooltip once its hidden + simulatePointerEnter(transientAnchor); await clock.tickAsync(DEFAULT_SHOW_DELAY); await showComplete(); expect(tooltip.open).to.be.false; + + simulatePointerEnter(defaultAnchor); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(); + expect(tooltip.open).to.be.true; }); }); diff --git a/stories/tooltip.stories.ts b/stories/tooltip.stories.ts index 035375a3f..53c56a36a 100644 --- a/stories/tooltip.stories.ts +++ b/stories/tooltip.stories.ts @@ -554,6 +554,9 @@ export const SharedTooltipMultipleAnchors: Story = { id="transient3" >Transient 3 + Switch default anchor to be Transient 1
From 105d6e7d44641d575213824bbec7f9a5f8bfb17d Mon Sep 17 00:00:00 2001 From: didimmova Date: Thu, 17 Apr 2025 12:25:23 +0300 Subject: [PATCH 45/48] feat(tooltip): switch content alignment to be flex-start --- src/components/tooltip/themes/tooltip.base.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/tooltip/themes/tooltip.base.scss b/src/components/tooltip/themes/tooltip.base.scss index 2fdb9be61..05edfb62d 100644 --- a/src/components/tooltip/themes/tooltip.base.scss +++ b/src/components/tooltip/themes/tooltip.base.scss @@ -18,7 +18,7 @@ text-align: start; max-width: 200px; display: flex; - align-items: center; + align-items: flex-start; gap: rem(8px); position: relative; } From 7c027ffd1e891615bca431a08f1148572b6d5639 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 17 Apr 2025 14:00:36 +0300 Subject: [PATCH 46/48] refactor(tooltip): Anchor targets as weak refs * Anchor event listeners as wrapped weak references. * `show` method now accepts an IDREF in addition to the element reference. * Some code reorganization. * Included the tooltip component inside the `defineAllComponents` collection. --- .../common/definitions/defineAllComponents.ts | 2 + src/components/common/util.ts | 26 +++++ ...ltip-event-controller.ts => controller.ts} | 97 ++++++++++++------- .../{tooltip-service.ts => service.ts} | 4 +- src/components/tooltip/tooltip.spec.ts | 45 +++++++++ src/components/tooltip/tooltip.ts | 17 ++-- 6 files changed, 142 insertions(+), 49 deletions(-) rename src/components/tooltip/{tooltip-event-controller.ts => controller.ts} (70%) rename src/components/tooltip/{tooltip-service.ts => service.ts} (92%) diff --git a/src/components/common/definitions/defineAllComponents.ts b/src/components/common/definitions/defineAllComponents.ts index b51832d23..54a88bb73 100644 --- a/src/components/common/definitions/defineAllComponents.ts +++ b/src/components/common/definitions/defineAllComponents.ts @@ -63,6 +63,7 @@ import IgcTextareaComponent from '../../textarea/textarea.js'; import IgcTileManagerComponent from '../../tile-manager/tile-manager.js'; import IgcTileComponent from '../../tile-manager/tile.js'; import IgcToastComponent from '../../toast/toast.js'; +import IgcTooltipComponent from '../../tooltip/tooltip.js'; import IgcTreeItemComponent from '../../tree/tree-item.js'; import IgcTreeComponent from '../../tree/tree.js'; import { defineComponents } from './defineComponents.js'; @@ -136,6 +137,7 @@ const allComponents: IgniteComponent[] = [ IgcTextareaComponent, IgcTileComponent, IgcTileManagerComponent, + IgcTooltipComponent, ]; export function defineAllComponents() { diff --git a/src/components/common/util.ts b/src/components/common/util.ts index 3259cdbe9..17a0adb28 100644 --- a/src/components/common/util.ts +++ b/src/components/common/util.ts @@ -292,6 +292,32 @@ export function isString(value: unknown): value is string { return typeof value === 'string'; } +export function isObject(value: unknown): value is object { + return value != null && typeof value === 'object'; +} + +export function isEventListenerObject(x: unknown): x is EventListenerObject { + return isObject(x) && 'handleEvent' in x; +} + +export function addWeakEventListener( + element: Element, + event: string, + listener: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions | boolean +): void { + const weakRef = new WeakRef(listener); + const wrapped = (evt: Event) => { + const handler = weakRef.deref(); + + return isEventListenerObject(handler) + ? handler.handleEvent(evt) + : handler?.(evt); + }; + + element.addEventListener(event, wrapped, options); +} + /** * Returns whether a given collection is empty. */ diff --git a/src/components/tooltip/tooltip-event-controller.ts b/src/components/tooltip/controller.ts similarity index 70% rename from src/components/tooltip/tooltip-event-controller.ts rename to src/components/tooltip/controller.ts index 65593066f..a390a82f1 100644 --- a/src/components/tooltip/tooltip-event-controller.ts +++ b/src/components/tooltip/controller.ts @@ -1,11 +1,20 @@ import type { ReactiveController } from 'lit'; -import { getElementByIdFromRoot, isString } from '../common/util.js'; -import service from './tooltip-service.js'; +import { + addWeakEventListener, + getElementByIdFromRoot, + isString, +} from '../common/util.js'; +import service from './service.js'; import type IgcTooltipComponent from './tooltip.js'; class TooltipController implements ReactiveController { //#region Internal properties and state + private static readonly _listeners = [ + 'pointerenter', + 'pointerleave', + ] as const; + private readonly _host: IgcTooltipComponent; private readonly _options: TooltipCallbacks; @@ -15,9 +24,10 @@ class TooltipController implements ReactiveController { private _showTriggers = new Set(['pointerenter']); private _hideTriggers = new Set(['pointerleave', 'click']); - private _anchor: TooltipAnchor; - private _defaultAnchor: TooltipAnchor; - private _isTransientAnchor = false; + private _anchor: WeakRef | null = null; + private _initialAnchor: WeakRef | null = null; + + private _isTransient = false; private _open = false; //#endregion @@ -37,9 +47,9 @@ class TooltipController implements ReactiveController { this._addTooltipListeners(); service.add(this._host, this._options.onEscape); } else { - if (this._isTransientAnchor) { - this._isTransientAnchor = false; - this.setAnchor(this._defaultAnchor); + if (this._isTransient) { + this._isTransient = false; + this.setAnchor(this._initialAnchor?.deref()); } this._removeTooltipListeners(); @@ -51,7 +61,9 @@ class TooltipController implements ReactiveController { * Returns the current tooltip anchor target if any. */ public get anchor(): TooltipAnchor { - return this._anchor; + return this._isTransient + ? this._anchor?.deref() + : this._initialAnchor?.deref(); } /** @@ -105,17 +117,21 @@ class TooltipController implements ReactiveController { //#region Internal event listeners state private _addAnchorListeners(): void { - if (!this._anchor) return; + const anchor = this.anchor; + + if (!anchor) { + return; + } this._anchorAbortController = new AbortController(); const signal = this._anchorAbortController.signal; for (const each of this._showTriggers) { - this._anchor.addEventListener(each, this, { passive: true, signal }); + addWeakEventListener(anchor, each, this, { passive: true, signal }); } for (const each of this._hideTriggers) { - this._anchor.addEventListener(each, this, { passive: true, signal }); + addWeakEventListener(anchor, each, this, { passive: true, signal }); } } @@ -128,14 +144,9 @@ class TooltipController implements ReactiveController { this._hostAbortController = new AbortController(); const signal = this._hostAbortController.signal; - this._host.addEventListener('pointerenter', this, { - passive: true, - signal, - }); - this._host.addEventListener('pointerleave', this, { - passive: true, - signal, - }); + for (const event of TooltipController._listeners) { + this._host.addEventListener(event, this, { passive: true, signal }); + } } private _removeTooltipListeners(): void { @@ -147,23 +158,23 @@ class TooltipController implements ReactiveController { //#region Event handlers - private _handleTooltipEvent(event: Event): void { + private async _handleTooltipEvent(event: Event): Promise { switch (event.type) { case 'pointerenter': - this._options.onShow.call(this._host); + await this._options.onShow.call(this._host); break; case 'pointerleave': - this._options.onHide.call(this._host); + await this._options.onHide.call(this._host); } } - private _handleAnchorEvent(event: Event): void { + private async _handleAnchorEvent(event: Event): Promise { if (!this._open && this._showTriggers.has(event.type)) { - this._options.onShow.call(this._host); + await this._options.onShow.call(this._host); } if (this._open && this._hideTriggers.has(event.type)) { - this._options.onHide.call(this._host); + await this._options.onHide.call(this._host); } } @@ -171,9 +182,9 @@ class TooltipController implements ReactiveController { public handleEvent(event: Event): void { if (event.target === this._host) { this._handleTooltipEvent(event); - } else if (event.target === this._anchor) { + } else if (event.target === this._anchor?.deref()) { this._handleAnchorEvent(event); - } else if (event.target === this._defaultAnchor) { + } else if (event.target === this._initialAnchor?.deref()) { this.open = false; this._handleAnchorEvent(event); } @@ -183,7 +194,10 @@ class TooltipController implements ReactiveController { private _dispose(): void { this._removeAnchorListeners(); + this._removeTooltipListeners(); + service.remove(this._host); this._anchor = null; + this._initialAnchor = null; } //#region Public API @@ -192,24 +206,35 @@ class TooltipController implements ReactiveController { * Removes all triggers from the previous `anchor` target and rebinds the current * sets back to the new value if it exists. */ - public setAnchor(value: TooltipAnchor, transient = false): void { - if (this._anchor === value) return; + public setAnchor(value: TooltipAnchor | string, transient = false): void { + const newAnchor = isString(value) + ? getElementByIdFromRoot(this._host, value) + : value; + + if (this._anchor?.deref() === newAnchor) { + return; + } + + // Tooltip `show()` method called with a target. Set to hidden state. + if (transient && this._open) { + this.open = false; + } - if (this._anchor !== this._defaultAnchor) { + if (this._anchor?.deref() !== this._initialAnchor?.deref()) { this._removeAnchorListeners(); } - this._anchor = value; - this._isTransientAnchor = transient; + this._anchor = newAnchor ? new WeakRef(newAnchor) : null; + this._isTransient = transient; this._addAnchorListeners(); } - public resolveAnchor(value: Element | string | undefined): void { + public resolveAnchor(value: TooltipAnchor | string): void { const resolvedElement = isString(value) ? getElementByIdFromRoot(this._host, value) : value; - this._defaultAnchor = resolvedElement; + this._initialAnchor = resolvedElement ? new WeakRef(resolvedElement) : null; this.setAnchor(resolvedElement); } @@ -225,8 +250,6 @@ class TooltipController implements ReactiveController { /** @internal */ public hostDisconnected(): void { this._dispose(); - this._removeTooltipListeners(); - service.remove(this._host); } //#endregion diff --git a/src/components/tooltip/tooltip-service.ts b/src/components/tooltip/service.ts similarity index 92% rename from src/components/tooltip/tooltip-service.ts rename to src/components/tooltip/service.ts index fabae7714..70f5f7b0e 100644 --- a/src/components/tooltip/tooltip-service.ts +++ b/src/components/tooltip/service.ts @@ -43,13 +43,13 @@ class TooltipEscapeCallbacks { } /** @internal */ - public handleEvent(event: KeyboardEvent): void { + public async handleEvent(event: KeyboardEvent): Promise { if (event.key !== escapeKey) { return; } const [tooltip, callback] = last(Array.from(this._collection.entries())); - callback?.call(tooltip); + await callback?.call(tooltip); } } diff --git a/src/components/tooltip/tooltip.spec.ts b/src/components/tooltip/tooltip.spec.ts index bb3f25d63..d23ecf8a5 100644 --- a/src/components/tooltip/tooltip.spec.ts +++ b/src/components/tooltip/tooltip.spec.ts @@ -500,6 +500,51 @@ describe('Tooltip', () => { await showComplete(); expect(tooltip.open).to.be.true; }); + + it('should be able to pass and IDREF to `show` method', async () => { + const eventSpy = spy(tooltip, 'emitEvent'); + + const [_, transientAnchor] = Array.from( + tooltip.parentElement!.querySelectorAll('button') + ); + + transientAnchor.id = 'custom-target'; + + const result = await tooltip.show('custom-target'); + expect(result).to.be.true; + expect(tooltip.open).to.be.true; + expect(eventSpy.callCount).to.equal(0); + }); + + it('should correctly handle open state and events between default and transient anchors', async () => { + const eventSpy = spy(tooltip, 'emitEvent'); + + const [defaultAnchor, transientAnchor] = Array.from( + tooltip.parentElement!.querySelectorAll('button') + ); + + const result = await tooltip.show(transientAnchor); + expect(result).to.be.true; + expect(tooltip.open).to.be.true; + expect(eventSpy.callCount).to.equal(0); + + simulatePointerEnter(defaultAnchor); + // Trigger on the initial default anchor. Tooltip must be hidden. + expect(tooltip.open).to.be.false; + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(); + expect(tooltip.open).to.be.true; + + expect(eventSpy).calledWith('igcOpening', { + cancelable: true, + detail: defaultAnchor, + }); + + expect(eventSpy).calledWith('igcOpened', { + cancelable: false, + detail: defaultAnchor, + }); + }); }); describe('Behaviors', () => { diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index e8dfcb44e..6ef2dedab 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -15,10 +15,10 @@ import IgcIconComponent from '../icon/icon.js'; import IgcPopoverComponent, { type PopoverPlacement, } from '../popover/popover.js'; +import { addTooltipController } from './controller.js'; import { styles as shared } from './themes/shared/tooltip.common.css'; import { all } from './themes/themes.js'; import { styles } from './themes/tooltip.base.css.js'; -import { addTooltipController } from './tooltip-event-controller.js'; export interface IgcTooltipComponentEventMap { igcOpening: CustomEvent; @@ -258,7 +258,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< super(); this._internals = this.attachInternals(); - this._internals.role = this.sticky ? 'status' : 'tooltip'; + this._internals.role = 'tooltip'; this._internals.ariaAtomic = 'true'; this._internals.ariaLive = 'polite'; } @@ -273,7 +273,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< } @watch('anchor') - protected _onAnchorChange() { + protected _onAnchorChange(): void { this._controller.resolveAnchor(this.anchor); } @@ -328,6 +328,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< if (withDelay) { clearTimeout(this._timeoutId); + return new Promise(() => { this._timeoutId = setTimeout( async () => await commitStateChange(), @@ -343,13 +344,9 @@ export default class IgcTooltipComponent extends EventEmitterMixin< * Shows the tooltip if not already showing. * If a target is provided, sets it as a transient anchor. */ - public show(target?: Element): Promise { + public async show(target?: Element | string): Promise { if (target) { this._stopTimeoutAndAnimation(); - - if (this._controller.anchor !== target) { - this.open = false; - } this._controller.setAnchor(target, true); } @@ -357,12 +354,12 @@ export default class IgcTooltipComponent extends EventEmitterMixin< } /** Hides the tooltip if not already hidden. */ - public hide(): Promise { + public async hide(): Promise { return this._applyTooltipState({ show: false }); } /** Toggles the tooltip between shown/hidden state */ - public toggle(): Promise { + public async toggle(): Promise { return this.open ? this.hide() : this.show(); } From 717ea52f97b9da6ff84ae9562f48a97c323d52c5 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 22 Apr 2025 10:23:39 +0300 Subject: [PATCH 47/48] refactor: Minor code style changes and a CHANGELOG entry --- CHANGELOG.md | 3 ++- src/components/tooltip/controller.ts | 24 +++++++++++++++++++++++- src/components/tooltip/tooltip.ts | 20 ++++++++++---------- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c7014a6c..b76d362e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added -- New File Input Component(`igc-file-input`) +- File Input component - Exposed more public API type aliases for component property types like `ButtonVariant`, `PickerMode`, `StepperOrientation`, `HorizontalTransitionAnimation` (carousel and horizontal stepper) and more. +- Tooltip component ### Deprecated - Some event argument types have been renamed for consistency: diff --git a/src/components/tooltip/controller.ts b/src/components/tooltip/controller.ts index a390a82f1..71bbca99a 100644 --- a/src/components/tooltip/controller.ts +++ b/src/components/tooltip/controller.ts @@ -165,6 +165,9 @@ class TooltipController implements ReactiveController { break; case 'pointerleave': await this._options.onHide.call(this._host); + break; + default: + return; } } @@ -256,9 +259,28 @@ class TooltipController implements ReactiveController { } function parseTriggers(string: string): Set { - return new Set((string ?? '').split(/[,\s]+/).filter((s) => s.trim())); + return new Set( + (string ?? '').split(TooltipRegexes.triggers).filter((s) => s.trim()) + ); } +export const TooltipRegexes = Object.freeze({ + /** Used for parsing the strings passed in the tooltip `show/hide-trigger` properties. */ + triggers: /[,\s]+/, + + /** Matches horizontal `PopoverPlacement` start positions. */ + horizontalStart: /^(left|right)-start$/, + + /** Matches horizontal `PopoverPlacement` end positions. */ + horizontalEnd: /^(left|right)-end$/, + + /** Matches vertical `PopoverPlacement` start positions. */ + start: /start$/, + + /** Matches vertical `PopoverPlacement` end positions. */ + end: /end$/, +}); + export function addTooltipController( host: IgcTooltipComponent, options: TooltipCallbacks diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index 6ef2dedab..b8bfcd5a1 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -15,7 +15,7 @@ import IgcIconComponent from '../icon/icon.js'; import IgcPopoverComponent, { type PopoverPlacement, } from '../popover/popover.js'; -import { addTooltipController } from './controller.js'; +import { TooltipRegexes, addTooltipController } from './controller.js'; import { styles as shared } from './themes/shared/tooltip.common.css'; import { all } from './themes/themes.js'; import { styles } from './themes/tooltip.base.css.js'; @@ -91,24 +91,24 @@ export default class IgcTooltipComponent extends EventEmitterMixin< private _arrowElement!: HTMLElement; private get _arrowOffset() { - if (/-/.test(this.placement)) { + if (this.placement.includes('-')) { // Horizontal start | end placement - if (/^(left|right)-start$/.test(this.placement)) { + if (TooltipRegexes.horizontalStart.test(this.placement)) { return -8; } - if (/^(left|right)-end$/.test(this.placement)) { + if (TooltipRegexes.horizontalEnd.test(this.placement)) { return 8; } // Vertical start | end placement - if (/start$/.test(this.placement)) { + if (TooltipRegexes.start.test(this.placement)) { return isLTR(this) ? -8 : 8; } - if (/end$/.test(this.placement)) { + if (TooltipRegexes.end.test(this.placement)) { return isLTR(this) ? 8 : -8; } } @@ -350,17 +350,17 @@ export default class IgcTooltipComponent extends EventEmitterMixin< this._controller.setAnchor(target, true); } - return this._applyTooltipState({ show: true }); + return await this._applyTooltipState({ show: true }); } /** Hides the tooltip if not already hidden. */ public async hide(): Promise { - return this._applyTooltipState({ show: false }); + return await this._applyTooltipState({ show: false }); } /** Toggles the tooltip between shown/hidden state */ public async toggle(): Promise { - return this.open ? this.hide() : this.show(); + return await (this.open ? this.hide() : this.show()); } protected _showWithEvent(): Promise { @@ -393,7 +393,7 @@ export default class IgcTooltipComponent extends EventEmitterMixin< this._stopTimeoutAndAnimation(); this._timeoutId = setTimeout( - () => this._hideWithEvent(), + this._hideWithEvent.bind(this), this._autoHideDelay ); } From 854ceafc5fb6b797a952ad9952c358857d4946c3 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 23 Apr 2025 11:03:14 +0300 Subject: [PATCH 48/48] refactor: Dropped inline property --- src/components/tooltip/tooltip.spec.ts | 1 - src/components/tooltip/tooltip.ts | 17 ++++++----------- stories/tooltip.stories.ts | 25 +++++++++---------------- 3 files changed, 15 insertions(+), 28 deletions(-) diff --git a/src/components/tooltip/tooltip.spec.ts b/src/components/tooltip/tooltip.spec.ts index d23ecf8a5..f6fdfd2a5 100644 --- a/src/components/tooltip/tooltip.spec.ts +++ b/src/components/tooltip/tooltip.spec.ts @@ -76,7 +76,6 @@ describe('Tooltip', () => { expect(tooltip.dir).to.be.empty; expect(tooltip.open).to.be.false; expect(tooltip.disableArrow).to.be.false; - expect(tooltip.inline).to.be.false; expect(tooltip.offset).to.equal(6); expect(tooltip.placement).to.equal('top'); expect(tooltip.anchor).to.be.undefined; diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index b8bfcd5a1..efa01527f 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -34,11 +34,16 @@ type TooltipStateOptions = { }; /** + * Provides a way to display supplementary information related to an element when a user interacts with it (e.g., hover, focus). + * It offers features such as placement customization, delays, sticky mode, and animations. + * * @element igc-tooltip * - * @slot - default slot + * @slot - Default slot of the tooltip component. * @slot close-button - Slot for custom sticky-mode close action (e.g., an icon/button). * + * @csspart base - The wrapping container of the tooltip content. + * * @fires igcOpening - Emitted before the tooltip begins to open. Can be canceled to prevent opening. * @fires igcOpened - Emitted after the tooltip has successfully opened and is visible. * @fires igcClosing - Emitted before the tooltip begins to close. Can be canceled to prevent closing. @@ -140,15 +145,6 @@ export default class IgcTooltipComponent extends EventEmitterMixin< @property({ attribute: 'disable-arrow', type: Boolean, reflect: true }) public disableArrow = false; - /** - * Improves positioning for inline based elements, such as links. - * - * @attr inline - * @default false - */ - @property({ type: Boolean, reflect: true }) - public inline = false; - /** * The offset of the tooltip from the anchor in pixels. * @@ -420,7 +416,6 @@ export default class IgcTooltipComponent extends EventEmitterMixin< .arrowOffset=${this._arrowOffset} .shiftPadding=${8} ?open=${this.open} - ?inline=${this.inline} flip shift > diff --git a/stories/tooltip.stories.ts b/stories/tooltip.stories.ts index 6f4e6738b..757bcb931 100644 --- a/stories/tooltip.stories.ts +++ b/stories/tooltip.stories.ts @@ -28,7 +28,12 @@ const metadata: Meta = { title: 'Tooltip', component: 'igc-tooltip', parameters: { - docs: { description: { component: '' } }, + docs: { + description: { + component: + 'Provides a way to display supplementary information related to an element when a user interacts with it (e.g., hover, focus).\nIt offers features such as placement customization, delays, sticky mode, and animations.', + }, + }, actions: { handles: ['igcOpening', 'igcOpened', 'igcClosing', 'igcClosed'], }, @@ -47,13 +52,6 @@ const metadata: Meta = { control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, - inline: { - type: 'boolean', - description: - 'Improves positioning for inline based elements, such as links.', - control: 'boolean', - table: { defaultValue: { summary: 'false' } }, - }, offset: { type: 'number', description: 'The offset of the tooltip from the anchor in pixels.', @@ -133,7 +131,6 @@ const metadata: Meta = { args: { open: false, disableArrow: false, - inline: false, offset: 6, placement: 'top', showTriggers: 'pointerenter', @@ -152,8 +149,6 @@ interface IgcTooltipArgs { open: boolean; /** Whether to disable the rendering of the arrow indicator for the tooltip. */ disableArrow: boolean; - /** Improves positioning for inline based elements, such as links. */ - inline: boolean; /** The offset of the tooltip from the anchor in pixels. */ offset: number; /** Where to place the floating element relative to the parent anchor element. */ @@ -327,7 +322,7 @@ export const Inline: Story = {
- +

A style sheet language, or style language, is a computer language that expresses @@ -338,7 +333,7 @@ export const Inline: Story = {

- +

Hypertext Markup Language (HTML) is the standard markup language for @@ -511,10 +506,8 @@ export const Default: Story = { function createDynamicTooltip() { const tooltip = document.createElement('igc-tooltip'); - tooltip.message = `I'm created on demand at ${new Date().toLocaleTimeString()}`; + tooltip.message = `Created on demand at ${new Date().toLocaleTimeString()}`; tooltip.anchor = 'dynamic-target'; - tooltip.showTriggers = 'focus, click'; - tooltip.hideTriggers = 'blur'; tooltip.id = 'dynamic'; const previousTooltip = document.querySelector('#dynamic');