This is an automated email from the ASF dual-hosted git repository.
mcgilman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 643a8246e0c NIFI-16172 - Fix: Multiple nifiTooltip hover tooltips can
remain open at once and can get stuck open indefinitely (#11511)
643a8246e0c is described below
commit 643a8246e0c2f5845202aa95edc5abac1d85c2a4
Author: Rob Fellows <[email protected]>
AuthorDate: Thu Aug 6 14:03:56 2026 -0400
NIFI-16172 - Fix: Multiple nifiTooltip hover tooltips can remain open at
once and can get stuck open indefinitely (#11511)
---
.../connector-table/connector-table.component.html | 2 +-
.../manage-remote-ports.component.html | 4 +-
.../flow-analysis-rule-table.component.html | 4 +-
.../parameter-providers-table.component.html | 2 +-
.../reporting-task-table.component.html | 4 +-
.../controller-service-references.component.html | 2 +-
.../controller-service-table.component.html | 4 +-
.../src/directives/nifi-tooltip.directive.spec.ts | 276 ++++++++++++++++++++-
.../src/directives/nifi-tooltip.directive.ts | 154 +++++++++---
9 files changed, 395 insertions(+), 57 deletions(-)
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-table/connector-table.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-table/connector-table.component.html
index 91d2e95578e..bb9943817f2 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-table/connector-table.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-table/connector-table.component.html
@@ -35,7 +35,7 @@
<div
class="pointer fa fa-warning has-errors
caution-color"
nifiTooltip
- [delayClose]="false"
+ [delayClose]="true"
[tooltipComponentType]="ValidationErrorsTip"
[tooltipInputData]="getValidationErrorsTipData(item)"
data-qa="connector-validation-errors"></div>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/manage-remote-ports/manage-remote-ports.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/manage-remote-ports/manage-remote-ports.component.html
index 1fcc391cc3b..1880569fa98 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/manage-remote-ports/manage-remote-ports.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/manage-remote-ports/manage-remote-ports.component.html
@@ -70,7 +70,7 @@
<div
class="pointer fa
fa-comment primary-color"
nifiTooltip
- [delayClose]="false"
+ [delayClose]="true"
[tooltipComponentType]="TextTip"
[tooltipInputData]="item.comments"></div>
}
@@ -78,7 +78,7 @@
<div
class="pointer fa
fa-warning invalid caution-color"
nifiTooltip
- [delayClose]="false"
+ [delayClose]="true"
[tooltipComponentType]="TextTip"
tooltipInputData="This
port has been removed."></div>
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/ui/flow-analysis-rules/flow-analysis-rule-table/flow-analysis-rule-table.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/ui/flow-analysis-rules/flow-analysis-rule-table/flow-analysis-rule-table.component.html
index 2e5752e25c5..56137158a93 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/ui/flow-analysis-rules/flow-analysis-rule-table/flow-analysis-rule-table.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/ui/flow-analysis-rules/flow-analysis-rule-table/flow-analysis-rule-table.component.html
@@ -34,7 +34,7 @@
@if (hasComments(item)) {
<div
class="pointer fa fa-comment primary-color"
- [delayClose]="false"
+ [delayClose]="true"
nifiTooltip
[tooltipComponentType]="TextTip"
[tooltipInputData]="item.component.comments"></div>
@@ -42,7 +42,7 @@
@if (hasErrors(item)) {
<div
class="pointer fa fa-warning has-errors
caution-color"
- [delayClose]="false"
+ [delayClose]="true"
nifiTooltip
[tooltipComponentType]="ValidationErrorsTip"
[tooltipInputData]="getValidationErrorsTipData(item)"></div>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/ui/parameter-providers/parameter-providers-table/parameter-providers-table.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/ui/parameter-providers/parameter-providers-table/parameter-providers-table.component.html
index 2c126ab459a..f0581fa24f9 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/ui/parameter-providers/parameter-providers-table/parameter-providers-table.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/ui/parameter-providers/parameter-providers-table/parameter-providers-table.component.html
@@ -37,7 +37,7 @@
<div
class="pointer fa fa-warning
has-errors caution-color"
nifiTooltip
- [delayClose]="false"
+ [delayClose]="true"
[tooltipComponentType]="ValidationErrorsTip"
[tooltipInputData]="getValidationErrorsTipData(item)"></div>
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/ui/reporting-tasks/reporting-task-table/reporting-task-table.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/ui/reporting-tasks/reporting-task-table/reporting-task-table.component.html
index ae1e3efbd81..68f5745150f 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/ui/reporting-tasks/reporting-task-table/reporting-task-table.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/ui/reporting-tasks/reporting-task-table/reporting-task-table.component.html
@@ -34,7 +34,7 @@
@if (hasComments(item)) {
<div
class="pointer fa fa-comment primary-color"
- [delayClose]="false"
+ [delayClose]="true"
nifiTooltip
[tooltipComponentType]="TextTip"
[tooltipInputData]="item.component.comments"></div>
@@ -42,7 +42,7 @@
@if (hasErrors(item)) {
<div
class="pointer fa fa-warning has-errors
caution-color"
- [delayClose]="false"
+ [delayClose]="true"
nifiTooltip
[tooltipComponentType]="ValidationErrorsTip"
[tooltipInputData]="getValidationErrorsTipData(item)"></div>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/controller-service-references/controller-service-references.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/controller-service-references/controller-service-references.component.html
index e3456140223..73be72a47c0 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/controller-service-references/controller-service-references.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/controller-service-references/controller-service-references.component.html
@@ -94,7 +94,7 @@
nifiTooltip
[tooltipComponentType]="ValidationErrorsTip"
[tooltipInputData]="getValidationErrorTipData(reference.component)"
- [delayClose]="false"></div>
+ [delayClose]="true"></div>
} @else {
<div
[ngClass]="getNonServiceStateIcon(reference.component)"></div>
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/controller-service-table/controller-service-table.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/controller-service-table/controller-service-table.component.html
index 3cc6cdcd153..77ff223bdef 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/controller-service-table/controller-service-table.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/controller-service-table/controller-service-table.component.html
@@ -35,7 +35,7 @@
<div
class="pointer fa fa-comment primary-color"
nifiTooltip
- [delayClose]="false"
+ [delayClose]="true"
[tooltipComponentType]="TextTip"
[tooltipInputData]="item.component.comments"></div>
}
@@ -43,7 +43,7 @@
<div
class="pointer fa fa-warning has-errors
caution-color"
nifiTooltip
- [delayClose]="false"
+ [delayClose]="true"
[tooltipComponentType]="ValidationErrorsTip"
[tooltipInputData]="getValidationErrorsTipData(item)"></div>
}
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/directives/nifi-tooltip.directive.spec.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/directives/nifi-tooltip.directive.spec.ts
index d85b6b1afc3..cb17ab7c2b2 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/directives/nifi-tooltip.directive.spec.ts
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/directives/nifi-tooltip.directive.spec.ts
@@ -15,10 +15,276 @@
* limitations under the License.
*/
-describe('TooltipDirective', () => {
- it('should create an instance', () => {
- // TODO - need to provide ViewContainerRef
- // const directive = new NifiTooltipDirective();
- // expect(directive).toBeTruthy();
+import { Component } from '@angular/core';
+import { ComponentFixture, TestBed, fakeAsync, tick } from
'@angular/core/testing';
+import { Overlay } from '@angular/cdk/overlay';
+import { Subject } from 'rxjs';
+import { NifiTooltipDirective } from './nifi-tooltip.directive';
+import { NiFiCommon } from '../services/nifi-common.service';
+
+@Component({
+ selector: 'test-tip',
+ standalone: true,
+ template: '<div class="tooltip">tip</div>'
+})
+class TestTipComponent {
+ data: unknown;
+}
+
+interface MockOverlayRef {
+ overlayElement: HTMLElement;
+ attach: ReturnType<typeof vi.fn>;
+ detach: ReturnType<typeof vi.fn>;
+ dispose: ReturnType<typeof vi.fn>;
+ hasAttached: ReturnType<typeof vi.fn>;
+ detachments: ReturnType<typeof vi.fn>;
+}
+
+@Component({
+ standalone: true,
+ imports: [NifiTooltipDirective],
+ template: `
+ <div id="a" nifiTooltip [tooltipComponentType]="tip"
[delayOpen]="false" [delayClose]="true"></div>
+ <div id="b" nifiTooltip [tooltipComponentType]="tip"
[delayOpen]="false" [delayClose]="true"></div>
+ `
+})
+class HostComponent {
+ tip = TestTipComponent;
+}
+
+@Component({
+ standalone: true,
+ imports: [NifiTooltipDirective],
+ template: `<div id="c" nifiTooltip [tooltipComponentType]="tip"
[delayOpen]="true"></div>`
+})
+class DelayedHostComponent {
+ tip = TestTipComponent;
+}
+
+interface SetupResult {
+ fixture: ComponentFixture<HostComponent>;
+ elA: HTMLElement;
+ elB: HTMLElement;
+ overlayRefs: MockOverlayRef[];
+}
+
+interface DelayedSetupResult {
+ fixture: ComponentFixture<DelayedHostComponent>;
+ elC: HTMLElement;
+ overlayRefs: MockOverlayRef[];
+}
+
+function createMockOverlayRef(): MockOverlayRef {
+ const detachments = new Subject<void>();
+ let attached = false;
+
+ const overlayElement = document.createElement('div');
+ vi.spyOn(overlayElement, 'matches').mockReturnValue(false);
+
+ return {
+ overlayElement,
+ attach: vi.fn(() => {
+ attached = true;
+ return { setInput: vi.fn(), location: { nativeElement:
document.createElement('div') } };
+ }),
+ detach: vi.fn(() => {
+ if (attached) {
+ attached = false;
+ detachments.next();
+ }
+ }),
+ dispose: vi.fn(() => detachments.complete()),
+ hasAttached: vi.fn(() => attached),
+ detachments: vi.fn(() => detachments.asObservable())
+ };
+}
+
+function createMockOverlay(overlayRefs: MockOverlayRef[]) {
+ const positionStrategy = {
+ flexibleConnectedTo: vi.fn().mockReturnThis(),
+ withPositions: vi.fn().mockReturnThis(),
+ withPush: vi.fn().mockReturnValue({ detach: vi.fn(), dispose: vi.fn()
})
+ };
+
+ return {
+ create: vi.fn(() => {
+ const ref = createMockOverlayRef();
+ overlayRefs.push(ref);
+ return ref;
+ }),
+ position: vi.fn(() => positionStrategy)
+ };
+}
+
+async function setup(): Promise<SetupResult> {
+ const overlayRefs: MockOverlayRef[] = [];
+ const mockOverlay = createMockOverlay(overlayRefs);
+
+ await TestBed.configureTestingModule({
+ imports: [HostComponent],
+ providers: [{ provide: Overlay, useValue: mockOverlay }]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(HostComponent);
+ fixture.detectChanges();
+
+ const elA = fixture.nativeElement.querySelector('#a') as HTMLElement;
+ const elB = fixture.nativeElement.querySelector('#b') as HTMLElement;
+
+ return { fixture, elA, elB, overlayRefs };
+}
+
+async function setupDelayed(): Promise<DelayedSetupResult> {
+ const overlayRefs: MockOverlayRef[] = [];
+ const mockOverlay = createMockOverlay(overlayRefs);
+
+ await TestBed.configureTestingModule({
+ imports: [DelayedHostComponent],
+ providers: [{ provide: Overlay, useValue: mockOverlay }]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(DelayedHostComponent);
+ fixture.detectChanges();
+
+ const elC = fixture.nativeElement.querySelector('#c') as HTMLElement;
+
+ return { fixture, elC, overlayRefs };
+}
+
+describe('NifiTooltipDirective', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
});
+
+ afterEach(() => {
+ // Reset the shared single-open mutex so the invariant can't leak
across tests.
+ (NifiTooltipDirective as unknown as { openInstance: unknown
}).openInstance = null;
+ });
+
+ it('attaches a tooltip overlay on hover', () => {
+ return setup().then(({ elA, overlayRefs }) => {
+ elA.dispatchEvent(new MouseEvent('mouseenter'));
+
+ expect(overlayRefs).toHaveLength(1);
+ expect(overlayRefs[0].attach).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('closes the previously open tooltip when a second tooltip opens', () =>
{
+ return setup().then(({ elA, elB, overlayRefs }) => {
+ elA.dispatchEvent(new MouseEvent('mouseenter'));
+ elB.dispatchEvent(new MouseEvent('mouseenter'));
+
+ expect(overlayRefs).toHaveLength(2);
+ expect(overlayRefs[0].detach).toHaveBeenCalled();
+ expect(overlayRefs[1].attach).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('closes a tooltip the pointer had entered when another tooltip opens',
() => {
+ return setup().then(({ elA, elB, overlayRefs }) => {
+ elA.dispatchEvent(new MouseEvent('mouseenter'));
+
+ // move the pointer onto the first tooltip; in the past this
pinned it open
+ overlayRefs[0].overlayElement.dispatchEvent(new
MouseEvent('mouseenter'));
+
+ // opening a second tooltip must still close the first
+ elB.dispatchEvent(new MouseEvent('mouseenter'));
+
+ expect(overlayRefs[0].detach).toHaveBeenCalled();
+ });
+ });
+
+ it('detaches after the close delay when the pointer is not over the
tooltip', fakeAsync(async () => {
+ const { elA, overlayRefs } = await setup();
+
+ vi.spyOn(elA, 'matches').mockReturnValue(false);
+
+ elA.dispatchEvent(new MouseEvent('mouseenter'));
+ elA.dispatchEvent(new MouseEvent('mouseleave'));
+
+ expect(overlayRefs[0].detach).not.toHaveBeenCalled();
+
+ tick(NiFiCommon.TOOLTIP_DELAY_CLOSE_MILLIS);
+
+ expect(overlayRefs[0].detach).toHaveBeenCalled();
+ }));
+
+ it('keeps the tooltip open while the pointer is over it, then closes on
tooltip mouseleave', fakeAsync(async () => {
+ const { elA, overlayRefs } = await setup();
+
+ vi.spyOn(elA, 'matches').mockReturnValue(false);
+
+ elA.dispatchEvent(new MouseEvent('mouseenter'));
+ elA.dispatchEvent(new MouseEvent('mouseleave'));
+
+ // the pointer is over the tooltip overlay when the close watchdog
fires
+ vi.mocked(overlayRefs[0].overlayElement.matches).mockReturnValue(true);
+ tick(NiFiCommon.TOOLTIP_DELAY_CLOSE_MILLIS + 50);
+
+ expect(overlayRefs[0].detach).not.toHaveBeenCalled();
+
+ // leaving the tooltip overlay closes it
+ overlayRefs[0].overlayElement.dispatchEvent(new
MouseEvent('mouseleave'));
+
+ expect(overlayRefs[0].detach).toHaveBeenCalled();
+ }));
+
+ it('self-heals and closes when the tooltip mouseleave is dropped after
bridging onto it', fakeAsync(async () => {
+ const { elA, overlayRefs } = await setup();
+
+ vi.spyOn(elA, 'matches').mockReturnValue(false);
+
+ elA.dispatchEvent(new MouseEvent('mouseenter'));
+ elA.dispatchEvent(new MouseEvent('mouseleave'));
+
+ // pointer bridges onto the tooltip; the watchdog reschedules rather
than closing
+ vi.mocked(overlayRefs[0].overlayElement.matches).mockReturnValue(true);
+ tick(NiFiCommon.TOOLTIP_DELAY_CLOSE_MILLIS);
+
+ expect(overlayRefs[0].detach).not.toHaveBeenCalled();
+
+ // the pointer leaves the tooltip but its mouseleave is never
delivered; the
+ // next watchdog tick must still detach it
+
vi.mocked(overlayRefs[0].overlayElement.matches).mockReturnValue(false);
+ tick(NiFiCommon.TOOLTIP_DELAY_CLOSE_MILLIS);
+
+ expect(overlayRefs[0].detach).toHaveBeenCalled();
+ }));
+
+ it('cancels a pending open when the pointer leaves before the tooltip
attaches', fakeAsync(async () => {
+ const { elC, overlayRefs } = await setupDelayed();
+
+ elC.dispatchEvent(new MouseEvent('mouseenter'));
+ elC.dispatchEvent(new MouseEvent('mouseleave'));
+
+ tick(NiFiCommon.TOOLTIP_DELAY_OPEN_MILLIS + 50);
+
+ expect(overlayRefs).toHaveLength(0);
+ }));
+
+ it('does not open after the delay when the pointer is no longer over the
trigger', fakeAsync(async () => {
+ const { elC, overlayRefs } = await setupDelayed();
+
+ // simulate a quick pass-through whose mouseleave was dropped: the
open timer
+ // still fires, but the pointer is no longer over the trigger
+ vi.spyOn(elC, 'matches').mockReturnValue(false);
+
+ elC.dispatchEvent(new MouseEvent('mouseenter'));
+ tick(NiFiCommon.TOOLTIP_DELAY_OPEN_MILLIS + 50);
+
+ expect(overlayRefs).toHaveLength(0);
+ }));
+
+ it('opens after the delay when the pointer remains over the trigger',
fakeAsync(async () => {
+ const { elC, overlayRefs } = await setupDelayed();
+
+ vi.spyOn(elC, 'matches').mockReturnValue(true);
+
+ elC.dispatchEvent(new MouseEvent('mouseenter'));
+ tick(NiFiCommon.TOOLTIP_DELAY_OPEN_MILLIS + 50);
+
+ expect(overlayRefs).toHaveLength(1);
+ expect(overlayRefs[0].attach).toHaveBeenCalledTimes(1);
+ }));
});
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/directives/nifi-tooltip.directive.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/directives/nifi-tooltip.directive.ts
index e0df8975fcd..fb9257fc1a9 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/directives/nifi-tooltip.directive.ts
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/directives/nifi-tooltip.directive.ts
@@ -18,6 +18,7 @@
import { Directive, ElementRef, HostListener, Input, OnDestroy, Type, inject }
from '@angular/core';
import { ConnectedPosition, Overlay, OverlayRef, PositionStrategy } from
'@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
+import { Subscription } from 'rxjs';
import { NiFiCommon } from '../services/nifi-common.service';
@Directive({
@@ -25,6 +26,14 @@ import { NiFiCommon } from '../services/nifi-common.service';
standalone: true
})
export class NifiTooltipDirective<T> implements OnDestroy {
+ /**
+ * Only one tooltip should ever be visible at a time. Tracking the
currently
+ * attached instance lets a newly opened tooltip close any previous one,
which
+ * prevents overlapping overlays from stealing each other's mouse events
and
+ * getting stuck open.
+ */
+ private static openInstance: NifiTooltipDirective<unknown> | null = null;
+
private element = inject<ElementRef<HTMLElement>>(ElementRef);
private overlay = inject(Overlay);
@@ -38,17 +47,21 @@ export class NifiTooltipDirective<T> implements OnDestroy {
private closeTimer = -1;
private overlayRef: OverlayRef | null = null;
private positionStrategy: PositionStrategy | null = null;
- private overTip = false;
private openTimer = -1;
+ private detachmentsSubscription: Subscription | null = null;
@HostListener('mouseenter')
mouseEnter() {
if (this.delayOpen) {
this.openTimer = window.setTimeout(() => {
- if (!this.overlayRef?.hasAttached()) {
+ this.openTimer = -1;
+
+ // Only open if the pointer is genuinely still over the
trigger. A quick
+ // pass-through whose mouseleave was dropped or coalesced must
not open the
+ // tooltip once the delay elapses.
+ if (this.isPointerOverTrigger() &&
!this.overlayRef?.hasAttached()) {
this.attach();
}
- this.openTimer = -1;
}, NiFiCommon.TOOLTIP_DELAY_OPEN_MILLIS);
} else {
if (!this.overlayRef?.hasAttached()) {
@@ -60,11 +73,7 @@ export class NifiTooltipDirective<T> implements OnDestroy {
@HostListener('mousemove')
mouseMove() {
if (this.overlayRef?.hasAttached() && this.tooltipDisabled) {
- this.overlayRef?.detach();
-
- if (this.positionStrategy?.detach) {
- this.positionStrategy.detach();
- }
+ this.detachTip();
}
}
@@ -86,33 +95,63 @@ export class NifiTooltipDirective<T> implements OnDestroy {
}
private closeTip(): void {
- if (this.overlayRef?.hasAttached() && !this.overTip) {
- if (this.delayClose) {
- this.closeTimer = window.setTimeout(() => {
- this.overlayRef?.detach();
+ // cancel any pending open so a quick hover in-and-out never opens the
tooltip
+ if (this.openTimer > 0) {
+ window.clearTimeout(this.openTimer);
+ this.openTimer = -1;
+ }
- if (this.positionStrategy?.detach) {
- this.positionStrategy.detach();
- }
+ if (!this.overlayRef?.hasAttached()) {
+ return;
+ }
- this.closeTimer = -1;
- }, NiFiCommon.TOOLTIP_DELAY_CLOSE_MILLIS);
- } else {
- this.overlayRef?.detach();
+ if (this.delayClose) {
+ this.scheduleClose();
+ } else {
+ this.detachTip();
+ }
+ }
- if (this.positionStrategy?.detach) {
- this.positionStrategy.detach();
- }
- }
+ /**
+ * Schedule a close check. When the timer fires, close only if the pointer
is over
+ * neither the trigger nor the tooltip; otherwise reschedule. Re-checking
against
+ * the live `:hover` state (rather than cancelling the timer when the
pointer
+ * bridges onto the tooltip) keeps the "move onto the tooltip to scroll it"
+ * behavior working while guaranteeing that a dropped or coalesced
mouseleave on
+ * either element can never leave the tooltip stuck open.
+ */
+ private scheduleClose(): void {
+ if (this.closeTimer > 0) {
+ window.clearTimeout(this.closeTimer);
}
+ this.closeTimer = window.setTimeout(() => {
+ this.closeTimer = -1;
+
+ if (this.isPointerOverTrigger() || this.isPointerOverTip()) {
+ this.scheduleClose();
+ } else {
+ this.detachTip();
+ }
+ }, NiFiCommon.TOOLTIP_DELAY_CLOSE_MILLIS);
+ }
+
+ ngOnDestroy(): void {
if (this.openTimer > 0) {
window.clearTimeout(this.openTimer);
this.openTimer = -1;
}
- }
- ngOnDestroy(): void {
+ if (this.closeTimer > 0) {
+ window.clearTimeout(this.closeTimer);
+ this.closeTimer = -1;
+ }
+
+ if (NifiTooltipDirective.openInstance === (this as
NifiTooltipDirective<unknown>)) {
+ NifiTooltipDirective.openInstance = null;
+ }
+
+ this.detachmentsSubscription?.unsubscribe();
this.overlayRef?.dispose();
this.positionStrategy?.dispose();
}
@@ -122,33 +161,66 @@ export class NifiTooltipDirective<T> implements OnDestroy
{
return;
}
+ // enforce a single visible tooltip across the application
+ const currentlyOpen = NifiTooltipDirective.openInstance;
+ if (currentlyOpen && currentlyOpen !== (this as
NifiTooltipDirective<unknown>)) {
+ currentlyOpen.detachTip();
+ }
+
if (!this.overlayRef) {
this.positionStrategy = this.getPositionStrategy();
this.overlayRef = this.overlay.create({ positionStrategy:
this.positionStrategy });
+
+ // Reset transient state whenever the overlay detaches for any
reason so
+ // a missed overlay mouseleave can't leave the instance in a state
that
+ // permanently blocks closing.
+ this.detachmentsSubscription =
this.overlayRef.detachments().subscribe(() => {
+ if (this.closeTimer > 0) {
+ window.clearTimeout(this.closeTimer);
+ this.closeTimer = -1;
+ }
+
+ if (NifiTooltipDirective.openInstance === (this as
NifiTooltipDirective<unknown>)) {
+ NifiTooltipDirective.openInstance = null;
+ }
+ });
+
+ // Leaving the tooltip closes it immediately. This is only a fast
path;
+ // the scheduleClose() watchdog is the authoritative closer and
self-heals
+ // if this mouseleave is ever dropped or coalesced by the browser.
+ this.overlayRef.overlayElement.addEventListener('mouseleave', ()
=> {
+ this.detachTip();
+ });
}
const tooltipReference = this.overlayRef.attach(new
ComponentPortal(this.tooltipComponentType));
tooltipReference.setInput('data', this.tooltipInputData);
- // register mouse events
- tooltipReference.location.nativeElement.addEventListener('mouseenter',
() => {
- if (this.closeTimer > 0) {
- window.clearTimeout(this.closeTimer);
- this.closeTimer = -1;
- }
+ NifiTooltipDirective.openInstance = this as
NifiTooltipDirective<unknown>;
+ }
- this.overTip = true;
- });
- tooltipReference.location.nativeElement.addEventListener('mouseleave',
() => {
- this.overlayRef?.detach();
+ private detachTip(): void {
+ if (this.closeTimer > 0) {
+ window.clearTimeout(this.closeTimer);
+ this.closeTimer = -1;
+ }
- if (this.positionStrategy?.detach) {
- this.positionStrategy.detach();
- }
+ if (this.overlayRef?.hasAttached()) {
+ this.overlayRef.detach();
+ }
- this.closeTimer = -1;
- this.overTip = false;
- });
+ if (this.positionStrategy?.detach) {
+ this.positionStrategy.detach();
+ }
+ }
+
+ private isPointerOverTrigger(): boolean {
+ return this.element.nativeElement.matches(':hover');
+ }
+
+ private isPointerOverTip(): boolean {
+ const overlayElement = this.overlayRef?.overlayElement;
+ return !!overlayElement && overlayElement.matches(':hover');
}
private getPositionStrategy(): PositionStrategy {