Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions packages/optimus-ui/src/tooltip/tooltip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,97 @@ describe('Tooltip', () => {
});
});

describe('Re-entrant Mouse Enter', () => {
let fixture: ComponentFixture<TestBasicTooltipComponent>;
let component: TestBasicTooltipComponent;
let tooltipDirective: Tooltip;
let inputElement: HTMLElement;

// Chrome dispatches a fresh mouseenter on the host when the element under the pointer is
// detached - for instance an overlay rendered inside the host closing on click. The pointer
// never moved, so no mouseleave precedes it. See issue #950.
const enter = () => inputElement.dispatchEvent(new MouseEvent('mouseenter'));
const leave = () => inputElement.dispatchEvent(new MouseEvent('mouseleave'));
const click = () => inputElement.dispatchEvent(new MouseEvent('click'));

beforeEach(() => {
fixture = TestBed.createComponent(TestBasicTooltipComponent);
component = fixture.componentInstance;
fixture.detectChanges();

const debugElement = fixture.debugElement.query(By.directive(Tooltip));
tooltipDirective = debugElement.injector.get(Tooltip);
inputElement = component.inputElement.nativeElement;
});

afterEach(() => {
tooltipDirective.deactivate();
});

it('should activate on the first mouse enter', () => {
spyOn(tooltipDirective, 'activate').and.callThrough();

enter();

expect(tooltipDirective.activate).toHaveBeenCalledTimes(1);
expect(document.querySelector('.p-tooltip')).toBeTruthy();
});

it('should ignore a mouse enter that is not preceded by a mouse leave', () => {
enter();
tooltipDirective.deactivate();

spyOn(tooltipDirective, 'activate').and.callThrough();
enter();

expect(tooltipDirective.activate).not.toHaveBeenCalled();
});

it('should activate again once the pointer has really left', () => {
spyOn(tooltipDirective, 'activate').and.callThrough();

enter();
leave();
enter();

expect(tooltipDirective.activate).toHaveBeenCalledTimes(2);
});

it('should stay hidden when a click is followed by a re-entrant mouse enter', () => {
enter();
expect(document.querySelector('.p-tooltip')).toBeTruthy();

// clicking the host dismisses the tooltip, then the detached-element enter arrives
click();
expect(document.querySelector('.p-tooltip')).toBeFalsy();

enter();

expect(document.querySelector('.p-tooltip')).toBeFalsy();
});

it('should show again after the pointer leaves and returns following a click', () => {
enter();
click();
enter();
expect(document.querySelector('.p-tooltip')).toBeFalsy();

leave();
enter();

expect(document.querySelector('.p-tooltip')).toBeTruthy();
});

it('should reset the pointer state when the events are unbound', () => {
enter();
expect(tooltipDirective.pointerInside).toBeTrue();

tooltipDirective.unbindEvents();

expect(tooltipDirective.pointerInside).toBeFalse();
});
});

describe('Positioning', () => {
let fixture: ComponentFixture<TestBasicTooltipComponent>;
let component: TestBasicTooltipComponent;
Expand Down
16 changes: 16 additions & 0 deletions packages/optimus-ui/src/tooltip/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ export class Tooltip extends BaseComponent<TooltipPassThroughOptions> {

interactionInProgress = false;

pointerInside = false;

/**
* Used to pass attributes to DOM elements inside the Tooltip component.
* @defaultValue undefined
Expand Down Expand Up @@ -375,12 +377,24 @@ export class Tooltip extends BaseComponent<TooltipPassThroughOptions> {
}

onMouseEnter(e: Event) {
// Chrome re-dispatches mouseenter on the host when the element the pointer sits on is
// detached (e.g. an overlay rendered inside the host closing on click), without the
// pointer ever leaving. An enter without a preceding leave is not a new hover, so
// ignore it - otherwise a tooltip dismissed by that very click comes straight back.
if (this.pointerInside) {
return;
}

this.pointerInside = true;

if (!this.container && !this.showTimeout) {
this.activate();
}
}

onMouseLeave(e: MouseEvent) {
this.pointerInside = false;

if (!this.isAutoHide()) {
const valid = hasClass(e.relatedTarget as any, 'p-tooltip') || hasClass(e.relatedTarget as any, 'p-tooltip-text') || hasClass(e.relatedTarget as any, 'p-tooltip-arrow');
!valid && this.deactivate();
Expand Down Expand Up @@ -770,6 +784,8 @@ export class Tooltip extends BaseComponent<TooltipPassThroughOptions> {
unbindEvents() {
const tooltipEvent = this.getOption('tooltipEvent');

this.pointerInside = false;

if (tooltipEvent === 'hover' || tooltipEvent === 'both') {
this.el.nativeElement.removeEventListener('mouseenter', this.mouseEnterListener);
this.el.nativeElement.removeEventListener('mouseleave', this.mouseLeaveListener);
Expand Down