From 555a5a3bf18e8bfbbb7d14452974427f68e2cc67 Mon Sep 17 00:00:00 2001 From: Daniel Leroux Date: Wed, 17 Apr 2024 10:15:07 +0200 Subject: [PATCH] fix(core/tooltip): prevent focusin event call showTooltip (#1221) --- .changeset/short-bulldogs-repeat.md | 5 ++ .../src/components/tooltip/test/tooltip.ct.ts | 39 +++++++++ .../components/tooltip/tooltip-controller.ts | 14 +++ .../core/src/components/tooltip/tooltip.tsx | 85 +++++++++++++------ packages/core/src/components/utils/overlay.ts | 54 ++++++++++++ 5 files changed, 171 insertions(+), 26 deletions(-) create mode 100644 .changeset/short-bulldogs-repeat.md create mode 100644 packages/core/src/components/tooltip/tooltip-controller.ts create mode 100644 packages/core/src/components/utils/overlay.ts diff --git a/.changeset/short-bulldogs-repeat.md b/.changeset/short-bulldogs-repeat.md new file mode 100644 index 0000000000..9be44d0cfd --- /dev/null +++ b/.changeset/short-bulldogs-repeat.md @@ -0,0 +1,5 @@ +--- +"@siemens/ix": patch +--- + +fix(core/tooltip): prevent focusin event call showTooltip diff --git a/packages/core/src/components/tooltip/test/tooltip.ct.ts b/packages/core/src/components/tooltip/test/tooltip.ct.ts index 8d96d1d2af..dcf9ca02e1 100644 --- a/packages/core/src/components/tooltip/test/tooltip.ct.ts +++ b/packages/core/src/components/tooltip/test/tooltip.ct.ts @@ -67,3 +67,42 @@ test('hide tooltip after delay', async ({ mount, page }) => { await page.waitForTimeout(1000); await expect(tooltip).not.toBeVisible(); }); + +test('avoid double visibility request by focusin event', async ({ + mount, + page, +}) => { + await mount(` + + Item 1 + Item 2 + + `); + + const menuItem1 = page.locator('ix-menu-item:nth-child(1)'); + const menuItem2 = page.locator('ix-menu-item:nth-child(2)'); + + await menuItem1.hover(); + await page.waitForTimeout(5); + await menuItem1.click(); + await page.waitForTimeout(200); + await expect(menuItem1.locator('ix-tooltip')).toBeVisible(); + + await menuItem2.hover(); + await page.waitForTimeout(5); + await menuItem2.click(); + await page.waitForTimeout(200); + await expect(menuItem2.locator('ix-tooltip')).toBeVisible(); + + await menuItem1.hover(); + await page.waitForTimeout(5); + await menuItem1.click(); + await page.waitForTimeout(200); + await expect(menuItem1.locator('ix-tooltip')).toBeVisible(); + + await page.mouse.move(0, 0); + await page.waitForTimeout(200); + + await expect(menuItem1.locator('ix-tooltip')).not.toBeVisible(); + await expect(menuItem2.locator('ix-tooltip')).not.toBeVisible(); +}); diff --git a/packages/core/src/components/tooltip/tooltip-controller.ts b/packages/core/src/components/tooltip/tooltip-controller.ts new file mode 100644 index 0000000000..0258b6c4c5 --- /dev/null +++ b/packages/core/src/components/tooltip/tooltip-controller.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2024 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { OverlayController } from '../utils/overlay'; + +class TooltipController extends OverlayController {} + +export const tooltipController = new TooltipController(); diff --git a/packages/core/src/components/tooltip/tooltip.tsx b/packages/core/src/components/tooltip/tooltip.tsx index 280a5ad410..e6c41835c0 100644 --- a/packages/core/src/components/tooltip/tooltip.tsx +++ b/packages/core/src/components/tooltip/tooltip.tsx @@ -25,7 +25,8 @@ import { State, } from '@stencil/core'; import { OnListener } from '../utils/listener'; -import { IxComponent } from '../utils/internal'; +import { tooltipController } from './tooltip-controller'; +import { IxOverlayComponent } from '../utils/overlay'; type ArrowPosition = { top?: string; @@ -46,7 +47,7 @@ const numberToPixel = (value: number) => (value != null ? `${value}px` : ''); styleUrl: 'tooltip.scss', shadow: true, }) -export class Tooltip implements IxComponent { +export class Tooltip implements IxOverlayComponent { /** * CSS selector for hover trigger element e.g. `for="[data-my-custom-select]"` */ @@ -86,8 +87,6 @@ export class Tooltip implements IxComponent { private observer: MutationObserver; private hideTooltipTimeout: NodeJS.Timeout; private showTooltipTimeout: NodeJS.Timeout; - private onEnterElementBind = this.onTooltipShow.bind(this); - private onLeaveElementBind = this.onTooltipHide.bind(this); private disposeAutoUpdate?: () => void; private disposeListener: () => void; @@ -101,14 +100,6 @@ export class Tooltip implements IxComponent { } } - private onTooltipShow(e: Event) { - this.showTooltip(e.target as Element); - } - - private onTooltipHide() { - this.hideTooltip(); - } - /** @internal */ @Method() async showTooltip(anchorElement: any) { @@ -116,7 +107,7 @@ export class Tooltip implements IxComponent { await this.applyTooltipPosition(anchorElement); this.showTooltipTimeout = setTimeout(() => { - this.visible = true; + tooltipController.present(this); // Need to compute and apply tooltip position after initial render, // because arrow has no valid bounding rect before that this.applyTooltipPosition(anchorElement); @@ -127,10 +118,14 @@ export class Tooltip implements IxComponent { @Method() async hideTooltip() { clearTimeout(this.showTooltipTimeout); - const hideDelay = this.interactive ? 150 : this.hideDelay; + let hideDelay = 50; + + if (this.interactive && this.hideDelay === hideDelay) { + hideDelay = 150; + } this.hideTooltipTimeout = setTimeout(() => { - this.visible = false; + tooltipController.dismiss(this); }, hideDelay); this.destroyAutoUpdate(); } @@ -259,16 +254,36 @@ export class Tooltip implements IxComponent { } triggerElementList.forEach((element) => { - element.addEventListener('mouseenter', this.onEnterElementBind); - element.addEventListener('mouseleave', this.onLeaveElementBind); - element.addEventListener('focusin', this.onEnterElementBind); - element.addEventListener('focusout', this.onLeaveElementBind); + const onMouseEnter = () => { + this.showTooltip(element); + }; + + const onMouseLeave = () => { + this.hideTooltip(); + }; + + const onFocusIn = () => { + if (this.showTooltipTimeout !== undefined) { + clearTimeout(this.showTooltipTimeout); + } + + onMouseEnter(); + }; + + const onFocusOut = () => { + this.hideTooltip(); + }; + + element.addEventListener('mouseenter', onMouseEnter); + element.addEventListener('mouseleave', onMouseLeave); + element.addEventListener('focusin', onFocusIn); + element.addEventListener('focusout', onFocusOut); this.disposeListener = () => { - element.removeEventListener('mouseenter', this.onEnterElementBind); - element.removeEventListener('mouseleave', this.onLeaveElementBind); - element.removeEventListener('focusin', this.onEnterElementBind); - element.removeEventListener('focusout', this.onLeaveElementBind); + element.removeEventListener('mouseenter', onMouseEnter); + element.removeEventListener('mouseleave', onMouseLeave); + element.removeEventListener('focusin', onFocusIn); + element.removeEventListener('focusout', onFocusOut); }; }); } @@ -277,14 +292,15 @@ export class Tooltip implements IxComponent { const { hostElement } = this; hostElement.addEventListener('mouseenter', () => this.clearHideTimeout()); hostElement.addEventListener('focusin', () => this.clearHideTimeout()); - hostElement.addEventListener('mouseleave', () => this.onTooltipHide()); - hostElement.addEventListener('focusout', () => this.onTooltipHide()); + + hostElement.addEventListener('mouseleave', () => this.hideTooltip()); + hostElement.addEventListener('focusout', () => this.hideTooltip()); } @OnListener('keydown', (self) => self.visible) async onKeydown(event: KeyboardEvent) { if (event.code === 'Escape') { - this.onTooltipHide(); + this.hideTooltip(); } } @@ -307,9 +323,26 @@ export class Tooltip implements IxComponent { this.registerTooltipListener(); } + connectedCallback() { + tooltipController.connected(this); + } + disconnectedCallback() { this.observer?.disconnect(); this.destroyAutoUpdate(); + tooltipController.disconnected(this); + } + + isPresent(): boolean { + return this.visible; + } + + present(): void { + this.visible = true; + } + + dismiss(): void { + this.visible = false; } render() { diff --git a/packages/core/src/components/utils/overlay.ts b/packages/core/src/components/utils/overlay.ts new file mode 100644 index 0000000000..850a324b86 --- /dev/null +++ b/packages/core/src/components/utils/overlay.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2024 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import { IxComponent } from '../utils/internal'; + +export interface IxOverlayComponent extends IxComponent { + isPresent(): boolean; + + willPresent?(): boolean; + willDismiss?(): boolean; + + present(): void; + dismiss(): void; +} + +export class OverlayController { + overlays: Set = new Set(); + + connected(instance: IxOverlayComponent): void { + this.overlays.add(instance); + } + + disconnected(instance: IxOverlayComponent): void { + this.overlays.delete(instance); + } + + present(instance: IxOverlayComponent): void { + if (instance.willPresent && !instance.willPresent()) { + return; + } + this.dismissOthers(instance); + instance.present(); + } + + dismiss(instance: IxOverlayComponent): void { + if (instance.willDismiss && !instance.willDismiss()) { + return; + } + instance.dismiss(); + } + + private dismissOthers(instance: IxOverlayComponent): void { + this.overlays.forEach((overlay) => { + if (overlay !== instance) { + this.dismiss(overlay); + } + }); + } +}