feat: debounce clipboard directive tooltip to prevent flickering

This commit is contained in:
Joshua Cain
2025-10-14 05:57:08 -04:00
committed by Joshua Cain
parent 0335a32a3a
commit 32897d0ab9
2 changed files with 89 additions and 11 deletions
@@ -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);
}));
});
@@ -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<ClipboardComponent>;
private mouseEnterTimeout: ReturnType<typeof setTimeout>;
private mouseLeaveTimeout: ReturnType<typeof setTimeout>;
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'])