From 32897d0ab9243603069abb1f249bd27785adadfd Mon Sep 17 00:00:00 2001 From: Joshua Cain Date: Mon, 13 Oct 2025 12:58:01 -0400 Subject: [PATCH] feat: debounce clipboard directive tooltip to prevent flickering --- .../lib/clipboard/clipboard.directive.spec.ts | 38 ++++++++++-- .../src/lib/clipboard/clipboard.directive.ts | 62 ++++++++++++++++--- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/lib/core/src/lib/clipboard/clipboard.directive.spec.ts b/lib/core/src/lib/clipboard/clipboard.directive.spec.ts index b3943cb82f..a2618312ad 100644 --- a/lib/core/src/lib/clipboard/clipboard.directive.spec.ts +++ b/lib/core/src/lib/clipboard/clipboard.directive.spec.ts @@ -96,21 +96,24 @@ describe('CopyClipboardDirective', () => { fixture.detectChanges(); }); - it('should show tooltip when hover element', () => { + it('should show tooltip when hover element', fakeAsync(() => { testingUtils.hoverOverByCSS('span'); + tick(200); fixture.detectChanges(); expect(testingUtils.getByCSS('.adf-copy-tooltip')).not.toBeNull(); - }); + })); - it('should not show tooltip when element it is not hovered', () => { + it('should not show tooltip when element it is not hovered', fakeAsync(() => { testingUtils.hoverOverByCSS('span'); + tick(200); fixture.detectChanges(); expect(testingUtils.getByCSS('.adf-copy-tooltip')).not.toBeNull(); testingUtils.mouseLeaveByCSS('span'); + tick(150); fixture.detectChanges(); expect(testingUtils.getByCSS('.adf-copy-tooltip')).toBeNull(); - }); + })); it('should copy the content of element when click it', fakeAsync(() => { spyOn(navigator.clipboard, 'writeText'); @@ -135,4 +138,31 @@ describe('CopyClipboardDirective', () => { fixture.detectChanges(); expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); })); + + it('should cleanup tooltip on destroy', fakeAsync(() => { + testingUtils.hoverOverByCSS('span'); + tick(200); + fixture.detectChanges(); + expect(testingUtils.getByCSS('.adf-copy-tooltip')).not.toBeNull(); + + // Manually trigger the directive's destroy method + const directive = fixture.componentInstance.clipboardDirective; + directive.ngOnDestroy(); + + // Tooltip should be cleaned up + expect(testingUtils.getByCSS('.adf-copy-tooltip')).toBeNull(); + })); + + it('should prevent flickering on rapid mouse movements', fakeAsync(() => { + testingUtils.hoverOverByCSS('span'); + tick(50); + testingUtils.mouseLeaveByCSS('span'); + tick(50); + testingUtils.hoverOverByCSS('span'); + tick(200); + fixture.detectChanges(); + + const tooltips = testingUtils.getAllByCSS('.adf-copy-tooltip'); + expect(tooltips.length).toBeLessThanOrEqual(1); + })); }); diff --git a/lib/core/src/lib/clipboard/clipboard.directive.ts b/lib/core/src/lib/clipboard/clipboard.directive.ts index c10fa22b0a..6d07593786 100644 --- a/lib/core/src/lib/clipboard/clipboard.directive.ts +++ b/lib/core/src/lib/clipboard/clipboard.directive.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { Directive, Input, HostListener, Component, ViewContainerRef, ViewEncapsulation, OnInit } from '@angular/core'; +import { Directive, Input, HostListener, Component, ViewContainerRef, ViewEncapsulation, OnInit, ComponentRef, OnDestroy } from '@angular/core'; import { ClipboardService } from './clipboard.service'; import { TranslatePipe } from '@ngx-translate/core'; @@ -23,7 +23,7 @@ import { TranslatePipe } from '@ngx-translate/core'; selector: '[adf-clipboard]', exportAs: 'adfClipboard' }) -export class ClipboardDirective { +export class ClipboardDirective implements OnDestroy { /** Translation key or message for the tooltip. */ // eslint-disable-next-line @angular-eslint/no-input-rename @Input('adf-clipboard') @@ -37,19 +37,67 @@ export class ClipboardDirective { // eslint-disable-next-line @angular-eslint/no-input-rename @Input('clipboard-notification') message: string; - constructor(private clipboardService: ClipboardService, public viewContainerRef: ViewContainerRef) {} + private tooltipComponentRef: ComponentRef; + private mouseEnterTimeout: ReturnType; + private mouseLeaveTimeout: ReturnType; + + constructor( + private clipboardService: ClipboardService, + public viewContainerRef: ViewContainerRef + ) {} + + ngOnDestroy(): void { + this.clearTimeouts(); + this.hideTooltip(); + } @HostListener('mouseenter') showTooltip() { - if (this.placeholder) { - const componentRef = this.viewContainerRef.createComponent(ClipboardComponent).instance; - componentRef.placeholder = this.placeholder; + if (this.mouseLeaveTimeout) { + clearTimeout(this.mouseLeaveTimeout); + this.mouseLeaveTimeout = null; } + + if (this.tooltipComponentRef) { + return; + } + + this.mouseEnterTimeout = setTimeout(() => { + if (this.placeholder && !this.tooltipComponentRef) { + this.tooltipComponentRef = this.viewContainerRef.createComponent(ClipboardComponent); + this.tooltipComponentRef.instance.placeholder = this.placeholder; + } + }, 150); } @HostListener('mouseleave') closeTooltip() { - this.viewContainerRef.remove(); + if (this.mouseEnterTimeout) { + clearTimeout(this.mouseEnterTimeout); + this.mouseEnterTimeout = null; + } + + this.mouseLeaveTimeout = setTimeout(() => { + this.hideTooltip(); + }, 100); + } + + private hideTooltip(): void { + if (this.tooltipComponentRef) { + this.tooltipComponentRef.destroy(); + this.tooltipComponentRef = null; + } + } + + private clearTimeouts(): void { + if (this.mouseEnterTimeout) { + clearTimeout(this.mouseEnterTimeout); + this.mouseEnterTimeout = null; + } + if (this.mouseLeaveTimeout) { + clearTimeout(this.mouseLeaveTimeout); + this.mouseLeaveTimeout = null; + } } @HostListener('keydown.enter', ['$event'])