diff --git a/.changeset/short-bulldogs-repeat.md b/.changeset/short-bulldogs-repeat.md
new file mode 100644
index 00000000000..9be44d0cfdb
--- /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 8d96d1d2af3..dcf9ca02e1c 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 00000000000..0258b6c4c50
--- /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 280a5ad4101..e6c41835c00 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 00000000000..850a324b866
--- /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);
+ }
+ });
+ }
+}