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 {

Reply via email to