Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import {EvoTooltipConfig} from '../interfaces/evo-tooltip-config';
export const EVO_TOOLTIP_CONFIG: EvoTooltipConfig = {
hideDelay: 300,
showDelay: 100,
scrollStrategy: 'close',
};
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { EvoTooltipDirective } from './evo-tooltip.directive';
import { Component } from '@angular/core';
import { EvoTooltipPosition } from '../enums/evo-tooltip-position';
import { EvoTooltipStyles } from '../interfaces/evo-tooltip-styles';
import { EvoTooltipVariableArrowPosition } from '../enums/evo-tooltip-variable-arrow-position';
import { CommonModule } from '@angular/common';
import { EvoTooltipService } from '../services/evo-tooltip.service';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { first } from 'rxjs/operators';
import { EvoTooltipComponent } from '../evo-tooltip.component';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {EvoTooltipDirective} from './evo-tooltip.directive';
import {Component, NO_ERRORS_SCHEMA} from '@angular/core';
import {EvoTooltipPosition} from '../enums/evo-tooltip-position';
import {EvoTooltipStyles} from '../interfaces/evo-tooltip-styles';
import {EvoTooltipStyleVariable} from '../enums/evo-tooltip-style-variable';
import {CommonModule} from '@angular/common';
import {EvoTooltipService} from '../services/evo-tooltip.service';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {first} from 'rxjs/operators';
import {By} from "@angular/platform-browser";

@Component({
template: `
<div evoTooltip="Test tooltip"
<div [evoTooltip]="content"
[evoTooltipPosition]="position"
[evoTooltipDisabled]="disabled"
[evoTooltipConfig]="config"
Expand All @@ -27,36 +26,44 @@ import { NO_ERRORS_SCHEMA } from '@angular/core';
`,
})
class TestHostComponent {
content = 'Test tooltip';
position = EvoTooltipPosition.BOTTOM;
disabled = false;
config = { showDelay: 0, hideDelay: 0 };
config = {showDelay: 0, hideDelay: 0};
visibleArrow = true;
styles: EvoTooltipStyles = {
[EvoTooltipVariableArrowPosition.VERTICAL_POSITION_ARROW]: '10px',
[EvoTooltipVariableArrowPosition.HORIZONTAL_POSITION_ARROW]: '20px',
[EvoTooltipStyleVariable.VERTICAL_POSITION_ARROW]: '10px',
[EvoTooltipStyleVariable.HORIZONTAL_POSITION_ARROW]: '20px',
};
classes = ['class-1', 'class-2'];
onOpen = jasmine.createSpy('onOpen');
onClose = jasmine.createSpy('onClose');
}

describe('EvoTooltipDirective', () => {
let component: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;

let component: TestHostComponent;
let triggerEl: HTMLElement;

let directive: EvoTooltipDirective;
let tooltipService: EvoTooltipService;

beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [TestHostComponent, EvoTooltipDirective, EvoTooltipComponent],
imports: [CommonModule, BrowserAnimationsModule],
declarations: [TestHostComponent],
imports: [CommonModule, BrowserAnimationsModule, EvoTooltipDirective],
providers: [EvoTooltipService],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();

fixture = TestBed.createComponent(TestHostComponent);
component = fixture.componentInstance;
directive = fixture.debugElement.children[0].injector.get(EvoTooltipDirective);

const directiveEl = fixture.debugElement.query(By.directive(EvoTooltipDirective));
triggerEl = directiveEl.nativeElement;

directive = directiveEl.injector.get(EvoTooltipDirective);
tooltipService = TestBed.inject(EvoTooltipService);
fixture.detectChanges();
});
Expand All @@ -66,15 +73,13 @@ describe('EvoTooltipDirective', () => {
});

it('should have correct host classes', () => {
const element = fixture.debugElement.children[0].nativeElement;
expect(element.classList.contains('evo-tooltip-trigger')).toBeTrue();
expect(triggerEl.classList.contains('evo-tooltip-trigger')).toBeTrue();
});

it('should add disabled class when disabled', () => {
component.disabled = true;
fixture.detectChanges();
const element = fixture.debugElement.children[0].nativeElement;
expect(element.classList.contains('evo-tooltip-trigger_disabled')).toBeTrue();
expect(triggerEl.classList.contains('evo-tooltip-trigger_disabled')).toBeTrue();
});

it('should emit open event when tooltip is shown', fakeAsync(() => {
Expand Down Expand Up @@ -104,16 +109,25 @@ describe('EvoTooltipDirective', () => {
}));

it('should not show tooltip when content is empty', fakeAsync(() => {
directive.content = null;
component.content = null;
fixture.detectChanges();
directive.show();
tick(0);
fixture.detectChanges();
expect(component.onOpen).not.toHaveBeenCalled();
}));

it('should handle mouseenter event', fakeAsync(() => {
const element = fixture.debugElement.children[0].nativeElement;
element.dispatchEvent(new MouseEvent('mouseenter'));
triggerEl.dispatchEvent(new MouseEvent('mouseenter'));
tick(0);
fixture.detectChanges();
tooltipService.isOpen$.pipe(first()).subscribe((isOpen) => {
expect(isOpen).toBeTrue();
});
}));

it('should handle touchstart event', fakeAsync(() => {
triggerEl.dispatchEvent(new MouseEvent('touchstart'));
tick(0);
fixture.detectChanges();
tooltipService.isOpen$.pipe(first()).subscribe((isOpen) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,102 +1,136 @@
import {
DestroyRef,
Directive,
effect,
ElementRef,
EventEmitter,
HostBinding,
Input,
inject,
input,
OnDestroy,
OnInit,
Output,
output,
TemplateRef,
} from '@angular/core';
import {fromEvent, Observable, Subject} from 'rxjs';
import {takeUntil, tap, throttleTime} from 'rxjs/operators';
import {EvoTooltipService} from '../services/evo-tooltip.service';
import {EvoTooltipPositionType} from '../types/evo-tooltip-position-type';
import {EvoTooltipConfig} from '../interfaces/evo-tooltip-config';
import {EMPTY, filter, fromEvent, merge} from 'rxjs';
import {catchError, debounceTime, distinctUntilChanged, first, map, takeUntil, tap, throttleTime} from 'rxjs/operators';
import {EVO_TOOLTIP_CONFIG} from '../constants/evo-tooltip-config';
import {EvoTooltipPosition} from '../enums/evo-tooltip-position';
import {EvoTooltipConfig} from '../interfaces/evo-tooltip-config';
import {EvoTooltipStyles} from '../interfaces/evo-tooltip-styles';
import {EvoTooltipService} from '../services/evo-tooltip.service';
import {EvoTooltipPositionType} from '../types/evo-tooltip-position-type';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";

@Directive({
selector: '[evoTooltip]',
exportAs: 'evoTooltip',
standalone: true,
providers: [EvoTooltipService],
})
export class EvoTooltipDirective implements OnInit, OnDestroy {
@Input('evoTooltip') content: string | TemplateRef<HTMLElement>;
@Input('evoTooltipPosition') position: EvoTooltipPositionType | string = EvoTooltipPosition.BOTTOM;
@Input('evoTooltipDisabled') disabled = false;
@Input('evoTooltipConfig') config: Partial<EvoTooltipConfig>;
@Input() set evoTooltipVisibleArrow(visibleArrow: boolean) {
this.tooltipService.setArrowVisibility(visibleArrow);
}
@Input() set evoTooltipStyles(tooltipStyles: EvoTooltipStyles) {
this.tooltipService.setTooltipStyles(tooltipStyles);
}
@Input() set evoTooltipClass(tooltipClass: string | string[]) {
this.tooltipService.setTooltipClass(tooltipClass);
}
readonly content = input<string | TemplateRef<HTMLElement>>('', {alias: 'evoTooltip'});

@Output() evoTooltipOpen = new EventEmitter<void>();
@Output() evoTooltipClose = new EventEmitter<void>();
readonly position = input<EvoTooltipPositionType | string>(EvoTooltipPosition.BOTTOM, {alias: 'evoTooltipPosition'});

@HostBinding('class') get hostClasses(): string[] {
return ['evo-tooltip-trigger', ...(this.disabled ? ['evo-tooltip-trigger_disabled'] : [])];
}
readonly disabled = input(false, {alias: 'evoTooltipDisabled'});

readonly config = input<Partial<EvoTooltipConfig>>(EVO_TOOLTIP_CONFIG, {alias: 'evoTooltipConfig'});

readonly isOpen$: Observable<boolean> = this.tooltipService.isOpen$;
readonly isVisibleArrow = input(true, {alias: 'evoTooltipVisibleArrow'});

private readonly destroy$ = new Subject<void>();
readonly tooltipStyles = input<EvoTooltipStyles>({}, {alias: 'evoTooltipStyles'});

constructor(private readonly elementRef: ElementRef, private readonly tooltipService: EvoTooltipService) {}
readonly tooltipClass = input<string | string[]>([], {alias: 'evoTooltipClass'});

readonly evoTooltipOpen = output<void>()
readonly evoTooltipClose = output<void>()

private readonly destroyRef = inject(DestroyRef);
private readonly elementRef = inject(ElementRef);
private readonly tooltipService = inject(EvoTooltipService);

@HostBinding('class') get hostClasses(): string[] {
return ['evo-tooltip-trigger', ...(this.disabled() ? ['evo-tooltip-trigger_disabled'] : [])];
}

constructor() {
effect((): void => this.tooltipService.setArrowVisibility(this.isVisibleArrow()));
effect((): void => this.tooltipService.setTooltipStyles(this.tooltipStyles()));
effect((): void => this.tooltipService.setTooltipClass(this.tooltipClass()));
}

ngOnInit(): void {
this.initSubscriptions();
}

ngOnDestroy(): void {
this.hide();
this.destroy$.next();
this.destroy$.complete();
}

hide(): void {
this.tooltipService.hideTooltip();
}

show(event?: MouseEvent): void {
if (!this.content || this.tooltipService.hasAttached || this.disabled) {
show(): void {
const content = this.content();

if (!content || this.tooltipService.hasAttached || this.disabled()) {
return;
}

this.tooltipService.showTooltip(
const tooltip = this.tooltipService.showTooltip(
this.elementRef,
this.content,
this.position as EvoTooltipPosition,
{...EVO_TOOLTIP_CONFIG, ...this.config},
event?.target,
content,
this.position() as EvoTooltipPosition,
{...EVO_TOOLTIP_CONFIG, ...this.config()},
);

this.initHideSubscription(tooltip);
}

private initSubscriptions(): void {
fromEvent(this.elementRef.nativeElement, 'mouseenter')
const element = this.elementRef.nativeElement;

merge(fromEvent(element, 'mouseenter'), fromEvent(element, 'touchstart'))
.pipe(
throttleTime(this.config?.showDelay ?? EVO_TOOLTIP_CONFIG.showDelay),
tap(() => {
this.show();
}),
takeUntil(this.destroy$),
throttleTime(this.config()?.showDelay ?? EVO_TOOLTIP_CONFIG.showDelay),
tap((): void => this.show()),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();

this.tooltipService.isOpen$
.pipe(
tap((isOpen) => {
isOpen ? this.evoTooltipOpen.emit() : this.evoTooltipClose.emit();
distinctUntilChanged(),
tap((isOpen): void => {
if (isOpen) {
this.evoTooltipOpen.emit();
} else {
this.evoTooltipClose.emit();
}
}),
takeUntil(this.destroy$),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}

private initHideSubscription(tooltip: HTMLElement): void {
const mouseLeaveParentElement$ = fromEvent(tooltip, 'mouseleave').pipe(map(() => this.elementRef.nativeElement));
const mouseLeaveOverlayElement$ = fromEvent(this.elementRef.nativeElement, 'mouseleave').pipe(map(() => tooltip));

merge(mouseLeaveParentElement$, mouseLeaveOverlayElement$)
.pipe(
filter((element) => !element.matches(':hover')),
debounceTime(this.config()?.hideDelay ?? EVO_TOOLTIP_CONFIG.hideDelay),
filter(() => !tooltip.matches(':hover') && !this.elementRef.nativeElement.matches(':hover')),
first(),
catchError(() => {
this.tooltipService.hideTooltip();
return EMPTY;
}),
takeUntil(this.tooltipService.isOpen$.pipe(filter((isOpened: boolean) => !isOpened))),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(() => this.tooltipService.hideTooltip());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export enum EvoTooltipStyleVariable {
HORIZONTAL_POSITION_ARROW = '--evo-tooltip-horizontal-position-arrow',
VERTICAL_POSITION_ARROW = '--evo-tooltip-vertical-position-arrow',

COLOR = '--evo-tooltip-color',
BACKGROUND_COLOR = '--evo-tooltip-background-color',
MAX_WIDTH = '--evo-tooltip-max-width',

PADDING = '--evo-tooltip-padding',

BORDER_RADIUS = '--evo-tooltip-border-radius',
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
<ng-container
*ngIf="{position: position$ | async, styles: styles$ | async, visibleArrow: visibleArrow$ | async} as asyncData"
<div
class="evo-tooltip"
[class]="'evo-tooltip_' + position()"
[class.evo-tooltip_not-arrow]="!visibleArrow()"
[style]="styles()"
>
<div
class="evo-tooltip"
[class]="'evo-tooltip_' + asyncData.position"
[class.evo-tooltip_not-arrow]="!asyncData.visibleArrow"
[style]="asyncData.styles"
>
<ng-container *ngIf="stringContent$ | async as content; else templateRef">{{ content }}</ng-container>
</div>
</ng-container>

<ng-template #templateRef>
<ng-container *ngIf="templateContent$ | async as content" [ngTemplateOutlet]="content" />
</ng-template>
@if (stringContent(); as content) {
{{ content }}
} @else {
@if (templateContent(); as content) {
<ng-container [ngTemplateOutlet]="content"/>
}
}
</div>
Loading
Loading