Skip to content

Commit

Permalink
fix(core/tooltip): prevent focusin event call showTooltip (#1221)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielleroux authored Apr 17, 2024
1 parent 846edc4 commit 555a5a3
Show file tree
Hide file tree
Showing 5 changed files with 171 additions and 26 deletions.
5 changes: 5 additions & 0 deletions .changeset/short-bulldogs-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@siemens/ix": patch
---

fix(core/tooltip): prevent focusin event call showTooltip
39 changes: 39 additions & 0 deletions packages/core/src/components/tooltip/test/tooltip.ct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
<ix-menu>
<ix-menu-item>Item 1</ix-menu-item>
<ix-menu-item>Item 2</ix-menu-item>
</ix-menu>
`);

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();
});
14 changes: 14 additions & 0 deletions packages/core/src/components/tooltip/tooltip-controller.ts
Original file line number Diff line number Diff line change
@@ -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();
85 changes: 59 additions & 26 deletions packages/core/src/components/tooltip/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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]"`
*/
Expand Down Expand Up @@ -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;

Expand All @@ -101,22 +100,14 @@ 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) {
clearTimeout(this.hideTooltipTimeout);
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);
Expand All @@ -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();
}
Expand Down Expand Up @@ -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);
};
});
}
Expand All @@ -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<Tooltip>('keydown', (self) => self.visible)
async onKeydown(event: KeyboardEvent) {
if (event.code === 'Escape') {
this.onTooltipHide();
this.hideTooltip();
}
}

Expand All @@ -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() {
Expand Down
54 changes: 54 additions & 0 deletions packages/core/src/components/utils/overlay.ts
Original file line number Diff line number Diff line change
@@ -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<IxOverlayComponent> = 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);
}
});
}
}

0 comments on commit 555a5a3

Please sign in to comment.