From a62d528201a411c04accb45f3e47b742e7cd4dcc Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Tue, 5 May 2026 19:43:50 +0300 Subject: [PATCH 01/11] feat(evo-tooltip-trigger): use styles in main --- .../components/evo-tooltip-trigger.scss} | 0 projects/evo-ui-kit/src/lib/styles/main.scss | 1 + 2 files changed, 1 insertion(+) rename projects/evo-ui-kit/src/lib/{components/evo-tooltip/assets/evo-tooltip-global.scss => styles/components/evo-tooltip-trigger.scss} (100%) diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/assets/evo-tooltip-global.scss b/projects/evo-ui-kit/src/lib/styles/components/evo-tooltip-trigger.scss similarity index 100% rename from projects/evo-ui-kit/src/lib/components/evo-tooltip/assets/evo-tooltip-global.scss rename to projects/evo-ui-kit/src/lib/styles/components/evo-tooltip-trigger.scss diff --git a/projects/evo-ui-kit/src/lib/styles/main.scss b/projects/evo-ui-kit/src/lib/styles/main.scss index 8531ec4a0..12f6d6a7c 100644 --- a/projects/evo-ui-kit/src/lib/styles/main.scss +++ b/projects/evo-ui-kit/src/lib/styles/main.scss @@ -22,3 +22,4 @@ @import './components/evo-form.scss'; @import './components/evo-dropdown.scss'; @import './components/skeleton.scss'; +@import "components/evo-tooltip-trigger"; From 590709439c577359ee31d729fbb40c9295ba4975 Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Tue, 12 May 2026 14:05:36 +0300 Subject: [PATCH 02/11] feat(evo-tooltip): stories added --- src/stories/components/evo-tooltip.stories.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/stories/components/evo-tooltip.stories.ts diff --git a/src/stories/components/evo-tooltip.stories.ts b/src/stories/components/evo-tooltip.stories.ts new file mode 100644 index 000000000..17d4dbd2f --- /dev/null +++ b/src/stories/components/evo-tooltip.stories.ts @@ -0,0 +1,96 @@ +import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; +import {EvoButtonModule, EvoTooltipModule, EvoTooltipPosition} from '@evotor-dev/ui-kit'; +import {moduleMetadata} from '@storybook/angular'; + +export default { + title: 'Components/Tooltip', + + decorators: [ + moduleMetadata({ + imports: [EvoTooltipModule, EvoButtonModule, BrowserAnimationsModule], + }), + ], +}; + +const POSITIONS_LIST: EvoTooltipPosition[] = [ + EvoTooltipPosition.TOP_START, + EvoTooltipPosition.TOP, + EvoTooltipPosition.TOP_END, + + EvoTooltipPosition.BOTTOM_START, + EvoTooltipPosition.BOTTOM, + EvoTooltipPosition.BOTTOM_END, + + EvoTooltipPosition.RIGHT_START, + EvoTooltipPosition.RIGHT, + EvoTooltipPosition.RIGHT_END, + + EvoTooltipPosition.LEFT_START, + EvoTooltipPosition.LEFT, + EvoTooltipPosition.LEFT_END, +]; + +export const Default = () => ({ + template: ` + +
+ + +
    +
  • +

    {{item | titlecase }}

    +

    Tooltip parent with some content. Lorem ipsum dolor sit

    +
  • +
+ + + Some tooltip content + +
+ `, + props: { + POSITIONS_LIST: POSITIONS_LIST, + isDisabled: false + } +}); + +Default.storyName = 'default'; From 2ef87f79a246ea58aae82232fc60dfb44df53e2d Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Tue, 12 May 2026 15:57:35 +0300 Subject: [PATCH 03/11] refactor(evo-tooltip): close tooltip inside directive --- .../directives/evo-tooltip.directive.spec.ts | 2 +- .../directives/evo-tooltip.directive.ts | 59 ++++++---- .../lib/components/evo-tooltip/public-api.ts | 5 + .../services/evo-tooltip.service.spec.ts | 6 +- .../services/evo-tooltip.service.ts | 105 ++++++++---------- 5 files changed, 96 insertions(+), 81 deletions(-) diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts index b59bd54de..a14b52e87 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts @@ -120,4 +120,4 @@ describe('EvoTooltipDirective', () => { expect(isOpen).toBeTrue(); }); })); -}); \ No newline at end of file +}); diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.ts index 9a59ffdf2..5224655fe 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.ts @@ -9,8 +9,8 @@ import { Output, TemplateRef, } from '@angular/core'; -import {fromEvent, Observable, Subject} from 'rxjs'; -import {takeUntil, tap, throttleTime} from 'rxjs/operators'; +import {fromEvent, merge, Subject} from 'rxjs'; +import {debounceTime, filter, 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'; @@ -28,27 +28,26 @@ export class EvoTooltipDirective implements OnInit, OnDestroy { @Input('evoTooltipPosition') position: EvoTooltipPositionType | string = EvoTooltipPosition.BOTTOM; @Input('evoTooltipDisabled') disabled = false; @Input('evoTooltipConfig') config: Partial; + @Output() evoTooltipOpen = new EventEmitter(); + @Output() evoTooltipClose = new EventEmitter(); + private readonly destroy$ = new Subject(); + @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); } - @Output() evoTooltipOpen = new EventEmitter(); - @Output() evoTooltipClose = new EventEmitter(); - @HostBinding('class') get hostClasses(): string[] { return ['evo-tooltip-trigger', ...(this.disabled ? ['evo-tooltip-trigger_disabled'] : [])]; } - readonly isOpen$: Observable = this.tooltipService.isOpen$; - - private readonly destroy$ = new Subject(); - constructor(private readonly elementRef: ElementRef, private readonly tooltipService: EvoTooltipService) {} ngOnInit(): void { @@ -65,27 +64,28 @@ export class EvoTooltipDirective implements OnInit, OnDestroy { this.tooltipService.hideTooltip(); } - show(event?: MouseEvent): void { + show(): void { if (!this.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, ); + + this.initHideSubscription(tooltip); } private initSubscriptions(): void { - fromEvent(this.elementRef.nativeElement, 'mouseenter') + const element = this.elementRef.nativeElement; + const showDelay = this.config?.showDelay ?? EVO_TOOLTIP_CONFIG.showDelay; + + merge(fromEvent(element, 'mouseenter'), fromEvent(element, 'touchstart')) .pipe( - throttleTime(this.config?.showDelay ?? EVO_TOOLTIP_CONFIG.showDelay), - tap(() => { - this.show(); - }), + throttleTime(showDelay), + tap(() => this.show()), takeUntil(this.destroy$), ) .subscribe(); @@ -93,10 +93,31 @@ export class EvoTooltipDirective implements OnInit, OnDestroy { this.tooltipService.isOpen$ .pipe( tap((isOpen) => { - isOpen ? this.evoTooltipOpen.emit() : this.evoTooltipClose.emit(); + if (isOpen) { + this.evoTooltipOpen.emit(); + return; + } + + this.evoTooltipClose.emit(); }), takeUntil(this.destroy$), ) .subscribe(); } + + private initHideSubscription(tooltip: HTMLElement): void { + const trigger = this.elementRef.nativeElement; + const closed$ = this.tooltipService.isOpen$.pipe(filter((isOpen) => !isOpen)); + + const hideDelay = this.config?.hideDelay ?? EVO_TOOLTIP_CONFIG.hideDelay; + + merge(fromEvent(tooltip, 'mouseleave'), fromEvent(trigger, 'mouseleave')) + .pipe( + debounceTime(hideDelay), + filter(() => !tooltip.matches(':hover') && !trigger.matches(':hover')), + tap(() => this.tooltipService.hideTooltip()), + takeUntil(merge(closed$, this.destroy$)), + ) + .subscribe(); + } } diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/public-api.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/public-api.ts index 06bf44990..79a592ce9 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/public-api.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/public-api.ts @@ -1,2 +1,7 @@ export * from './evo-tooltip.module'; export * from './directives/evo-tooltip.directive'; +export * from './types/evo-tooltip-position-type'; +export * from './enums/evo-tooltip-position'; +export * from './interfaces/evo-tooltip-config'; +export * from './interfaces/evo-tooltip-styles'; +export * from './services/evo-tooltip.service'; diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts index 521286e89..2bffe6f99 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts @@ -2,13 +2,12 @@ import {TestBed} from '@angular/core/testing'; import {EvoTooltipService} from './evo-tooltip.service'; import {EvoTooltipPosition} from '../enums/evo-tooltip-position'; import {EvoTooltipStyles} from '../interfaces/evo-tooltip-styles'; -import {ElementRef} from '@angular/core'; +import {ElementRef, NO_ERRORS_SCHEMA} from '@angular/core'; import {first} from 'rxjs/operators'; import {EvoTooltipVariableArrowPosition} from '../enums/evo-tooltip-variable-arrow-position'; import {CommonModule} from '@angular/common'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {EvoTooltipComponent} from '../evo-tooltip.component'; -import {NO_ERRORS_SCHEMA} from '@angular/core'; describe('EvoTooltipService', () => { let service: EvoTooltipService; @@ -78,9 +77,8 @@ describe('EvoTooltipService', () => { it('should show tooltip', () => { const content = 'Test content'; const position = EvoTooltipPosition.TOP; - const config = {showDelay: 0, hideDelay: 0}; - service.showTooltip(elementRef, content, position, config); + service.showTooltip(elementRef, content, position); service.stringContent$.pipe(first()).subscribe((value) => { expect(value).toBe(content); diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.ts index 26c3e1f0d..6551f9973 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.ts @@ -1,4 +1,4 @@ -import {ComponentRef, ElementRef, Injectable, Injector, TemplateRef} from '@angular/core'; +import {ComponentRef, ElementRef, Injectable, Injector, OnDestroy, TemplateRef} from '@angular/core'; import {ComponentPortal} from '@angular/cdk/portal'; import { ConnectedPosition, @@ -7,10 +7,8 @@ import { OverlayPositionBuilder, OverlayRef, } from '@angular/cdk/overlay'; -import {BehaviorSubject, EMPTY, fromEvent, merge, Observable, Subject} from 'rxjs'; -import {catchError, debounceTime, filter, first, map} from 'rxjs/operators'; -import {EvoTooltipConfig} from '../interfaces/evo-tooltip-config'; -import {EVO_TOOLTIP_CONFIG} from '../constants/evo-tooltip-config'; +import {BehaviorSubject, EMPTY, merge, Observable, Subject} from 'rxjs'; +import {filter, take, takeUntil, tap} from 'rxjs/operators'; import {EvoTooltipComponent} from '../evo-tooltip.component'; import {EvoTooltipPosition} from '../enums/evo-tooltip-position'; import {EVO_CONNECTED_POSITION} from '../constants/evo-tooltip-connected-position'; @@ -22,7 +20,7 @@ import {EvoTooltipStyles} from '../interfaces/evo-tooltip-styles'; import {EVO_TOOLTIP_OFFSET} from '../constants/evo-tooltip-offset'; @Injectable() -export class EvoTooltipService { +export class EvoTooltipService implements OnDestroy { readonly stringContent$: Observable = EMPTY; readonly templateContent$: Observable | null> = EMPTY; readonly position$: Observable = EMPTY; @@ -37,15 +35,18 @@ export class EvoTooltipService { private readonly _position$ = new BehaviorSubject(EvoTooltipPosition.BOTTOM); private readonly _parentRef$ = new BehaviorSubject(null); private readonly _tooltipClasses$ = new BehaviorSubject([]); - private readonly _config$ = new BehaviorSubject(EVO_TOOLTIP_CONFIG); private readonly _styles$ = new BehaviorSubject(null); private readonly _visibleArrow$ = new BehaviorSubject(true); private readonly _isOpen$ = new Subject(); + private readonly destroy$ = new Subject(); - private overlayRef: OverlayRef; - private positionStrategy: FlexibleConnectedPositionStrategy; - private tooltipComponentRef: ComponentRef | null; - private targetElement: EventTarget | null; + private overlayRef: OverlayRef | null = null; + private positionStrategy: FlexibleConnectedPositionStrategy | null = null; + private tooltipComponentRef: ComponentRef | null = null; + + get hasAttached(): boolean { + return this.overlayRef?.hasAttached() ?? false; + } constructor( private readonly overlay: Overlay, @@ -62,24 +63,27 @@ export class EvoTooltipService { this.isOpen$ = this._isOpen$.asObservable(); } + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + showTooltip( parentRef: ElementRef, content: string | TemplateRef, position: EvoTooltipPosition, - config: EvoTooltipConfig, - targetElement?: EventTarget | null, - ): void { + ): HTMLElement { this._parentRef$.next(parentRef); - this.targetElement = targetElement ?? null; this.setContent(content); this._position$.next(position); - this._config$.next(config); this.createOverlay(parentRef, position); this.createPortal(); - this._isOpen$.next(this.overlayRef.hasAttached()); + this._isOpen$.next(this.hasAttached); this.initSubscriptions(); + + return this.overlayRef.overlayElement; } hideTooltip(): void { @@ -110,18 +114,6 @@ export class EvoTooltipService { ); } - get hasAttached(): boolean { - return this.overlayRef?.hasAttached() ?? false; - } - - get config(): EvoTooltipConfig { - return this._config$.value; - } - - private get parentRef(): ElementRef { - return this._parentRef$.value; - } - private setContent(content: string | TemplateRef | undefined): void { if (typeof content === 'string') { this._stringContent$.next(content); @@ -130,10 +122,11 @@ export class EvoTooltipService { } } - private createOverlay(elementRef: ElementRef, position: EvoTooltipPosition): void { + private createOverlay(parentRef: ElementRef, position: EvoTooltipPosition): void { this.positionStrategy = this.overlayPositionBuilder - .flexibleConnectedTo(elementRef) - .withPositions(this.getPositions(position)); + .flexibleConnectedTo(parentRef) + .withPositions(this.getPositions(position, parentRef)) + .withPush(false); const scrollStrategy = this.overlay.scrollStrategies.reposition(); this.overlayRef = this.overlay.create({positionStrategy: this.positionStrategy, scrollStrategy}); @@ -145,38 +138,36 @@ export class EvoTooltipService { } private initSubscriptions(): void { - const parentElement = this.targetElement ? this.targetElement : this.parentRef?.nativeElement; - const overlayElement = this.overlayRef.overlayElement; - - const mouseLeaveParentElement$ = fromEvent(parentElement, 'mouseleave').pipe(map(() => overlayElement)); - const mouseLeaveOverlayElement$ = fromEvent(overlayElement, 'mouseleave').pipe(map(() => parentElement)); - - merge(mouseLeaveParentElement$, mouseLeaveOverlayElement$) - .pipe( - filter((element) => !element.matches(':hover')), - debounceTime(this.config?.hideDelay ?? EVO_TOOLTIP_CONFIG.hideDelay), - filter(() => !parentElement.matches(':hover') && !overlayElement.matches(':hover')), - first(), - catchError(() => { - this.hideTooltip(); - return EMPTY; - }), - ) - .subscribe(() => { - this.hideTooltip(); + if (this.positionStrategy) { + const closed$ = this._isOpen$.pipe(filter((isOpened: boolean) => !isOpened)); + + this.positionStrategy.positionChanges.pipe(takeUntil(merge(this.destroy$, closed$))).subscribe((value) => { + this._position$.next(value.connectionPair.panelClass as EvoTooltipPosition); }); + } - this.positionStrategy.positionChanges.subscribe((value) => { - this._position$.next(value.connectionPair.panelClass as EvoTooltipPosition); - }); + if (this.overlayRef) { + this.overlayRef + .detachments() + .pipe( + take(1), + tap(() => { + this.positionStrategy = null; + this.overlayRef = null; + this._isOpen$.next(false); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } } - private getPositions(position: EvoTooltipPosition): ConnectedPosition[] { + private getPositions(position: EvoTooltipPosition, parentRef: ElementRef): ConnectedPosition[] { return this.getPositionsOrder(position).map((key) => { const offset = EVO_TOOLTIP_OFFSET(this._visibleArrow$.value); const position = {...EVO_CONNECTED_POSITION(offset)[key]}; - const width = this.parentRef.nativeElement.offsetWidth; - const height = this.parentRef.nativeElement.offsetHeight; + const width = parentRef.nativeElement.offsetWidth; + const height = parentRef.nativeElement.offsetHeight; const maxSize = EVO_TOOLTIP_ARROW_SIZE + EVO_TOOLTIP_RADIUS * 2; // Добавляем смещение тултипа для мелких обьектов From d4d4b1d98430500ea6afe3c93da3e542fc3e00e8 Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Wed, 13 May 2026 00:35:09 +0300 Subject: [PATCH 04/11] feat(evo-tooltip): added touchstart handler --- .../directives/evo-tooltip.directive.spec.ts | 57 +++++++++++-------- .../directives/evo-tooltip.directive.ts | 8 ++- 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts index a14b52e87..740c28524 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts @@ -1,27 +1,28 @@ -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 {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'; @Component({ template: ` -
+
Hover me
`, @@ -29,7 +30,7 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; class TestHostComponent { position = EvoTooltipPosition.BOTTOM; disabled = false; - config = { showDelay: 0, hideDelay: 0 }; + config = {showDelay: 0, hideDelay: 0}; visibleArrow = true; styles: EvoTooltipStyles = { [EvoTooltipVariableArrowPosition.VERTICAL_POSITION_ARROW]: '10px', @@ -116,7 +117,17 @@ describe('EvoTooltipDirective', () => { element.dispatchEvent(new MouseEvent('mouseenter')); tick(0); fixture.detectChanges(); - tooltipService.isOpen$.pipe(first()).subscribe(isOpen => { + tooltipService.isOpen$.pipe(first()).subscribe((isOpen) => { + expect(isOpen).toBeTrue(); + }); + })); + + it('should handle touchstart event', fakeAsync(() => { + const element = fixture.debugElement.children[0].nativeElement; + element.dispatchEvent(new MouseEvent('touchstart')); + tick(0); + fixture.detectChanges(); + tooltipService.isOpen$.pipe(first()).subscribe((isOpen) => { expect(isOpen).toBeTrue(); }); })); diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.ts index 5224655fe..e38e899e4 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.ts @@ -28,9 +28,6 @@ export class EvoTooltipDirective implements OnInit, OnDestroy { @Input('evoTooltipPosition') position: EvoTooltipPositionType | string = EvoTooltipPosition.BOTTOM; @Input('evoTooltipDisabled') disabled = false; @Input('evoTooltipConfig') config: Partial; - @Output() evoTooltipOpen = new EventEmitter(); - @Output() evoTooltipClose = new EventEmitter(); - private readonly destroy$ = new Subject(); @Input() set evoTooltipVisibleArrow(visibleArrow: boolean) { this.tooltipService.setArrowVisibility(visibleArrow); @@ -44,10 +41,15 @@ export class EvoTooltipDirective implements OnInit, OnDestroy { this.tooltipService.setTooltipClass(tooltipClass); } + @Output() evoTooltipOpen = new EventEmitter(); + @Output() evoTooltipClose = new EventEmitter(); + @HostBinding('class') get hostClasses(): string[] { return ['evo-tooltip-trigger', ...(this.disabled ? ['evo-tooltip-trigger_disabled'] : [])]; } + private readonly destroy$ = new Subject(); + constructor(private readonly elementRef: ElementRef, private readonly tooltipService: EvoTooltipService) {} ngOnInit(): void { From 58435f29674cb9c1bc06a1b9fcedf4443c43b11a Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Tue, 12 May 2026 23:09:52 +0300 Subject: [PATCH 05/11] fix(evo-tooltip): fixed arrow position logic --- .../directives/evo-tooltip.directive.spec.ts | 6 +- .../enums/evo-tooltip-style-variable.ts | 12 ++ .../evo-tooltip-variable-arrow-position.ts | 4 - .../evo-tooltip/evo-tooltip.component.scss | 109 ++--------- .../evo-tooltip/evo-tooltip.component.spec.ts | 6 +- .../evo-tooltip/evo-tooltip.component.ts | 169 +++++++++++------- .../interfaces/evo-tooltip-styles.ts | 13 +- .../services/evo-tooltip.service.spec.ts | 6 +- 8 files changed, 148 insertions(+), 177 deletions(-) create mode 100644 projects/evo-ui-kit/src/lib/components/evo-tooltip/enums/evo-tooltip-style-variable.ts delete mode 100644 projects/evo-ui-kit/src/lib/components/evo-tooltip/enums/evo-tooltip-variable-arrow-position.ts diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts index 740c28524..3c4062df5 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts @@ -3,7 +3,7 @@ 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 {EvoTooltipVariableArrowPosition} from '../enums/evo-tooltip-variable-arrow-position'; +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'; @@ -33,8 +33,8 @@ class TestHostComponent { 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'); diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/enums/evo-tooltip-style-variable.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/enums/evo-tooltip-style-variable.ts new file mode 100644 index 000000000..5f6a5806f --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/enums/evo-tooltip-style-variable.ts @@ -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', +} diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/enums/evo-tooltip-variable-arrow-position.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/enums/evo-tooltip-variable-arrow-position.ts deleted file mode 100644 index cd74a502d..000000000 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/enums/evo-tooltip-variable-arrow-position.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum EvoTooltipVariableArrowPosition { - HORIZONTAL_POSITION_ARROW = '--evo-tooltip-horizontal-position-arrow', - VERTICAL_POSITION_ARROW = '--evo-tooltip-vertical-position-arrow', -} diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.scss b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.scss index 0ccbb3b20..565e6d82e 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.scss +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.scss @@ -1,4 +1,4 @@ -@import '../../styles/mixins'; +@import "../../styles/mixins"; :host { --evo-tooltip-horizontal-position-arrow: 50%; @@ -15,7 +15,7 @@ } .evo-tooltip { - $arrow-size: 8px; + $arrow-border-width: 8px; display: inline-block; position: relative; @@ -38,64 +38,19 @@ &:not(&_not-arrow):before { content: ""; position: absolute; - border-style: solid; - } - - &_top-start, - &_top, - &_top-end { - &:before { - border-width: $arrow-size $arrow-size 0 $arrow-size; - border-color: var(--evo-tooltip-background-color) transparent transparent transparent; - bottom: -$arrow-size; - } - } - - &_top-start { - &:before { - left: var(--evo-tooltip-horizontal-position-arrow); - } - } - - &_top { - &:before { - left: 50%; - transform: translateX(-50%); - } - } - - &_top-end { - &:before { - right: var(--evo-tooltip-horizontal-position-arrow); - } + left: var(--evo-tooltip-horizontal-position-arrow); + top: var(--evo-tooltip-vertical-position-arrow); + width: 0; + height: 0; + border: $arrow-border-width solid transparent; + border-top: $arrow-border-width solid var(--evo-tooltip-background-color); } &_right-start, &_right, &_right-end { &:before { - border-width: $arrow-size $arrow-size $arrow-size 0; - border-color: transparent var(--evo-tooltip-background-color) transparent transparent; - left: -$arrow-size; - } - } - - &_right-start { - &:before { - top: var(--evo-tooltip-vertical-position-arrow); - } - } - - &_right { - &:before { - top: 50%; - transform: translateY(-50%); - } - } - - &_right-end { - &:before { - bottom: var(--evo-tooltip-vertical-position-arrow); + transform: rotate(90deg); } } @@ -103,28 +58,7 @@ &_bottom, &_bottom-end { &:before { - border-width: 0 $arrow-size $arrow-size $arrow-size; - border-color: transparent transparent var(--evo-tooltip-background-color) transparent; - top: -$arrow-size; - } - } - - &_bottom-start { - &:before { - left: var(--evo-tooltip-horizontal-position-arrow); - } - } - - &_bottom { - &:before { - left: 50%; - transform: translateX(-50%); - } - } - - &_bottom-end { - &:before { - right: var(--evo-tooltip-horizontal-position-arrow); + transform: rotate(180deg); } } @@ -132,28 +66,7 @@ &_left, &_left-end { &:before { - border-width: $arrow-size 0 $arrow-size $arrow-size; - border-color: transparent transparent transparent var(--evo-tooltip-background-color); - right: -$arrow-size; - } - } - - &_left-start { - &:before { - top: var(--evo-tooltip-vertical-position-arrow); - } - } - - &_left { - &:before { - top: 50%; - transform: translateY(-50%); - } - } - - &_left-end { - &:before { - bottom: var(--evo-tooltip-vertical-position-arrow); + transform: rotate(-90deg); } } } diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.spec.ts index a18ec84eb..0c5310daa 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.spec.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.spec.ts @@ -5,8 +5,8 @@ import {NO_ERRORS_SCHEMA, Component, ViewChild, TemplateRef} from '@angular/core import {EvoTooltipPosition} from './enums/evo-tooltip-position'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {EvoTooltipStyles} from './interfaces/evo-tooltip-styles'; -import {EvoTooltipVariableArrowPosition} from './enums/evo-tooltip-variable-arrow-position'; import {CommonModule} from '@angular/common'; +import {EvoTooltipStyleVariable} from './enums/evo-tooltip-style-variable'; @Component({ selector: 'evo-host-component', @@ -83,8 +83,8 @@ describe('EvoTooltipComponent', () => { it('should update styles when styles$ changes', () => { const styles: EvoTooltipStyles = { - [EvoTooltipVariableArrowPosition.VERTICAL_POSITION_ARROW]: '10px', - [EvoTooltipVariableArrowPosition.HORIZONTAL_POSITION_ARROW]: '20px', + [EvoTooltipStyleVariable.MAX_WIDTH]: 'auto', + [EvoTooltipStyleVariable.PADDING]: 0, }; tooltipService['_styles$'].next(styles); testHostFixture.detectChanges(); diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.ts index 9fb9f2eea..832ceb6e3 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.ts @@ -5,19 +5,40 @@ import { ElementRef, HostBinding, OnDestroy, - OnInit, Renderer2, TemplateRef, } from '@angular/core'; -import {BehaviorSubject, combineLatest, EMPTY, Observable, Subject} from 'rxjs'; -import {filter, map, pairwise, startWith, takeUntil, tap} from 'rxjs/operators'; +import {combineLatest, Observable, Subject} from 'rxjs'; +import {map, pairwise, startWith, takeUntil, tap} from 'rxjs/operators'; import {EVO_TOOLTIP_FADEIN_ANIMATION} from './constants/evo-tooltip-fadein.animation'; import {EvoTooltipService} from './services/evo-tooltip.service'; import {EvoTooltipStyles} from './interfaces/evo-tooltip-styles'; import {EvoTooltipPosition} from './enums/evo-tooltip-position'; import {EVO_TOOLTIP_ARROW_SIZE} from './constants/evo-tooltip-arrow-size'; import {EVO_TOOLTIP_RADIUS} from './constants/evo-tooltip-radius'; -import {EvoTooltipVariableArrowPosition} from './enums/evo-tooltip-variable-arrow-position'; +import {EvoTooltipStyleVariable} from './enums/evo-tooltip-style-variable'; + +const START_POSITIONS_LIST: ReadonlyArray = [ + EvoTooltipPosition.TOP_START, + EvoTooltipPosition.BOTTOM_START, + EvoTooltipPosition.LEFT_START, + EvoTooltipPosition.RIGHT_START, +]; + +const END_POSITIONS_LIST: ReadonlyArray = [ + EvoTooltipPosition.TOP_END, + EvoTooltipPosition.BOTTOM_END, + EvoTooltipPosition.LEFT_END, + EvoTooltipPosition.RIGHT_END, +]; + +interface TooltipArrowCalcParams { + parentStart: number; + parentEnd: number; + tooltipStart: number; + tooltipEnd: number; + position: EvoTooltipPosition; +} @Component({ selector: 'evo-tooltip', @@ -26,51 +47,40 @@ import {EvoTooltipVariableArrowPosition} from './enums/evo-tooltip-variable-arro changeDetection: ChangeDetectionStrategy.OnPush, animations: [EVO_TOOLTIP_FADEIN_ANIMATION], }) -export class EvoTooltipComponent implements OnInit, AfterViewInit, OnDestroy { +export class EvoTooltipComponent implements AfterViewInit, OnDestroy { readonly position$: Observable = this.tooltipService.position$; readonly stringContent$: Observable = this.tooltipService.stringContent$; readonly templateContent$: Observable> = this.tooltipService.templateContent$; readonly visibleArrow$: Observable = this.tooltipService.visibleArrow$; - readonly styles$: Observable = EMPTY; + + readonly styles$: Observable = combineLatest([ + this.position$, + this.tooltipService.styles$, + this.tooltipService.parentRef$, + this.visibleArrow$, + ]).pipe( + map( + ([position, baseStyles, parentRef, visibleArrow]: [ + EvoTooltipPosition, + EvoTooltipStyles, + ElementRef, + boolean, + ]) => + visibleArrow && parentRef + ? {...baseStyles, ...this.calculateArrowStyles(parentRef, position)} + : baseStyles, + ), + ); @HostBinding('@fadeIn') fadeIn = true; - private readonly _positionArrowStyles$ = new BehaviorSubject(null); private readonly _destroy$ = new Subject(); constructor( private readonly elementRef: ElementRef, private readonly tooltipService: EvoTooltipService, private readonly renderer: Renderer2, - ) { - this.styles$ = combineLatest([this.tooltipService.styles$, this._positionArrowStyles$]).pipe( - map(([style1, style2]) => ({...style1, ...style2})), - ); - } - - ngOnInit(): void { - combineLatest([this.position$, this.tooltipService.parentRef$, this.visibleArrow$]) - .pipe( - filter(([_position, _parentRef, visibleArrow]) => visibleArrow), - // Вычисление стрелки нужно только для угловых позиций - filter(([position]) => { - switch (position) { - case EvoTooltipPosition.TOP: - case EvoTooltipPosition.RIGHT: - case EvoTooltipPosition.BOTTOM: - case EvoTooltipPosition.LEFT: - return false; - default: - return true; - } - }), - tap(([_, parentRef]) => { - this.setArrowPosition(parentRef); - }), - takeUntil(this._destroy$), - ) - .subscribe(); - } + ) {} ngAfterViewInit(): void { this.tooltipService.tooltipClasses$ @@ -92,34 +102,65 @@ export class EvoTooltipComponent implements OnInit, AfterViewInit, OnDestroy { this._destroy$.complete(); } - private setArrowPosition(parentRef: ElementRef): void { - // Для того чтобы стрелка тянулась к центру родителя - берем середину - const widthParent = parentRef.nativeElement.offsetWidth / 2; - const heightParent = parentRef.nativeElement.offsetHeight / 2; - const isParentLonger = widthParent >= this.elementRef.nativeElement.offsetWidth; - const isParentHigher = heightParent >= this.elementRef.nativeElement.offsetHeight; - // Если середина родителя оказывается меньше тултипа - берем середину родителя иначе размер тултипа - // Это проверка на максимальное смещение, смещение стрелки не должно быть больше размера тултипа - const width = isParentLonger ? this.elementRef.nativeElement.offsetWidth : widthParent; - const height = isParentHigher ? this.elementRef.nativeElement.offsetHeight : heightParent; - - const positionArrow = (size: number, isParentBigger: boolean): number => - // Если середина родителя больше тултипа - // То берем размер тултипа и отнимаем размер стрелки и радиуса - // Иначе берем середину родителя и отнимаем половину стрелки - // Это условие нужно чтобы стрелка не смещалась - isParentBigger ? size - EVO_TOOLTIP_ARROW_SIZE - EVO_TOOLTIP_RADIUS : size - EVO_TOOLTIP_ARROW_SIZE / 2; - let verticalPositionArrow = positionArrow(height, isParentHigher); - let horizontalPositionArrow = positionArrow(width, isParentLonger); - - // Проверка на минимальное смещение, смещение стрелки не должно быть меньше размера радиуса тултипа 8px - horizontalPositionArrow = - horizontalPositionArrow > EVO_TOOLTIP_RADIUS ? horizontalPositionArrow : EVO_TOOLTIP_RADIUS; - verticalPositionArrow = verticalPositionArrow > EVO_TOOLTIP_RADIUS ? verticalPositionArrow : EVO_TOOLTIP_RADIUS; - - this._positionArrowStyles$.next({ - [EvoTooltipVariableArrowPosition.VERTICAL_POSITION_ARROW]: `${verticalPositionArrow}px`, - [EvoTooltipVariableArrowPosition.HORIZONTAL_POSITION_ARROW]: `${horizontalPositionArrow}px`, + private getArrowOffset(params: TooltipArrowCalcParams): number { + const tooltipSize = params.tooltipEnd - params.tooltipStart; + + // tooltip after the parent + if (params.parentEnd < params.tooltipStart) { + return -EVO_TOOLTIP_ARROW_SIZE; + } + + // tooltip before the parent + if (params.parentStart > params.tooltipEnd) { + return tooltipSize; + } + + const parentCenter = params.parentStart + (params.parentEnd - params.parentStart) / 2; + const safeBoundary = EVO_TOOLTIP_RADIUS + EVO_TOOLTIP_ARROW_SIZE / 2; + + let idealTargetOnScreen: number; + + if (START_POSITIONS_LIST.includes(params.position)) { + idealTargetOnScreen = Math.min(parentCenter, params.parentStart + safeBoundary); + } else if (END_POSITIONS_LIST.includes(params.position)) { + idealTargetOnScreen = Math.max(parentCenter, params.parentEnd - safeBoundary); + } else { + idealTargetOnScreen = parentCenter; + } + + // target in tooltip coordinates + const initialOffset = idealTargetOnScreen - params.tooltipStart - EVO_TOOLTIP_ARROW_SIZE / 2; + + // clamping + const minPosition = EVO_TOOLTIP_RADIUS; + const maxPosition = tooltipSize - EVO_TOOLTIP_RADIUS - EVO_TOOLTIP_ARROW_SIZE; + + return Math.round(Math.max(minPosition, Math.min(initialOffset, maxPosition))); + } + + private calculateArrowStyles(parentRef: ElementRef, position: EvoTooltipPosition): EvoTooltipStyles { + const parentRect = (parentRef.nativeElement as HTMLElement).getBoundingClientRect(); + const tooltipRect = (this.elementRef.nativeElement as HTMLElement).getBoundingClientRect(); + + const vertical = this.getArrowOffset({ + parentStart: parentRect.top, + parentEnd: parentRect.bottom, + tooltipStart: tooltipRect.top, + tooltipEnd: tooltipRect.bottom, + position, }); + + const horizontal = this.getArrowOffset({ + parentStart: parentRect.left, + parentEnd: parentRect.right, + tooltipStart: tooltipRect.left, + tooltipEnd: tooltipRect.right, + position, + }); + + return { + [EvoTooltipStyleVariable.VERTICAL_POSITION_ARROW]: `${vertical}px`, + [EvoTooltipStyleVariable.HORIZONTAL_POSITION_ARROW]: `${horizontal}px`, + }; } } diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/interfaces/evo-tooltip-styles.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/interfaces/evo-tooltip-styles.ts index ff1afa77f..0c0026bdc 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/interfaces/evo-tooltip-styles.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/interfaces/evo-tooltip-styles.ts @@ -1,5 +1,14 @@ -import {EvoTooltipVariableArrowPosition} from '../enums/evo-tooltip-variable-arrow-position'; +import {EvoTooltipStyleVariable} from '../enums/evo-tooltip-style-variable'; export type EvoTooltipStyles = { - [key in EvoTooltipVariableArrowPosition]: string; + [EvoTooltipStyleVariable.HORIZONTAL_POSITION_ARROW]?: string; + [EvoTooltipStyleVariable.VERTICAL_POSITION_ARROW]?: string; + + [EvoTooltipStyleVariable.COLOR]?: string; + [EvoTooltipStyleVariable.BACKGROUND_COLOR]?: string; + + [EvoTooltipStyleVariable.MAX_WIDTH]?: string | number; + + [EvoTooltipStyleVariable.PADDING]?: string | number; + [EvoTooltipStyleVariable.BORDER_RADIUS]?: string | number; }; diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts index 2bffe6f99..1c5740045 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts @@ -4,7 +4,7 @@ import {EvoTooltipPosition} from '../enums/evo-tooltip-position'; import {EvoTooltipStyles} from '../interfaces/evo-tooltip-styles'; import {ElementRef, NO_ERRORS_SCHEMA} from '@angular/core'; import {first} from 'rxjs/operators'; -import {EvoTooltipVariableArrowPosition} from '../enums/evo-tooltip-variable-arrow-position'; +import {EvoTooltipStyleVariable} from '../enums/evo-tooltip-style-variable'; import {CommonModule} from '@angular/common'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {EvoTooltipComponent} from '../evo-tooltip.component'; @@ -43,8 +43,8 @@ describe('EvoTooltipService', () => { it('should set tooltip styles', () => { const styles: EvoTooltipStyles = { - [EvoTooltipVariableArrowPosition.VERTICAL_POSITION_ARROW]: '10px', - [EvoTooltipVariableArrowPosition.HORIZONTAL_POSITION_ARROW]: '20px', + [EvoTooltipStyleVariable.VERTICAL_POSITION_ARROW]: '10px', + [EvoTooltipStyleVariable.HORIZONTAL_POSITION_ARROW]: '20px', }; service.setTooltipStyles(styles); service.styles$.pipe(first()).subscribe((value) => { From c4f58ea74304c343f947bba0f25e95c00bb5f5ef Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Wed, 13 May 2026 09:44:55 +0300 Subject: [PATCH 06/11] fix(evo-tooltip): fix tests --- .../directives/evo-tooltip.directive.spec.ts | 31 +-- .../evo-tooltip/evo-tooltip.component.spec.ts | 177 +++++++++++------- .../evo-tooltip/evo-tooltip.component.ts | 65 +------ .../interfaces/tooltip-arrow-calc-params.ts | 9 + .../services/evo-tooltip.service.spec.ts | 20 +- .../utils/get-tooltip-arrow-offset.spec.ts | 120 ++++++++++++ .../utils/get-tooltip-arrow-offset.ts | 54 ++++++ 7 files changed, 330 insertions(+), 146 deletions(-) create mode 100644 projects/evo-ui-kit/src/lib/components/evo-tooltip/interfaces/tooltip-arrow-calc-params.ts create mode 100644 projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-arrow-offset.spec.ts create mode 100644 projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-arrow-offset.ts diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts index 3c4062df5..8520632a3 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts @@ -1,6 +1,6 @@ import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; import {EvoTooltipDirective} from './evo-tooltip.directive'; -import {Component, NO_ERRORS_SCHEMA} from '@angular/core'; +import {Component, DebugElement, 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'; @@ -9,11 +9,12 @@ 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 {By} from '@angular/platform-browser'; @Component({ template: `
{ let component: TestHostComponent; let fixture: ComponentFixture; + let directiveDebugEl: DebugElement; let directive: EvoTooltipDirective; let tooltipService: EvoTooltipService; @@ -57,7 +60,8 @@ describe('EvoTooltipDirective', () => { fixture = TestBed.createComponent(TestHostComponent); component = fixture.componentInstance; - directive = fixture.debugElement.children[0].injector.get(EvoTooltipDirective); + directiveDebugEl = fixture.debugElement.query(By.directive(EvoTooltipDirective)); + directive = directiveDebugEl.injector.get(EvoTooltipDirective); tooltipService = TestBed.inject(EvoTooltipService); fixture.detectChanges(); }); @@ -67,15 +71,18 @@ describe('EvoTooltipDirective', () => { }); it('should have correct host classes', () => { - const element = fixture.debugElement.children[0].nativeElement; - expect(element.classList.contains('evo-tooltip-trigger')).toBeTrue(); + const element = fixture.debugElement.query(By.css('.evo-tooltip-trigger')); + + expect(element).toBeTruthy(); }); 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(); + + const element = fixture.debugElement.query(By.css('.evo-tooltip-trigger_disabled')); + + expect(element).toBeTruthy(); }); it('should emit open event when tooltip is shown', fakeAsync(() => { @@ -105,7 +112,9 @@ describe('EvoTooltipDirective', () => { })); it('should not show tooltip when content is empty', fakeAsync(() => { - directive.content = null; + component.tooltipContent = null; + fixture.detectChanges(); + directive.show(); tick(0); fixture.detectChanges(); @@ -113,8 +122,7 @@ describe('EvoTooltipDirective', () => { })); it('should handle mouseenter event', fakeAsync(() => { - const element = fixture.debugElement.children[0].nativeElement; - element.dispatchEvent(new MouseEvent('mouseenter')); + directiveDebugEl.triggerEventHandler('mouseenter', null); tick(0); fixture.detectChanges(); tooltipService.isOpen$.pipe(first()).subscribe((isOpen) => { @@ -123,8 +131,7 @@ describe('EvoTooltipDirective', () => { })); it('should handle touchstart event', fakeAsync(() => { - const element = fixture.debugElement.children[0].nativeElement; - element.dispatchEvent(new MouseEvent('touchstart')); + directiveDebugEl.triggerEventHandler('touchstart', null); tick(0); fixture.detectChanges(); tooltipService.isOpen$.pipe(first()).subscribe((isOpen) => { diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.spec.ts index 0c5310daa..d9fc0ccd1 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.spec.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.spec.ts @@ -1,112 +1,155 @@ -import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; +import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; import {EvoTooltipComponent} from './evo-tooltip.component'; import {EvoTooltipService} from './services/evo-tooltip.service'; -import {NO_ERRORS_SCHEMA, Component, ViewChild, TemplateRef} from '@angular/core'; -import {EvoTooltipPosition} from './enums/evo-tooltip-position'; +import {Component, ElementRef, getDebugNode, NO_ERRORS_SCHEMA, TemplateRef, ViewChild} from '@angular/core'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; -import {EvoTooltipStyles} from './interfaces/evo-tooltip-styles'; import {CommonModule} from '@angular/common'; +import {OverlayContainer} from '@angular/cdk/overlay'; +import {EvoTooltipPosition} from './enums/evo-tooltip-position'; import {EvoTooltipStyleVariable} from './enums/evo-tooltip-style-variable'; @Component({ selector: 'evo-host-component', template: ` - +
Hover me
+ -
Test template content
+
Test template content
`, }) class TestHostComponent { - @ViewChild(EvoTooltipComponent, {static: true}) tooltipComponent: EvoTooltipComponent; + @ViewChild('trigger', {static: true}) triggerEl: ElementRef; @ViewChild('testTemplate', {static: true}) testTemplate: TemplateRef; } describe('EvoTooltipComponent', () => { + const textTooltipContent = 'Text tooltip content'; + + let overlayContainer: OverlayContainer; + let overlayContainerElement: HTMLElement; + let testHostComponent: TestHostComponent; let testHostFixture: ComponentFixture; - let tooltipComponent: EvoTooltipComponent; let tooltipService: EvoTooltipService; - beforeEach( - waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [EvoTooltipComponent, TestHostComponent], - imports: [BrowserAnimationsModule, CommonModule], - schemas: [NO_ERRORS_SCHEMA], - providers: [EvoTooltipService], - }).compileComponents(); - }), - ); - - beforeEach(() => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [EvoTooltipComponent, TestHostComponent], + imports: [BrowserAnimationsModule, CommonModule], + schemas: [NO_ERRORS_SCHEMA], + providers: [EvoTooltipService], + }).compileComponents(); + + overlayContainer = TestBed.inject(OverlayContainer); + overlayContainerElement = overlayContainer.getContainerElement(); + testHostFixture = TestBed.createComponent(TestHostComponent); testHostComponent = testHostFixture.componentInstance; - tooltipComponent = testHostComponent.tooltipComponent; + tooltipService = TestBed.inject(EvoTooltipService); testHostFixture.detectChanges(); }); - it('should create', () => { - expect(tooltipComponent).toBeTruthy(); + afterEach(() => { + overlayContainer.ngOnDestroy(); }); - it('should update position when position$ changes', () => { - const position = EvoTooltipPosition.TOP; - tooltipService['_position$'].next(position); + const showTooltip = (content: string | TemplateRef, position = EvoTooltipPosition.TOP): HTMLElement => { + tooltipService.showTooltip(testHostComponent.triggerEl, content, position); + tick(); testHostFixture.detectChanges(); - tooltipComponent.position$.subscribe((value) => { - expect(value).toBe(position); - }); - }); + return overlayContainerElement.querySelector('evo-tooltip'); + }; - it('should update string content when stringContent$ changes', () => { - const content = 'Test content'; - tooltipService['_stringContent$'].next(content); - testHostFixture.detectChanges(); + const getTooltipElementFromHost = (host: HTMLElement): HTMLElement => { + return host.querySelector('.evo-tooltip'); + }; - tooltipComponent.stringContent$.subscribe((value) => { - expect(value).toBe(content); - }); - }); + it('should create', fakeAsync(() => { + const tooltipEl = showTooltip(textTooltipContent); - it('should update template content when templateContent$ changes', () => { - const template = testHostComponent.testTemplate; - tooltipService['_templateContent$'].next(template); - testHostFixture.detectChanges(); + expect(tooltipEl).toBeTruthy(); + })); - tooltipComponent.templateContent$.subscribe((value) => { - expect(value).toBe(template); - }); - }); + it('should render correct string content when string is passed', fakeAsync(() => { + const tooltipHost = showTooltip(textTooltipContent); - it('should update styles when styles$ changes', () => { - const styles: EvoTooltipStyles = { - [EvoTooltipStyleVariable.MAX_WIDTH]: 'auto', - [EvoTooltipStyleVariable.PADDING]: 0, - }; - tooltipService['_styles$'].next(styles); - testHostFixture.detectChanges(); + expect(tooltipHost.textContent?.trim()).toBe(textTooltipContent); + })); - tooltipComponent.styles$.subscribe((value) => { - expect(value).toEqual(styles); - }); - }); + it('should render correct template content when template is passed', fakeAsync(() => { + const tooltipHost = showTooltip(testHostComponent.testTemplate); + + expect(tooltipHost.querySelector('.tooltip-custom-template')).toBeTruthy(); + })); + + it('should apply the correct host classes', fakeAsync(() => { + const tooltipHost = showTooltip(textTooltipContent); - it('should update visible arrow when visibleArrow$ changes', () => { - const visibleArrow = false; - tooltipService['_visibleArrow$'].next(visibleArrow); + tooltipService.setTooltipClass(['dynamic-class-1', 'dynamic-class-2']); testHostFixture.detectChanges(); - tooltipComponent.visibleArrow$.subscribe((value) => { - expect(value).toBe(visibleArrow); + expect(tooltipHost.classList.contains('dynamic-class-1')).toBeTrue(); + expect(tooltipHost.classList.contains('dynamic-class-2')).toBeTrue(); + })); + + it('should apply custom styles to the style attribute', fakeAsync(() => { + const testBackground = 'red'; + const testPadding = '12px'; + + + tooltipService.setTooltipStyles({ + [EvoTooltipStyleVariable.BACKGROUND_COLOR]: testBackground, + [EvoTooltipStyleVariable.PADDING]: testPadding, }); - }); - it('should unsubscribe from all observables on destroy', () => { - const destroySpy = spyOn(tooltipComponent['_destroy$'], 'next'); + const tooltipHost = showTooltip(textTooltipContent); + const tooltip = getTooltipElementFromHost(tooltipHost); + + expect(tooltip.style.getPropertyValue(EvoTooltipStyleVariable.BACKGROUND_COLOR)).toBe(testBackground); + expect(tooltip.style.getPropertyValue(EvoTooltipStyleVariable.PADDING)).toBe(testPadding); + })); + + it('should apply the correct position modifier class', fakeAsync(() => { + const position = EvoTooltipPosition.TOP; + const tooltipHost = showTooltip(textTooltipContent, position); + const tooltip = getTooltipElementFromHost(tooltipHost); + + expect(tooltip.classList.contains(`evo-tooltip_${position}`)).toBeTrue(); + })); + + it('should add "not-arrow" class when arrow is hidden', fakeAsync(() => { + tooltipService.setArrowVisibility(false); + const tooltipHost = showTooltip(textTooltipContent); + const tooltip = getTooltipElementFromHost(tooltipHost); + + expect(tooltip?.classList.contains('evo-tooltip_not-arrow')).toBeTrue(); + })); + + it('should apply arrow positions to the style attribute', fakeAsync(() => { + tooltipService.setArrowVisibility(true); + + const tooltipHost = showTooltip(textTooltipContent); + const tooltip = getTooltipElementFromHost(tooltipHost); + + expect(tooltip.style.getPropertyValue(EvoTooltipStyleVariable.HORIZONTAL_POSITION_ARROW)).toBeTruthy(); + expect(tooltip.style.getPropertyValue(EvoTooltipStyleVariable.VERTICAL_POSITION_ARROW)).toBeTruthy(); + })); + + it('should unsubscribe from service updates on destroy', fakeAsync(() => { + const tooltipHost = showTooltip(textTooltipContent); + + const tooltipComponent = getDebugNode(tooltipHost).componentInstance; + tooltipComponent.ngOnDestroy(); - expect(destroySpy).toHaveBeenCalled(); - }); + + const classTest = 'should-not-be-applied'; + + tooltipService.setTooltipClass([classTest]); + testHostFixture.detectChanges(); + + expect(tooltipHost.classList.contains(classTest)).toBeFalse(); + })); }); diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.ts index 832ceb6e3..9574670cc 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.ts @@ -14,31 +14,8 @@ import {EVO_TOOLTIP_FADEIN_ANIMATION} from './constants/evo-tooltip-fadein.anima import {EvoTooltipService} from './services/evo-tooltip.service'; import {EvoTooltipStyles} from './interfaces/evo-tooltip-styles'; import {EvoTooltipPosition} from './enums/evo-tooltip-position'; -import {EVO_TOOLTIP_ARROW_SIZE} from './constants/evo-tooltip-arrow-size'; -import {EVO_TOOLTIP_RADIUS} from './constants/evo-tooltip-radius'; import {EvoTooltipStyleVariable} from './enums/evo-tooltip-style-variable'; - -const START_POSITIONS_LIST: ReadonlyArray = [ - EvoTooltipPosition.TOP_START, - EvoTooltipPosition.BOTTOM_START, - EvoTooltipPosition.LEFT_START, - EvoTooltipPosition.RIGHT_START, -]; - -const END_POSITIONS_LIST: ReadonlyArray = [ - EvoTooltipPosition.TOP_END, - EvoTooltipPosition.BOTTOM_END, - EvoTooltipPosition.LEFT_END, - EvoTooltipPosition.RIGHT_END, -]; - -interface TooltipArrowCalcParams { - parentStart: number; - parentEnd: number; - tooltipStart: number; - tooltipEnd: number; - position: EvoTooltipPosition; -} +import {getTooltipArrowOffset} from './utils/get-tooltip-arrow-offset'; @Component({ selector: 'evo-tooltip', @@ -102,47 +79,11 @@ export class EvoTooltipComponent implements AfterViewInit, OnDestroy { this._destroy$.complete(); } - private getArrowOffset(params: TooltipArrowCalcParams): number { - const tooltipSize = params.tooltipEnd - params.tooltipStart; - - // tooltip after the parent - if (params.parentEnd < params.tooltipStart) { - return -EVO_TOOLTIP_ARROW_SIZE; - } - - // tooltip before the parent - if (params.parentStart > params.tooltipEnd) { - return tooltipSize; - } - - const parentCenter = params.parentStart + (params.parentEnd - params.parentStart) / 2; - const safeBoundary = EVO_TOOLTIP_RADIUS + EVO_TOOLTIP_ARROW_SIZE / 2; - - let idealTargetOnScreen: number; - - if (START_POSITIONS_LIST.includes(params.position)) { - idealTargetOnScreen = Math.min(parentCenter, params.parentStart + safeBoundary); - } else if (END_POSITIONS_LIST.includes(params.position)) { - idealTargetOnScreen = Math.max(parentCenter, params.parentEnd - safeBoundary); - } else { - idealTargetOnScreen = parentCenter; - } - - // target in tooltip coordinates - const initialOffset = idealTargetOnScreen - params.tooltipStart - EVO_TOOLTIP_ARROW_SIZE / 2; - - // clamping - const minPosition = EVO_TOOLTIP_RADIUS; - const maxPosition = tooltipSize - EVO_TOOLTIP_RADIUS - EVO_TOOLTIP_ARROW_SIZE; - - return Math.round(Math.max(minPosition, Math.min(initialOffset, maxPosition))); - } - private calculateArrowStyles(parentRef: ElementRef, position: EvoTooltipPosition): EvoTooltipStyles { const parentRect = (parentRef.nativeElement as HTMLElement).getBoundingClientRect(); const tooltipRect = (this.elementRef.nativeElement as HTMLElement).getBoundingClientRect(); - const vertical = this.getArrowOffset({ + const vertical = getTooltipArrowOffset({ parentStart: parentRect.top, parentEnd: parentRect.bottom, tooltipStart: tooltipRect.top, @@ -150,7 +91,7 @@ export class EvoTooltipComponent implements AfterViewInit, OnDestroy { position, }); - const horizontal = this.getArrowOffset({ + const horizontal = getTooltipArrowOffset({ parentStart: parentRect.left, parentEnd: parentRect.right, tooltipStart: tooltipRect.left, diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/interfaces/tooltip-arrow-calc-params.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/interfaces/tooltip-arrow-calc-params.ts new file mode 100644 index 000000000..fb081c7b7 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/interfaces/tooltip-arrow-calc-params.ts @@ -0,0 +1,9 @@ +import {EvoTooltipPosition} from '../enums/evo-tooltip-position'; + +export interface TooltipArrowCalcParams { + parentStart: number; + parentEnd: number; + tooltipStart: number; + tooltipEnd: number; + position: EvoTooltipPosition; +} diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts index 1c5740045..d1394e955 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts @@ -1,4 +1,4 @@ -import {TestBed} from '@angular/core/testing'; +import {fakeAsync, TestBed, tick} from '@angular/core/testing'; import {EvoTooltipService} from './evo-tooltip.service'; import {EvoTooltipPosition} from '../enums/evo-tooltip-position'; import {EvoTooltipStyles} from '../interfaces/evo-tooltip-styles'; @@ -93,12 +93,22 @@ describe('EvoTooltipService', () => { }); }); - it('should hide tooltip', () => { - service.hideTooltip(); + it('should hide tooltip', fakeAsync(() => { + let isOpenResult: boolean | null = null; + let hasAttachedResult: boolean | null = null; + service.isOpen$.pipe(first()).subscribe((value) => { - expect(value).toBeFalse(); + isOpenResult = value; + hasAttachedResult = service.hasAttached; }); - }); + + service.hideTooltip(); + + tick(); + + expect(isOpenResult).toBe(false); + expect(hasAttachedResult).toBe(false); + })); it('should check if tooltip is attached', () => { expect(service.hasAttached).toBeFalse(); diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-arrow-offset.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-arrow-offset.spec.ts new file mode 100644 index 000000000..361587997 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-arrow-offset.spec.ts @@ -0,0 +1,120 @@ +import {EvoTooltipPosition} from 'projects/evo-ui-kit/src/public_api'; +import {getTooltipArrowOffset} from './get-tooltip-arrow-offset'; +import {EVO_TOOLTIP_ARROW_SIZE} from '../constants/evo-tooltip-arrow-size'; +import {EVO_TOOLTIP_RADIUS} from '../constants/evo-tooltip-radius'; +import {TooltipArrowCalcParams} from '../interfaces/tooltip-arrow-calc-params'; + +interface TestCase { + description: string; + params: TooltipArrowCalcParams; + result: number; +} + +describe('getTooltipArrowOffset', () => { + const testCases: TestCase[] = [ + { + description: + 'should calculate correct offset when tooltip is completely BEFORE the parent (no intersection)', + params: { + tooltipStart: 0, + tooltipEnd: 100, + parentStart: 120, + parentEnd: 200, + position: EvoTooltipPosition.LEFT, + }, + // equals tooltip size (100) + result: 100, + }, + { + description: 'should calculate correct offset when tooltip is aligned to parent start', + params: { + tooltipStart: 0, + tooltipEnd: 100, + parentStart: 0, + parentEnd: 200, + position: EvoTooltipPosition.TOP_START, + }, + result: EVO_TOOLTIP_RADIUS, + }, + { + description: 'should calculate correct offset when tooltip is aligned to parent center', + params: { + tooltipStart: 40, + tooltipEnd: 200, + parentStart: 0, + parentEnd: 240, + position: EvoTooltipPosition.TOP, + }, + // parentCenter (120) - tooltipStart (40) - arrow half (8) + result: 120 - 40 - EVO_TOOLTIP_ARROW_SIZE / 2, + }, + { + description: 'should calculate correct offset when tooltip is aligned to parent end', + params: { + tooltipStart: 0, + tooltipEnd: 200, + parentStart: 160, + parentEnd: 200, + position: EvoTooltipPosition.TOP_END, + }, + result: 200 - EVO_TOOLTIP_RADIUS - EVO_TOOLTIP_ARROW_SIZE, // // parentEnd (200) - tooltipStart (0) - radius (8) - arrow size (16) + }, + { + description: + 'should calculate correct offset when tooltip is completely AFTER the parent (no intersection)', + params: { + tooltipStart: 210, + tooltipEnd: 300, + parentStart: 0, + parentEnd: 200, + position: EvoTooltipPosition.RIGHT, + }, + // negative arrow width as a fallback offset + result: -EVO_TOOLTIP_ARROW_SIZE, + }, + { + description: 'should center the arrow on a micro parent (16px)', + params: { + tooltipStart: 40, + tooltipEnd: 190, + parentStart: 100, + parentEnd: 116, + position: EvoTooltipPosition.TOP, + }, + // parentCenter (108) - tooltip start (40) - arrow half (8) = 60 + result: 108 - 40 - EVO_TOOLTIP_ARROW_SIZE / 2, + }, + { + description: 'should calculate correct offset when parent is micro (16px) and position is TOP_START', + params: { + tooltipStart: 90, + tooltipEnd: 240, + parentStart: 100, + parentEnd: 116, + position: EvoTooltipPosition.TOP_START, + }, + // parent center (108) - tooltip start (90) - arrow half (8) = 10 + result: 108 - 90 - EVO_TOOLTIP_ARROW_SIZE / 2, + }, + { + description: 'should calculate correct offset when parent is micro (16px) and position is TOP_END', + params: { + tooltipStart: 0, + tooltipEnd: 120, + parentStart: 100, + parentEnd: 116, + position: EvoTooltipPosition.TOP_END, + }, + // tooltip size (120) - radius (8) - arrow (16) = 96 + result: 120 - EVO_TOOLTIP_RADIUS - EVO_TOOLTIP_ARROW_SIZE, + }, + ]; + + testCases.forEach((testCase) => + it(testCase.description, () => { + const result = getTooltipArrowOffset(testCase.params); + + expect(result).toBe(testCase.result); + }), + ); +}); diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-arrow-offset.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-arrow-offset.ts new file mode 100644 index 000000000..f0ce105c8 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-arrow-offset.ts @@ -0,0 +1,54 @@ +import {EVO_TOOLTIP_ARROW_SIZE} from '../constants/evo-tooltip-arrow-size'; +import {EVO_TOOLTIP_RADIUS} from '../constants/evo-tooltip-radius'; +import {TooltipArrowCalcParams} from '../interfaces/tooltip-arrow-calc-params'; +import {EvoTooltipPosition} from '../enums/evo-tooltip-position'; + +const START_POSITIONS_LIST: ReadonlyArray = [ + EvoTooltipPosition.TOP_START, + EvoTooltipPosition.BOTTOM_START, + EvoTooltipPosition.LEFT_START, + EvoTooltipPosition.RIGHT_START, +]; + +const END_POSITIONS_LIST: ReadonlyArray = [ + EvoTooltipPosition.TOP_END, + EvoTooltipPosition.BOTTOM_END, + EvoTooltipPosition.LEFT_END, + EvoTooltipPosition.RIGHT_END, +]; + +export function getTooltipArrowOffset(params: TooltipArrowCalcParams): number { + const tooltipSize = params.tooltipEnd - params.tooltipStart; + + // tooltip after the parent + if (params.parentEnd < params.tooltipStart) { + return -EVO_TOOLTIP_ARROW_SIZE; + } + + // tooltip before the parent + if (params.parentStart > params.tooltipEnd) { + return tooltipSize; + } + + const parentCenter = params.parentStart + (params.parentEnd - params.parentStart) / 2; + const safeBoundary = EVO_TOOLTIP_RADIUS + EVO_TOOLTIP_ARROW_SIZE / 2; + + let idealTargetOnScreen: number; + + if (START_POSITIONS_LIST.includes(params.position)) { + idealTargetOnScreen = Math.min(parentCenter, params.parentStart + safeBoundary); + } else if (END_POSITIONS_LIST.includes(params.position)) { + idealTargetOnScreen = Math.max(parentCenter, params.parentEnd - safeBoundary); + } else { + idealTargetOnScreen = parentCenter; + } + + // target in tooltip coordinates + const initialOffset = idealTargetOnScreen - params.tooltipStart - EVO_TOOLTIP_ARROW_SIZE / 2; + + // clamping + const minPosition = EVO_TOOLTIP_RADIUS; + const maxPosition = tooltipSize - EVO_TOOLTIP_RADIUS - EVO_TOOLTIP_ARROW_SIZE; + + return Math.round(Math.max(minPosition, Math.min(initialOffset, maxPosition))); +} From f812eec3a65fad8003baf3fe8d8dc087eb52997a Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Wed, 13 May 2026 17:06:46 +0300 Subject: [PATCH 07/11] refactor(evo-tooltip-service): isolate position logic in utils --- .../directives/evo-tooltip.directive.ts | 15 ++- .../evo-tooltip/evo-tooltip.component.spec.ts | 19 ++-- .../services/evo-tooltip.service.spec.ts | 37 ++++--- .../services/evo-tooltip.service.ts | 97 +++---------------- .../utils/get-tooltip-connected-positions.ts | 57 +++++++++++ .../utils/get-tooltip-positions-order.spec.ts | 58 +++++++++++ .../utils/get-tooltip-positions-order.ts | 20 ++++ 7 files changed, 194 insertions(+), 109 deletions(-) create mode 100644 projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-connected-positions.ts create mode 100644 projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-positions-order.spec.ts create mode 100644 projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-positions-order.ts diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.ts index e38e899e4..8d448b7cc 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.ts @@ -29,9 +29,7 @@ export class EvoTooltipDirective implements OnInit, OnDestroy { @Input('evoTooltipDisabled') disabled = false; @Input('evoTooltipConfig') config: Partial; - @Input() set evoTooltipVisibleArrow(visibleArrow: boolean) { - this.tooltipService.setArrowVisibility(visibleArrow); - } + @Input('evoTooltipVisibleArrow') visibleArrow = true; @Input() set evoTooltipStyles(tooltipStyles: EvoTooltipStyles) { this.tooltipService.setTooltipStyles(tooltipStyles); @@ -71,11 +69,12 @@ export class EvoTooltipDirective implements OnInit, OnDestroy { return; } - const tooltip = this.tooltipService.showTooltip( - this.elementRef, - this.content, - this.position as EvoTooltipPosition, - ); + const tooltip = this.tooltipService.showTooltip({ + parentRef: this.elementRef, + content: this.content, + position: this.position as EvoTooltipPosition, + hasArrow: this.visibleArrow, + }); this.initHideSubscription(tooltip); } diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.spec.ts index d9fc0ccd1..95afdeea1 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.spec.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.spec.ts @@ -55,8 +55,17 @@ describe('EvoTooltipComponent', () => { overlayContainer.ngOnDestroy(); }); - const showTooltip = (content: string | TemplateRef, position = EvoTooltipPosition.TOP): HTMLElement => { - tooltipService.showTooltip(testHostComponent.triggerEl, content, position); + const showTooltip = ( + content: string | TemplateRef, + position = EvoTooltipPosition.TOP, + hasArrow = true, + ): HTMLElement => { + tooltipService.showTooltip({ + parentRef: testHostComponent.triggerEl, + content, + position, + hasArrow, + }); tick(); testHostFixture.detectChanges(); @@ -99,7 +108,6 @@ describe('EvoTooltipComponent', () => { const testBackground = 'red'; const testPadding = '12px'; - tooltipService.setTooltipStyles({ [EvoTooltipStyleVariable.BACKGROUND_COLOR]: testBackground, [EvoTooltipStyleVariable.PADDING]: testPadding, @@ -121,16 +129,13 @@ describe('EvoTooltipComponent', () => { })); it('should add "not-arrow" class when arrow is hidden', fakeAsync(() => { - tooltipService.setArrowVisibility(false); - const tooltipHost = showTooltip(textTooltipContent); + const tooltipHost = showTooltip(textTooltipContent, EvoTooltipPosition.TOP, false); const tooltip = getTooltipElementFromHost(tooltipHost); expect(tooltip?.classList.contains('evo-tooltip_not-arrow')).toBeTrue(); })); it('should apply arrow positions to the style attribute', fakeAsync(() => { - tooltipService.setArrowVisibility(true); - const tooltipHost = showTooltip(textTooltipContent); const tooltip = getTooltipElementFromHost(tooltipHost); diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts index d1394e955..ceae12937 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts @@ -34,12 +34,20 @@ describe('EvoTooltipService', () => { expect(service).toBeTruthy(); }); - it('should set arrow visibility', () => { - service.setArrowVisibility(false); + it('should set arrow visibility', fakeAsync(() => { + service.showTooltip({ + parentRef: elementRef, + content: 'Test content', + position: EvoTooltipPosition.TOP, + hasArrow: false, + }); + + tick(); + service.visibleArrow$.pipe(first()).subscribe((value) => { expect(value).toBeFalse(); }); - }); + })); it('should set tooltip styles', () => { const styles: EvoTooltipStyles = { @@ -78,7 +86,11 @@ describe('EvoTooltipService', () => { const content = 'Test content'; const position = EvoTooltipPosition.TOP; - service.showTooltip(elementRef, content, position); + service.showTooltip({ + parentRef: elementRef, + content, + position, + }); service.stringContent$.pipe(first()).subscribe((value) => { expect(value).toBe(content); @@ -94,20 +106,23 @@ describe('EvoTooltipService', () => { }); it('should hide tooltip', fakeAsync(() => { - let isOpenResult: boolean | null = null; - let hasAttachedResult: boolean | null = null; + service.showTooltip({ + parentRef: elementRef, + content: 'Test content', + position: EvoTooltipPosition.TOP, + hasArrow: true, + }); + + tick(); service.isOpen$.pipe(first()).subscribe((value) => { - isOpenResult = value; - hasAttachedResult = service.hasAttached; + expect(value).toBeFalse(); + expect(service.hasAttached).toBeFalse(); }); service.hideTooltip(); tick(); - - expect(isOpenResult).toBe(false); - expect(hasAttachedResult).toBe(false); })); it('should check if tooltip is attached', () => { diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.ts index 6551f9973..fe84f7387 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.ts @@ -1,23 +1,12 @@ import {ComponentRef, ElementRef, Injectable, Injector, OnDestroy, TemplateRef} from '@angular/core'; import {ComponentPortal} from '@angular/cdk/portal'; -import { - ConnectedPosition, - FlexibleConnectedPositionStrategy, - Overlay, - OverlayPositionBuilder, - OverlayRef, -} from '@angular/cdk/overlay'; +import {FlexibleConnectedPositionStrategy, Overlay, OverlayPositionBuilder, OverlayRef} from '@angular/cdk/overlay'; import {BehaviorSubject, EMPTY, merge, Observable, Subject} from 'rxjs'; import {filter, take, takeUntil, tap} from 'rxjs/operators'; import {EvoTooltipComponent} from '../evo-tooltip.component'; import {EvoTooltipPosition} from '../enums/evo-tooltip-position'; -import {EVO_CONNECTED_POSITION} from '../constants/evo-tooltip-connected-position'; -import {EVO_PRIORITY_POSITIONS_ORDER} from '../constants/evo-tooltip-priority-positions-order'; -import {EVO_DEFAULT_POSITIONS_ORDER} from '../constants/evo-tooltip-default-positions-order'; -import {EVO_TOOLTIP_ARROW_SIZE} from '../constants/evo-tooltip-arrow-size'; -import {EVO_TOOLTIP_RADIUS} from '../constants/evo-tooltip-radius'; import {EvoTooltipStyles} from '../interfaces/evo-tooltip-styles'; -import {EVO_TOOLTIP_OFFSET} from '../constants/evo-tooltip-offset'; +import {getTooltipConnectedPositions} from '../utils/get-tooltip-connected-positions'; @Injectable() export class EvoTooltipService implements OnDestroy { @@ -68,16 +57,20 @@ export class EvoTooltipService implements OnDestroy { this.destroy$.complete(); } - showTooltip( - parentRef: ElementRef, - content: string | TemplateRef, - position: EvoTooltipPosition, - ): HTMLElement { + showTooltip(params: { + parentRef: ElementRef; + content: string | TemplateRef; + position?: EvoTooltipPosition; + hasArrow?: boolean; + }): HTMLElement { + const {parentRef, content, position = EvoTooltipPosition.BOTTOM, hasArrow = true} = params; + this._parentRef$.next(parentRef); + this._visibleArrow$.next(hasArrow); this.setContent(content); this._position$.next(position); - this.createOverlay(parentRef, position); + this.createOverlay(parentRef, position, hasArrow); this.createPortal(); this._isOpen$.next(this.hasAttached); @@ -96,10 +89,6 @@ export class EvoTooltipService implements OnDestroy { this._isOpen$.next(!!this.overlayRef?.hasAttached()); } - setArrowVisibility(hasArrow: boolean): void { - this._visibleArrow$.next(hasArrow); - } - setTooltipStyles(tooltipStyles: EvoTooltipStyles): void { this._styles$.next(tooltipStyles); } @@ -122,10 +111,10 @@ export class EvoTooltipService implements OnDestroy { } } - private createOverlay(parentRef: ElementRef, position: EvoTooltipPosition): void { + private createOverlay(parentRef: ElementRef, position: EvoTooltipPosition, hasArrow: boolean): void { this.positionStrategy = this.overlayPositionBuilder .flexibleConnectedTo(parentRef) - .withPositions(this.getPositions(position, parentRef)) + .withPositions(getTooltipConnectedPositions(position, parentRef, hasArrow)) .withPush(false); const scrollStrategy = this.overlay.scrollStrategies.reposition(); @@ -161,62 +150,4 @@ export class EvoTooltipService implements OnDestroy { .subscribe(); } } - - private getPositions(position: EvoTooltipPosition, parentRef: ElementRef): ConnectedPosition[] { - return this.getPositionsOrder(position).map((key) => { - const offset = EVO_TOOLTIP_OFFSET(this._visibleArrow$.value); - const position = {...EVO_CONNECTED_POSITION(offset)[key]}; - const width = parentRef.nativeElement.offsetWidth; - const height = parentRef.nativeElement.offsetHeight; - const maxSize = EVO_TOOLTIP_ARROW_SIZE + EVO_TOOLTIP_RADIUS * 2; - - // Добавляем смещение тултипа для мелких обьектов - if (width <= maxSize) { - switch (key) { - case EvoTooltipPosition.BOTTOM_START: - case EvoTooltipPosition.TOP_START: - position.offsetX = -(maxSize - width) / 2; - break; - case EvoTooltipPosition.BOTTOM_END: - case EvoTooltipPosition.TOP_END: - position.offsetX = (maxSize - width) / 2; - break; - } - } - - // Добавляем смещение тултипа для мелких обьектов - if (height <= maxSize) { - switch (key) { - case EvoTooltipPosition.LEFT_START: - case EvoTooltipPosition.RIGHT_START: - position.offsetY = -(maxSize - height) / 2; - break; - case EvoTooltipPosition.LEFT_END: - case EvoTooltipPosition.RIGHT_END: - position.offsetY = (maxSize - height) / 2; - break; - } - } - - return position; - }); - } - - private getPositionsOrder(position: EvoTooltipPosition): EvoTooltipPosition[] { - const priorityPositions = EVO_PRIORITY_POSITIONS_ORDER[position]; - - if (priorityPositions) { - return [ - ...priorityPositions, - ...EVO_DEFAULT_POSITIONS_ORDER.filter( - (defaultPosition) => !priorityPositions.includes(defaultPosition), - ), - ]; - } - - const indexPositionKey: number = EVO_DEFAULT_POSITIONS_ORDER.indexOf(position); - return EVO_DEFAULT_POSITIONS_ORDER.slice(indexPositionKey).concat( - EVO_DEFAULT_POSITIONS_ORDER.slice(0, indexPositionKey), - ); - } } diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-connected-positions.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-connected-positions.ts new file mode 100644 index 000000000..45dcc1a45 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-connected-positions.ts @@ -0,0 +1,57 @@ +import {EvoTooltipPosition} from '../enums/evo-tooltip-position'; +import {ElementRef} from '@angular/core'; +import {ConnectedPosition} from '@angular/cdk/overlay'; +import {getTooltipPositionsOrder} from './get-tooltip-positions-order'; +import {EVO_TOOLTIP_OFFSET} from '../constants/evo-tooltip-offset'; +import {EVO_CONNECTED_POSITION} from '../constants/evo-tooltip-connected-position'; +import {EVO_TOOLTIP_ARROW_SIZE} from '../constants/evo-tooltip-arrow-size'; +import {EVO_TOOLTIP_RADIUS} from '../constants/evo-tooltip-radius'; + +export function getTooltipConnectedPositions( + position: EvoTooltipPosition, + parentRef: ElementRef, + hasArrow: boolean, +): ConnectedPosition[] { + const parentWidth = parentRef.nativeElement.offsetWidth; + const parentHeight = parentRef.nativeElement.offsetHeight; + const maxSize = EVO_TOOLTIP_ARROW_SIZE + EVO_TOOLTIP_RADIUS * 2; + + const offset = EVO_TOOLTIP_OFFSET(hasArrow); + + const getStartOffset = (size: number): number => -(maxSize - size) / 2; + const getEndOffset = (size: number): number => (maxSize - size) / 2; + + return getTooltipPositionsOrder(position).map((key: EvoTooltipPosition) => { + const connectedPosition = {...EVO_CONNECTED_POSITION(offset)[key]}; + + // offset X for small parent + if (parentWidth <= maxSize) { + switch (key) { + case EvoTooltipPosition.BOTTOM_START: + case EvoTooltipPosition.TOP_START: + connectedPosition.offsetX = getStartOffset(parentWidth); + break; + case EvoTooltipPosition.BOTTOM_END: + case EvoTooltipPosition.TOP_END: + connectedPosition.offsetX = getEndOffset(parentWidth); + break; + } + } + + // offset Y for small parent + if (parentHeight <= maxSize) { + switch (key) { + case EvoTooltipPosition.LEFT_START: + case EvoTooltipPosition.RIGHT_START: + connectedPosition.offsetY = getStartOffset(parentHeight); + break; + case EvoTooltipPosition.LEFT_END: + case EvoTooltipPosition.RIGHT_END: + connectedPosition.offsetY = getEndOffset(parentHeight); + break; + } + } + + return connectedPosition; + }); +} diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-positions-order.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-positions-order.spec.ts new file mode 100644 index 000000000..85923f959 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-positions-order.spec.ts @@ -0,0 +1,58 @@ +import {EvoTooltipPosition} from '../enums/evo-tooltip-position'; +import {getTooltipPositionsOrder} from './get-tooltip-positions-order'; +import {EVO_DEFAULT_POSITIONS_ORDER} from '../constants/evo-tooltip-default-positions-order'; + +interface TestCase { + description: string; + position: EvoTooltipPosition; + expectedFirst: EvoTooltipPosition; + expectedSecond?: EvoTooltipPosition; +} + +describe('getTooltipPositionOrder', () => { + const testCases: TestCase[] = [ + { + description: 'should return priority positions first for TOP position', + position: EvoTooltipPosition.TOP, + expectedFirst: EvoTooltipPosition.TOP, + expectedSecond: EvoTooltipPosition.BOTTOM, + }, + { + description: 'should return priority positions first for BOTTOM position', + position: EvoTooltipPosition.BOTTOM, + expectedFirst: EvoTooltipPosition.BOTTOM, + expectedSecond: EvoTooltipPosition.TOP, + }, + { + description: 'should return priority positions first for LEFT position', + position: EvoTooltipPosition.LEFT, + expectedFirst: EvoTooltipPosition.LEFT, + expectedSecond: EvoTooltipPosition.RIGHT, + }, + { + description: 'should return priority positions first for RIGHT position', + position: EvoTooltipPosition.RIGHT, + expectedFirst: EvoTooltipPosition.RIGHT, + expectedSecond: EvoTooltipPosition.LEFT, + }, + { + description: 'should rotate all positions starting from TOP_START', + position: EvoTooltipPosition.TOP_START, + expectedFirst: EvoTooltipPosition.TOP_START, + }, + ]; + + testCases.forEach((testCase) => + it(testCase.description, () => { + const result: EvoTooltipPosition[] = getTooltipPositionsOrder(testCase.position); + + expect(result[0]).toBe(testCase.expectedFirst); + + if (testCase.expectedSecond) { + expect(result[1]).toBe(testCase.expectedSecond); + } + + expect(result.length).toBe(EVO_DEFAULT_POSITIONS_ORDER.length); + }), + ); +}); diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-positions-order.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-positions-order.ts new file mode 100644 index 000000000..81ba6a358 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/utils/get-tooltip-positions-order.ts @@ -0,0 +1,20 @@ +import {EVO_PRIORITY_POSITIONS_ORDER} from '../constants/evo-tooltip-priority-positions-order'; +import {EVO_DEFAULT_POSITIONS_ORDER} from '../constants/evo-tooltip-default-positions-order'; +import {EvoTooltipPosition} from '../enums/evo-tooltip-position'; + +export function getTooltipPositionsOrder(position: EvoTooltipPosition): EvoTooltipPosition[] { + const priorityPositions = EVO_PRIORITY_POSITIONS_ORDER[position]; + + if (priorityPositions) { + return [ + ...priorityPositions, + ...EVO_DEFAULT_POSITIONS_ORDER.filter((defaultPosition) => !priorityPositions.includes(defaultPosition)), + ]; + } + + const indexPositionKey: number = EVO_DEFAULT_POSITIONS_ORDER.indexOf(position); + + return EVO_DEFAULT_POSITIONS_ORDER.slice(indexPositionKey).concat( + EVO_DEFAULT_POSITIONS_ORDER.slice(0, indexPositionKey), + ); +} From 4f1a9d3bf5f9a152f7a42761f4d8cbcaa154743d Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Tue, 12 May 2026 13:24:47 +0300 Subject: [PATCH 08/11] feat(evo-scroll-strategies): created strategies --- .../classes/evo-close-scroll-strategy.ts | 103 ++++++++++++++++++ .../classes/evo-reposition-scroll-strategy.ts | 93 ++++++++++++++++ .../classes/evo-scroll-strategy-options.ts | 22 ++++ .../evo-ui-kit/src/lib/common/scroll/index.ts | 5 + .../evo-close-scroll-strategy-params.ts | 7 ++ .../scroll/interfaces/scroll-position.ts | 4 + .../scroll/types/evo-scroll-strategy.ts | 1 + .../scroll/utils/create-scroll-stream.ts | 14 +++ projects/evo-ui-kit/src/public_api.ts | 3 + 9 files changed, 252 insertions(+) create mode 100644 projects/evo-ui-kit/src/lib/common/scroll/classes/evo-close-scroll-strategy.ts create mode 100644 projects/evo-ui-kit/src/lib/common/scroll/classes/evo-reposition-scroll-strategy.ts create mode 100644 projects/evo-ui-kit/src/lib/common/scroll/classes/evo-scroll-strategy-options.ts create mode 100644 projects/evo-ui-kit/src/lib/common/scroll/index.ts create mode 100644 projects/evo-ui-kit/src/lib/common/scroll/interfaces/evo-close-scroll-strategy-params.ts create mode 100644 projects/evo-ui-kit/src/lib/common/scroll/interfaces/scroll-position.ts create mode 100644 projects/evo-ui-kit/src/lib/common/scroll/types/evo-scroll-strategy.ts create mode 100644 projects/evo-ui-kit/src/lib/common/scroll/utils/create-scroll-stream.ts diff --git a/projects/evo-ui-kit/src/lib/common/scroll/classes/evo-close-scroll-strategy.ts b/projects/evo-ui-kit/src/lib/common/scroll/classes/evo-close-scroll-strategy.ts new file mode 100644 index 000000000..e0ec12621 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/common/scroll/classes/evo-close-scroll-strategy.ts @@ -0,0 +1,103 @@ +import {Injector, NgZone} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; +import {OverlayRef, ScrollStrategy} from '@angular/cdk/overlay'; +import {EvoCloseScrollStrategyParams} from '../interfaces/evo-close-scroll-strategy-params'; +import {filter, first, tap} from 'rxjs/operators'; +import {createScrollStream} from '../utils/create-scroll-stream'; +import {Subscription} from 'rxjs'; +import {ScrollPosition} from '../interfaces/scroll-position'; + +export class EvoCloseScrollStrategy implements ScrollStrategy { + private readonly document: Document; + private readonly ngZone: NgZone; + + private overlayRef: OverlayRef | null = null; + private scrollSubscription: Subscription | null = null; + + private initialScrollPosition: ScrollPosition | null = null; + + constructor(private readonly injector: Injector, private readonly params?: EvoCloseScrollStrategyParams) { + this.document = this.injector.get(DOCUMENT); + this.ngZone = this.injector.get(NgZone); + } + + attach(overlayRef: OverlayRef): void { + this.overlayRef = overlayRef; + } + + detach(): void { + this.disable(); + this.overlayRef = null; + } + + disable(): void { + if (!this.scrollSubscription) { + return; + } + + this.scrollSubscription.unsubscribe(); + this.scrollSubscription = null; + } + + enable(): void { + if (this.scrollSubscription || !this.overlayRef) { + return; + } + + this.ngZone.runOutsideAngular(() => { + this.initialScrollPosition = this.getCurrentScrollPosition(); + + this.scrollSubscription = createScrollStream(this.document, this.overlayRef) + .pipe( + filter(() => this.checkThreshold()), + first(), + tap(() => this.detachOverlay()), + ) + .subscribe(); + }); + } + + private detachOverlay(): void { + if (!this.overlayRef?.hasAttached()) { + return; + } + + this.disable(); + this.ngZone.run(() => this.overlayRef.detach()); + } + + private checkThreshold(): boolean { + const threshold = this.params?.threshold ?? 0; + const triggerEl = this.params?.triggerRef?.nativeElement; + + if (!triggerEl || !this.initialScrollPosition) { + return true; + } + + const scrollPosition = this.getCurrentScrollPosition(); + + if (!scrollPosition) { + return false; + } + + const distanceY = Math.abs(scrollPosition.vertical - this.initialScrollPosition.vertical); + const distanceX = Math.abs(scrollPosition.horizontal - this.initialScrollPosition.horizontal); + + return Math.max(distanceX, distanceY) > threshold; + } + + private getCurrentScrollPosition(): ScrollPosition | null { + const element = this.params?.triggerRef?.nativeElement as Element; + + if (!element) { + return null; + } + + const rect = element.getBoundingClientRect(); + + return { + vertical: rect.top, + horizontal: rect.left, + }; + } +} diff --git a/projects/evo-ui-kit/src/lib/common/scroll/classes/evo-reposition-scroll-strategy.ts b/projects/evo-ui-kit/src/lib/common/scroll/classes/evo-reposition-scroll-strategy.ts new file mode 100644 index 000000000..5a4ec4abf --- /dev/null +++ b/projects/evo-ui-kit/src/lib/common/scroll/classes/evo-reposition-scroll-strategy.ts @@ -0,0 +1,93 @@ +import {Injector, NgZone} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; +import {OverlayRef, ScrollStrategy} from '@angular/cdk/overlay'; +import {Subscription} from 'rxjs'; +import {tap} from 'rxjs/operators'; +import {createScrollStream} from '../utils/create-scroll-stream'; + +export class EvoRepositionScrollStrategy implements ScrollStrategy { + private readonly document: Document; + private readonly ngZone: NgZone; + + private overlayRef: OverlayRef | null = null; + + private scrollSubscription: Subscription | null = null; + + constructor(private readonly injector: Injector) { + this.document = this.injector.get(DOCUMENT); + this.ngZone = this.injector.get(NgZone); + } + + attach(overlayRef: OverlayRef): void { + this.overlayRef = overlayRef; + } + + detach(): void { + this.disable(); + this.overlayRef = null; + } + + disable(): void { + if (!this.scrollSubscription) { + return; + } + + this.scrollSubscription.unsubscribe(); + this.scrollSubscription = null; + } + + enable(): void { + if (this.scrollSubscription || !this.overlayRef) { + return; + } + + this.ngZone.runOutsideAngular(() => { + this.scrollSubscription = createScrollStream(this.document, this.overlayRef) + .pipe( + tap(() => { + if (this.isOverlayScrolledOutsideView()) { + this.detachOverlay(); + return; + } + + this.updatePosition(); + }), + ) + .subscribe(); + }); + } + + private updatePosition(): void { + if (!this.overlayRef?.hasAttached()) { + return; + } + + this.ngZone.run(() => this.overlayRef.updatePosition()); + } + + private detachOverlay(): void { + if (!this.overlayRef?.hasAttached()) { + return; + } + + this.disable(); + this.ngZone.run(() => this.overlayRef.detach()); + } + + private isOverlayScrolledOutsideView(): boolean { + if (!this.overlayRef?.hasAttached()) { + return true; + } + + const overlayRect = this.overlayRef.overlayElement.getBoundingClientRect(); + const pageHeight = this.document.documentElement.clientHeight; + const pageWidth = this.document.documentElement.clientWidth; + + return ( + overlayRect.bottom < 0 || + overlayRect.top > pageHeight || + overlayRect.right < 0 || + overlayRect.left > pageWidth + ); + } +} diff --git a/projects/evo-ui-kit/src/lib/common/scroll/classes/evo-scroll-strategy-options.ts b/projects/evo-ui-kit/src/lib/common/scroll/classes/evo-scroll-strategy-options.ts new file mode 100644 index 000000000..efc97e1a0 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/common/scroll/classes/evo-scroll-strategy-options.ts @@ -0,0 +1,22 @@ +import {NoopScrollStrategy, ScrollStrategy} from '@angular/cdk/overlay'; +import {Injectable, Injector} from '@angular/core'; +import {EvoCloseScrollStrategy} from './evo-close-scroll-strategy'; +import {EvoRepositionScrollStrategy} from './evo-reposition-scroll-strategy'; +import {EvoCloseScrollStrategyParams} from '../interfaces/evo-close-scroll-strategy-params'; + +@Injectable() +export class EvoScrollStrategyOptions { + constructor(private readonly injector: Injector) {} + + noop(): ScrollStrategy { + return new NoopScrollStrategy(); + }; + + reposition(): ScrollStrategy { + return new EvoRepositionScrollStrategy(this.injector); + }; + + close(params?: EvoCloseScrollStrategyParams): ScrollStrategy { + return new EvoCloseScrollStrategy(this.injector, params); + }; +} diff --git a/projects/evo-ui-kit/src/lib/common/scroll/index.ts b/projects/evo-ui-kit/src/lib/common/scroll/index.ts new file mode 100644 index 000000000..02a3aab51 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/common/scroll/index.ts @@ -0,0 +1,5 @@ +export * from './types/evo-scroll-strategy'; + +export * from './classes/evo-scroll-strategy-options'; +export * from './classes/evo-close-scroll-strategy'; +export * from './classes/evo-reposition-scroll-strategy'; diff --git a/projects/evo-ui-kit/src/lib/common/scroll/interfaces/evo-close-scroll-strategy-params.ts b/projects/evo-ui-kit/src/lib/common/scroll/interfaces/evo-close-scroll-strategy-params.ts new file mode 100644 index 000000000..d49c21d8b --- /dev/null +++ b/projects/evo-ui-kit/src/lib/common/scroll/interfaces/evo-close-scroll-strategy-params.ts @@ -0,0 +1,7 @@ +import {ElementRef} from '@angular/core'; + +export interface EvoCloseScrollStrategyParams { + /** Amount of pixels the user has to scroll before the overlay is closed. */ + threshold: number; + triggerRef: ElementRef; +} diff --git a/projects/evo-ui-kit/src/lib/common/scroll/interfaces/scroll-position.ts b/projects/evo-ui-kit/src/lib/common/scroll/interfaces/scroll-position.ts new file mode 100644 index 000000000..b3c81a4de --- /dev/null +++ b/projects/evo-ui-kit/src/lib/common/scroll/interfaces/scroll-position.ts @@ -0,0 +1,4 @@ +export interface ScrollPosition { + vertical: number; + horizontal: number; +} diff --git a/projects/evo-ui-kit/src/lib/common/scroll/types/evo-scroll-strategy.ts b/projects/evo-ui-kit/src/lib/common/scroll/types/evo-scroll-strategy.ts new file mode 100644 index 000000000..f64c188ad --- /dev/null +++ b/projects/evo-ui-kit/src/lib/common/scroll/types/evo-scroll-strategy.ts @@ -0,0 +1 @@ +export type EvoScrollStrategy = 'noop' | 'close' | 'reposition'; diff --git a/projects/evo-ui-kit/src/lib/common/scroll/utils/create-scroll-stream.ts b/projects/evo-ui-kit/src/lib/common/scroll/utils/create-scroll-stream.ts new file mode 100644 index 000000000..f052f0024 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/common/scroll/utils/create-scroll-stream.ts @@ -0,0 +1,14 @@ +import {animationFrameScheduler, fromEvent, Observable} from 'rxjs'; +import {filter, throttleTime} from 'rxjs/operators'; +import {OverlayRef} from '@angular/cdk/overlay'; + +export function createScrollStream(document: Document, overlayRef: OverlayRef): Observable { + return fromEvent(document, 'scroll', {capture: true, passive: true}).pipe( + throttleTime(10, animationFrameScheduler, {leading: true, trailing: true}), + filter((event) => { + const target = event.target as Node; + + return !overlayRef.overlayElement.contains(target); + }), + ); +} diff --git a/projects/evo-ui-kit/src/public_api.ts b/projects/evo-ui-kit/src/public_api.ts index 29a4f91b6..448352869 100644 --- a/projects/evo-ui-kit/src/public_api.ts +++ b/projects/evo-ui-kit/src/public_api.ts @@ -10,6 +10,9 @@ export * from './lib/common/evo-control-state-manager/evo-control-states.enum'; export * from './lib/common/evo-control-state-manager/evo-control-state.interface'; export * from './lib/common/evo-base-control'; +// Scroll +export * from './lib/common/scroll/index'; + // Animations export * from './lib/common/animations/index'; From e98c0269c1c3868f2a3c668b47a0a3339cf60f4e Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Wed, 13 May 2026 19:11:05 +0300 Subject: [PATCH 09/11] feat(evo-tooltip): use scroll strategies in tooltip --- .../directives/evo-tooltip.directive.spec.ts | 3 +- .../directives/evo-tooltip.directive.ts | 4 +- .../evo-tooltip/evo-tooltip.component.spec.ts | 3 +- .../interfaces/evo-tooltip-config.ts | 3 ++ .../services/evo-tooltip.service.spec.ts | 16 ++++-- .../services/evo-tooltip.service.ts | 50 +++++++++++++++++-- 6 files changed, 66 insertions(+), 13 deletions(-) diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts index 8520632a3..b2e7a4925 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.spec.ts @@ -10,6 +10,7 @@ import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {first} from 'rxjs/operators'; import {EvoTooltipComponent} from '../evo-tooltip.component'; import {By} from '@angular/platform-browser'; +import {EvoScrollStrategyOptions} from '../../../common/scroll'; @Component({ template: ` @@ -54,7 +55,7 @@ describe('EvoTooltipDirective', () => { await TestBed.configureTestingModule({ declarations: [TestHostComponent, EvoTooltipDirective, EvoTooltipComponent], imports: [CommonModule, BrowserAnimationsModule], - providers: [EvoTooltipService], + providers: [EvoTooltipService, EvoScrollStrategyOptions], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.ts index 8d448b7cc..7dab7960e 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.ts @@ -17,11 +17,12 @@ import {EvoTooltipConfig} from '../interfaces/evo-tooltip-config'; import {EVO_TOOLTIP_CONFIG} from '../constants/evo-tooltip-config'; import {EvoTooltipPosition} from '../enums/evo-tooltip-position'; import {EvoTooltipStyles} from '../interfaces/evo-tooltip-styles'; +import {EvoScrollStrategyOptions} from '../../../common/scroll'; @Directive({ selector: '[evoTooltip]', exportAs: 'evoTooltip', - providers: [EvoTooltipService], + providers: [EvoTooltipService, EvoScrollStrategyOptions], }) export class EvoTooltipDirective implements OnInit, OnDestroy { @Input('evoTooltip') content: string | TemplateRef; @@ -74,6 +75,7 @@ export class EvoTooltipDirective implements OnInit, OnDestroy { content: this.content, position: this.position as EvoTooltipPosition, hasArrow: this.visibleArrow, + scrollStrategy: this.config?.scrollStrategy, }); this.initHideSubscription(tooltip); diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.spec.ts index 95afdeea1..8a484651c 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.spec.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.spec.ts @@ -7,6 +7,7 @@ import {CommonModule} from '@angular/common'; import {OverlayContainer} from '@angular/cdk/overlay'; import {EvoTooltipPosition} from './enums/evo-tooltip-position'; import {EvoTooltipStyleVariable} from './enums/evo-tooltip-style-variable'; +import {EvoScrollStrategyOptions} from '../../common/scroll'; @Component({ selector: 'evo-host-component', @@ -38,7 +39,7 @@ describe('EvoTooltipComponent', () => { declarations: [EvoTooltipComponent, TestHostComponent], imports: [BrowserAnimationsModule, CommonModule], schemas: [NO_ERRORS_SCHEMA], - providers: [EvoTooltipService], + providers: [EvoTooltipService, EvoScrollStrategyOptions], }).compileComponents(); overlayContainer = TestBed.inject(OverlayContainer); diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/interfaces/evo-tooltip-config.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/interfaces/evo-tooltip-config.ts index 097510ff3..d54180a8f 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/interfaces/evo-tooltip-config.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/interfaces/evo-tooltip-config.ts @@ -1,5 +1,8 @@ +import {EvoScrollStrategy} from '../../../common/scroll'; + export interface EvoTooltipConfig { // The default delay in ms before hiding the tooltip hideDelay?: number; showDelay?: number; + scrollStrategy?: EvoScrollStrategy; } diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts index ceae12937..b1bfec564 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.spec.ts @@ -8,6 +8,7 @@ import {EvoTooltipStyleVariable} from '../enums/evo-tooltip-style-variable'; import {CommonModule} from '@angular/common'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {EvoTooltipComponent} from '../evo-tooltip.component'; +import {EvoScrollStrategyOptions} from '../../../common/scroll'; describe('EvoTooltipService', () => { let service: EvoTooltipService; @@ -18,15 +19,20 @@ describe('EvoTooltipService', () => { imports: [CommonModule, BrowserAnimationsModule], declarations: [EvoTooltipComponent], schemas: [NO_ERRORS_SCHEMA], - providers: [EvoTooltipService], + providers: [EvoTooltipService, EvoScrollStrategyOptions], }); service = TestBed.inject(EvoTooltipService); + + const mockElement = document.createElement('div'); + + Object.defineProperties(mockElement, { + offsetWidth: {value: 100}, + offsetHeight: {value: 50}, + }); + elementRef = { - nativeElement: { - offsetWidth: 100, - offsetHeight: 50, - }, + nativeElement: mockElement, }; }); diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.ts index fe84f7387..491f43de8 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/services/evo-tooltip.service.ts @@ -1,12 +1,21 @@ import {ComponentRef, ElementRef, Injectable, Injector, OnDestroy, TemplateRef} from '@angular/core'; import {ComponentPortal} from '@angular/cdk/portal'; -import {FlexibleConnectedPositionStrategy, Overlay, OverlayPositionBuilder, OverlayRef} from '@angular/cdk/overlay'; +import { + FlexibleConnectedPositionStrategy, + Overlay, + OverlayPositionBuilder, + OverlayRef, + ScrollStrategy, +} from '@angular/cdk/overlay'; import {BehaviorSubject, EMPTY, merge, Observable, Subject} from 'rxjs'; import {filter, take, takeUntil, tap} from 'rxjs/operators'; import {EvoTooltipComponent} from '../evo-tooltip.component'; import {EvoTooltipPosition} from '../enums/evo-tooltip-position'; import {EvoTooltipStyles} from '../interfaces/evo-tooltip-styles'; import {getTooltipConnectedPositions} from '../utils/get-tooltip-connected-positions'; +import {EvoScrollStrategy, EvoScrollStrategyOptions} from 'projects/evo-ui-kit/src/public_api'; + +const DEFAULT_TOOLTIP_SCROLL_STRATEGY: EvoScrollStrategy = 'close'; @Injectable() export class EvoTooltipService implements OnDestroy { @@ -40,6 +49,7 @@ export class EvoTooltipService implements OnDestroy { constructor( private readonly overlay: Overlay, private readonly overlayPositionBuilder: OverlayPositionBuilder, + private readonly evoScrollStrategyOptions: EvoScrollStrategyOptions, private readonly injector: Injector, ) { this.stringContent$ = this._stringContent$.asObservable(); @@ -62,6 +72,7 @@ export class EvoTooltipService implements OnDestroy { content: string | TemplateRef; position?: EvoTooltipPosition; hasArrow?: boolean; + scrollStrategy?: EvoScrollStrategy; }): HTMLElement { const {parentRef, content, position = EvoTooltipPosition.BOTTOM, hasArrow = true} = params; @@ -70,7 +81,10 @@ export class EvoTooltipService implements OnDestroy { this.setContent(content); this._position$.next(position); - this.createOverlay(parentRef, position, hasArrow); + + const scrollStrategy = params.scrollStrategy || DEFAULT_TOOLTIP_SCROLL_STRATEGY; + this.createOverlay(parentRef, position, hasArrow, scrollStrategy); + this.createPortal(); this._isOpen$.next(this.hasAttached); @@ -111,14 +125,40 @@ export class EvoTooltipService implements OnDestroy { } } - private createOverlay(parentRef: ElementRef, position: EvoTooltipPosition, hasArrow: boolean): void { + private createOverlay( + parentRef: ElementRef, + position: EvoTooltipPosition, + hasArrow: boolean, + scrollStrategy: EvoScrollStrategy, + ): void { this.positionStrategy = this.overlayPositionBuilder .flexibleConnectedTo(parentRef) .withPositions(getTooltipConnectedPositions(position, parentRef, hasArrow)) .withPush(false); - const scrollStrategy = this.overlay.scrollStrategies.reposition(); - this.overlayRef = this.overlay.create({positionStrategy: this.positionStrategy, scrollStrategy}); + this.overlayRef = this.overlay.create({ + positionStrategy: this.positionStrategy, + scrollStrategy: this.getScrollStrategy(scrollStrategy, parentRef), + }); + } + + private getScrollStrategy(scrollStrategy: EvoScrollStrategy, parentRef: ElementRef): ScrollStrategy { + switch (scrollStrategy) { + case 'noop': { + return this.evoScrollStrategyOptions.noop(); + } + case 'reposition': { + return this.evoScrollStrategyOptions.reposition(); + } + + case 'close': + default: { + return this.evoScrollStrategyOptions.close({ + threshold: 10, + triggerRef: parentRef, + }); + } + } } private createPortal(): void { From f9690fa13af476da3628ed20a1afffa84940cdb7 Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Tue, 12 May 2026 13:25:23 +0300 Subject: [PATCH 10/11] feat(evo-dropdown): use scroll strategies in dropdown --- .../evo-dropdown/evo-dropdown.component.html | 1 + .../evo-dropdown/evo-dropdown.component.ts | 80 +++++-------------- 2 files changed, 22 insertions(+), 59 deletions(-) diff --git a/projects/evo-ui-kit/src/lib/components/evo-dropdown/evo-dropdown.component.html b/projects/evo-ui-kit/src/lib/components/evo-dropdown/evo-dropdown.component.html index 024f481d2..3b97ae0e4 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-dropdown/evo-dropdown.component.html +++ b/projects/evo-ui-kit/src/lib/components/evo-dropdown/evo-dropdown.component.html @@ -3,6 +3,7 @@ [cdkConnectedOverlayOpen]="isOpen" [cdkConnectedOverlayOrigin]="dropdownOrigin" [cdkConnectedOverlayPositions]="connectedPositions" + [cdkConnectedOverlayScrollStrategy]="scrollStrategy$ | async" (detach)="close()" (overlayOutsideClick)="onOverlayOutsideClick($event)" > diff --git a/projects/evo-ui-kit/src/lib/components/evo-dropdown/evo-dropdown.component.ts b/projects/evo-ui-kit/src/lib/components/evo-dropdown/evo-dropdown.component.ts index 8ae4c7b48..0766cfa07 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-dropdown/evo-dropdown.component.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-dropdown/evo-dropdown.component.ts @@ -2,11 +2,8 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - ElementRef, EventEmitter, Input, - NgZone, - OnDestroy, Output, ViewContainerRef, } from '@angular/core'; @@ -14,8 +11,9 @@ import {EvoDropdownOriginDirective} from './evo-dropdown-origin.directive'; import {ConnectedPosition} from '@angular/cdk/overlay'; import {EVO_DROPDOWN_POSITION_DESCRIPTION} from './evo-dropdown-position-description'; import {EvoDropdownPositions} from './types/evo-dropdown-positions'; -import {fromEvent, Subject, Subscription} from 'rxjs'; -import {filter, take, takeUntil, throttleTime} from 'rxjs/operators'; +import {BehaviorSubject} from 'rxjs'; +import {EvoScrollStrategy} from '../../common/scroll/types/evo-scroll-strategy'; +import {EvoScrollStrategyOptions} from '../../common/scroll'; type Position = EvoDropdownPositions | ConnectedPosition; @@ -25,10 +23,10 @@ const DEFAULT_POSITION = [EVO_DROPDOWN_POSITION_DESCRIPTION['bottom-right']]; selector: 'evo-dropdown', templateUrl: './evo-dropdown.component.html', changeDetection: ChangeDetectionStrategy.OnPush, + providers: [EvoScrollStrategyOptions], }) -export class EvoDropdownComponent implements OnDestroy { +export class EvoDropdownComponent { @Input() closeOnOutsideClick = true; - @Input() scrollStrategy: 'noop' | 'close' = 'close'; @Input() dropdownOrigin!: EvoDropdownOriginDirective; @Output() isOpenChange = new EventEmitter(); @@ -36,8 +34,8 @@ export class EvoDropdownComponent implements OnDestroy { connectedPositions: ConnectedPosition[] = DEFAULT_POSITION; - private scrollEventSubscription: Subscription; - private destroy$ = new Subject(); + readonly scrollStrategy$ = new BehaviorSubject(this.evoScrollStrategyOptions.close()); + private _isOpen = false; get isOpen(): boolean { @@ -46,10 +44,6 @@ export class EvoDropdownComponent implements OnDestroy { @Input() set isOpen(value: boolean) { this._isOpen = value; - - if (value) { - this.listenScroll(); - } } @Input() set positions(value: Position[] | Position) { @@ -58,19 +52,25 @@ export class EvoDropdownComponent implements OnDestroy { : DEFAULT_POSITION; } - private get element(): HTMLElement | null { - if (!this.viewContainerRef) { - return; + @Input() set scrollStrategy(strategy: EvoScrollStrategy) { + switch (strategy) { + case 'noop': { + this.scrollStrategy$.next(this.evoScrollStrategyOptions.noop()); + break; + } + case 'reposition': { + this.scrollStrategy$.next(this.evoScrollStrategyOptions.reposition()); + break; + } + case 'close': { + this.scrollStrategy$.next(this.evoScrollStrategyOptions.close()); + } } - - return this.viewContainerRef?.element instanceof ElementRef - ? (this.viewContainerRef.element?.nativeElement as HTMLElement) - : (this.viewContainerRef.element as HTMLElement); } constructor( + private readonly evoScrollStrategyOptions: EvoScrollStrategyOptions, protected readonly viewContainerRef: ViewContainerRef, - private readonly ngZone: NgZone, private readonly cdr: ChangeDetectorRef, ) {} @@ -100,18 +100,9 @@ export class EvoDropdownComponent implements OnDestroy { this.isOpen = false; this.isOpenChange.emit(this.isOpen); - if (this.scrollEventSubscription && !this.scrollEventSubscription.closed) { - this.scrollEventSubscription.unsubscribe(); - } - this.cdr.detectChanges(); } - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.unsubscribe(); - } - onOverlayOutsideClick(event: MouseEvent): void { this.outsideClick.emit(event); @@ -122,33 +113,4 @@ export class EvoDropdownComponent implements OnDestroy { this.close(); } } - - /** - * Listens to the scroll in the dropdown container and closes it - */ - private listenScroll() { - if (this.scrollStrategy === 'noop') { - return; - } - - this.scrollEventSubscription = this.ngZone.runOutsideAngular(() => { - return fromEvent(document, 'scroll', {capture: true}) - .pipe( - throttleTime(10), - filter((scrollEvent: Event) => { - return ( - (scrollEvent.target instanceof HTMLElement || scrollEvent.target instanceof HTMLDocument) && - (scrollEvent.target.contains(this.element) || !this.element) - ); - }), - take(1), - takeUntil(this.destroy$), - ) - .subscribe(() => { - this.ngZone.run(() => { - this.close(); - }); - }); - }); - } } From 8e1adbf427dbe85e6017aee5e00f0957b441704f Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Wed, 13 May 2026 19:15:20 +0300 Subject: [PATCH 11/11] feat(app): add test environment --- src/app/app.component.html | 36 ++++++ src/app/app.component.scss | 41 ++++++ src/app/app.component.ts | 34 ++++- src/app/app.module.ts | 20 ++- .../test-sidebar/test-sidebar.component.html | 117 ++++++++++++++++++ .../test-sidebar/test-sidebar.component.scss | 46 +++++++ .../test-sidebar.component.spec.ts | 25 ++++ .../test-sidebar/test-sidebar.component.ts | 12 ++ 8 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 src/app/test-sidebar/test-sidebar.component.html create mode 100644 src/app/test-sidebar/test-sidebar.component.scss create mode 100644 src/app/test-sidebar/test-sidebar.component.spec.ts create mode 100644 src/app/test-sidebar/test-sidebar.component.ts diff --git a/src/app/app.component.html b/src/app/app.component.html index e69de29bb..b99e413f4 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -0,0 +1,36 @@ +
+
+ {{ i }} +
+
+ +
+ +
+ Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ad, asperiores assumenda, beatae dolore doloribus error + fuga itaque magnam modi nesciunt odit officia quae quaerat repudiandae sapiente suscipit ut veniam, vitae? Lorem + ipsum dolor sit amet, consectetur adipisicing elit. Ad, asperiores assumenda, beatae dolore doloribus error fuga + itaque magnam modi nesciunt odit officia quae quaerat repudiandae sapiente suscipit ut veniam, vitae? Lorem ipsum + dolor sit amet, consectetur adipisicing elit. Ad, asperiores assumenda, beatae dolore doloribus error fuga itaque + magnam modi nesciunt odit officia quae quaerat repudiandae sapiente suscipit ut veniam, vitae? Lorem ipsum dolor sit + amet, consectetur adipisicing elit. Ad, asperiores assumenda, beatae dolore doloribus error fuga itaque magnam modi + nesciunt odit officia quae quaerat repudiandae sapiente suscipit ut veniam, vitae? + +
+ Tooltip +
+ + Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ad, asperiores assumenda, beatae dolore doloribus error + fuga itaque magnam modi nesciunt odit officia quae quaerat repudiandae sapiente suscipit ut veniam, vitae? Lorem + ipsum dolor sit amet, consectetur adipisicing elit. Ad, asperiores assumenda, beatae dolore doloribus error fuga + itaque magnam modi nesciunt odit officia quae quaerat repudiandae sapiente suscipit ut veniam, vitae? Lorem ipsum + dolor sit amet, consectetur adipisicing elit. Ad, asperiores assumenda, beatae dolore doloribus error fuga itaque + magnam modi nesciunt odit officia quae quaerat repudiandae sapiente suscipit ut veniam, vitae? Lorem ipsum dolor sit + amet, consectetur adipisicing elit. Ad, asperiores assumenda, beatae dolore doloribus error fuga itaque magnam modi + nesciunt odit officia quae quaerat repudiandae sapiente suscipit ut veniam, vitae? +
diff --git a/src/app/app.component.scss b/src/app/app.component.scss index e69de29bb..35ee32bdf 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -0,0 +1,41 @@ +.container { + display: grid; + grid-template-columns: repeat(3, min-content); + + margin-top: 200px; + gap: 15px; + + div { + border: 1px solid #000000; + width: 100px; + height: 100px; + overflow: hidden; + + position: relative; + + &:before { + content: ''; + position: absolute; + width: 100%; + height: 2px; + background-color: #ff0000; + top: calc(50% - 1px); + left: 0; + } + + &:after { + content: ''; + position: absolute; + width: 2px; + height: 100%; + background-color: #ff0000; + left: calc(50% - 1px); + top: 0; + } + } +} + +.cdk-overlay-container { + z-index: 3010 !important; // to make it over sidebars +} + diff --git a/src/app/app.component.ts b/src/app/app.component.ts index fe17e7b2c..8afa20db6 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,8 +1,38 @@ -import {Component} from '@angular/core'; +import {Component, ViewEncapsulation} from '@angular/core'; +import {EvoTooltipPosition} from '@evotor-dev/ui-kit'; +import {EvoSidebarService} from '../../projects/evo-ui-kit/src/lib/components/evo-sidebar'; +import {TestSidebarComponent} from './test-sidebar/test-sidebar.component'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'], + encapsulation: ViewEncapsulation.None, }) -export class AppComponent {} +export class AppComponent { + readonly positions = [ + EvoTooltipPosition.TOP_START, + EvoTooltipPosition.TOP, + EvoTooltipPosition.TOP_END, + + EvoTooltipPosition.BOTTOM_START, + EvoTooltipPosition.BOTTOM, + EvoTooltipPosition.BOTTOM_END, + + EvoTooltipPosition.RIGHT_START, + EvoTooltipPosition.RIGHT, + EvoTooltipPosition.RIGHT_END, + + EvoTooltipPosition.LEFT_START, + EvoTooltipPosition.LEFT, + EvoTooltipPosition.LEFT_END, + ]; + + constructor(private readonly evoSidebarService: EvoSidebarService) { + setTimeout(() => { + this.evoSidebarService.open({ + component: TestSidebarComponent, + }); + }); + } +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 73c7f4e1f..2e3bbfcac 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -3,10 +3,26 @@ import {NgModule} from '@angular/core'; import {AppComponent} from './app.component'; import {ReactiveFormsModule} from '@angular/forms'; +import {EvoTooltipModule} from '../../projects/evo-ui-kit/src/lib/components/evo-tooltip'; +import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; +import {TestSidebarComponent} from './test-sidebar/test-sidebar.component'; +import {EvoSidebarModule} from '../../projects/evo-ui-kit/src/lib/components/evo-sidebar'; +import {EvoChipModule} from '../../projects/evo-ui-kit/src/lib/components/evo-chip'; +import {EvoDropdownModule} from '../../projects/evo-ui-kit/src/lib/components/evo-dropdown'; +import {EvoButtonModule} from '../../projects/evo-ui-kit/src/lib/components/evo-button'; @NgModule({ - declarations: [AppComponent], - imports: [BrowserModule, ReactiveFormsModule], + declarations: [AppComponent, TestSidebarComponent], + imports: [ + BrowserModule, + ReactiveFormsModule, + EvoTooltipModule, + BrowserAnimationsModule, + EvoSidebarModule, + EvoChipModule, + EvoDropdownModule, + EvoButtonModule, + ], providers: [], bootstrap: [AppComponent], schemas: [], diff --git a/src/app/test-sidebar/test-sidebar.component.html b/src/app/test-sidebar/test-sidebar.component.html new file mode 100644 index 000000000..b02d4acf4 --- /dev/null +++ b/src/app/test-sidebar/test-sidebar.component.html @@ -0,0 +1,117 @@ +
Шапка
+
+ Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dicta ex fugiat in maxime praesentium, quia sed vitae! + Alias, beatae cumque dolores eos, fuga ipsum molestiae, nisi quasi qui repellat soluta. Lorem ipsum dolor sit amet, + consectetur adipisicing elit. Dicta ex fugiat in maxime praesentium, quia sed vitae! Alias, beatae cumque dolores + eos, fuga ipsum molestiae, nisi quasi qui repellat soluta. Lorem ipsum dolor sit amet, consectetur adipisicing elit. + Dicta ex fugiat in maxime praesentium, quia sed vitae! Alias, beatae cumque dolores eos, fuga ipsum molestiae, nisi + quasi qui repellat soluta. Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dicta ex fugiat in maxime + praesentium, quia sed vitae! Alias, beatae cumque dolores eos, fuga ipsum molestiae, nisi quasi qui repellat soluta. + Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dicta ex fugiat in maxime praesentium, quia sed vitae! + Alias, beatae cumque dolores eos, fuga ipsum molestiae, nisi quasi qui repellat soluta. Lorem ipsum dolor sit amet, + consectetur adipisicing elit. Dicta ex fugiat in maxime praesentium, quia sed vitae! Alias, beatae cumque dolores + eos, fuga ipsum molestiae, nisi quasi qui repellat soluta. Lorem ipsum dolor sit amet, consectetur adipisicing elit. + Dicta ex fugiat in maxime praesentium, quia sed vitae! Alias, beatae cumque dolores eos, fuga ipsum molestiae, nisi + quasi qui repellat soluta. Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dicta ex fugiat in maxime + praesentium, quia sed vitae! Alias, beatae cumque dolores eos, fuga ipsum molestiae, nisi quasi qui repellat soluta. + Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dicta ex fugiat in maxime praesentium, quia sed vitae! + Alias, beatae cumque dolores eos, fuga ipsum molestiae, nisi quasi qui repellat soluta. Lorem ipsum dolor sit amet, + consectetur adipisicing elit. Dicta ex fugiat in maxime praesentium, quia sed vitae! Alias, beatae cumque dolores + eos, fuga ipsum molestiae, nisi quasi qui repellat soluta. Lorem ipsum dolor sit amet, consectetur adipisicing elit. + Dicta ex fugiat in maxime praesentium, quia sed vitae! Alias, beatae cumque dolores eos, fuga ipsum molestiae, nisi + quasi qui repellat soluta.Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dicta ex fugiat in maxime + praesentium, quia sed vitae! Alias, beatae cumque dolores eos, fuga ipsum molestiae, nisi quasi qui repellat + soluta.Lorem ipsum dolor sit amet,block +
+
+ tooltip +
+
+ + +
+ Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusamus ad aperiam aspernatur consectetur + distinctio ducimus eum excepturi facere fuga impedit inventore iusto magnam nam odio perferendis, + perspiciatis, placeat totam voluptatem. +
+
+ Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusamus ad aperiam aspernatur consectetur + distinctio ducimus eum excepturi facere fuga impedit inventore iusto magnam nam odio perferendis, + perspiciatis, placeat totam voluptatem. +
+
+ +
+ top-left + top-center + top-right +
+
+ left-top + + left-center + + + left-bottom + +
+ + + + +
Content
+
+ +
+ + right-top + + + right-center + + + right-bottom + +
+
+ bottom-left + + bottom-center + + bottom-right +
+ + consectetur adipisicing elit. Dicta ex fugiat in maxime praesentium, quia sed vitae! Alias, beatae cumque dolores + eos, fuga ipsum molestiae, nisi quasi qui repellat soluta. Lorem ipsum dolor sit amet, consectetur adipisicing elit. + Dicta ex fugiat in maxime praesentium, quia sed vitae! Alias, beatae cumque dolores eos, fuga ipsum molestiae, nisi + quasi qui repellat soluta.Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dicta ex fugiat in maxime + praesentium, quia sed vitae! Alias, beatae cumque dolores eos, fuga ipsum molestiae, nisi quasi qui repellat + soluta.Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dicta ex fugiat in maxime praesentium, quia sed + vitae! Alias, beatae cumque dolores eos, fuga ipsum molestiae, nisi quasi qui repellat soluta. Lorem ipsum dolor sit + amet, consectetur adipisicing elit. Dicta ex fugiat in maxime praesentium, quia sed vitae! Alias, beatae cumque + dolores eos, fuga ipsum molestiae, nisi quasi qui repellat soluta. Lorem ipsum dolor sit amet, consectetur + adipisicing elit. Dicta ex fugiat in maxime praesentium, quia sed vitae! Alias, beatae cumque dolores eos, fuga + ipsum molestiae, nisi quasi qui repellat soluta. Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dicta ex + fugiat in maxime praesentium, quia sed vitae! Alias, beatae cumque dolores eos, fuga ipsum molestiae, nisi quasi qui + repellat soluta. Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dicta ex fugiat in maxime praesentium, + quia sed vitae! Alias, beatae cumque dolores eos, fuga ipsum molestiae, nisi quasi qui repellat soluta. Lorem ipsum + dolor sit amet, consectetur adipisicing elit. Dicta ex fugiat in maxime praesentium, quia sed vitae! Alias, beatae + cumque dolores eos, fuga ipsum molestiae, nisi quasi qui repellat soluta.Lorem ipsum dolor sit amet, consectetur + adipisicing elit. Dicta ex fugiat in maxime praesentium, quia sed vitae! Alias, beatae cumque dolores eos, fuga + ipsum molestiae, nisi quasi qui repellat soluta.Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dicta ex + fugiat in maxime praesentium, quia sed vitae! Alias, beatae cumque dolores eos, fuga ipsum molestiae, nisi quasi qui + repellat soluta.Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dicta ex fugiat in maxime praesentium, + quia sed vitae! Alias, beatae cumque dolores eos, fuga ipsum molestiae, nisi quasi qui repellat soluta.Lorem ipsum + dolor sit amet, consectetur adipisicing elit. Dicta ex fugiat in maxime praesentium, quia sed vitae! Alias, beatae + cumque dolores eos, fuga ipsum molestiae, nisi quasi qui repellat soluta. +
diff --git a/src/app/test-sidebar/test-sidebar.component.scss b/src/app/test-sidebar/test-sidebar.component.scss new file mode 100644 index 000000000..67545e3a8 --- /dev/null +++ b/src/app/test-sidebar/test-sidebar.component.scss @@ -0,0 +1,46 @@ +@import "./projects/evo-ui-kit/src/lib/styles/components/evo-sidebar-mixins.scss"; + +:host { + @include sidebar-inner; + + overflow-y: auto; + + [evo-sidebar-header] { + margin: 0; + padding: 32px 32px 16px; + position: sticky; + top: 0; + background: #fff; + z-index: 1; + border-bottom: none; + + &:before { + left: 32px; + right: 32px; + bottom: 0; + height: 1px; + + background: $color-disabled; + + position: absolute; + content: ''; + } + } + + [evo-sidebar-content] { + overflow-y: initial; + flex-grow: 0; + overscroll-behavior: none; + } + + [evo-sidebar-footer] { + margin: 0; + padding: 0 32px; + position: sticky; + bottom: 0; + + ::ng-deep .evo-sidebar__footer { + margin: 0; + } + } +} diff --git a/src/app/test-sidebar/test-sidebar.component.spec.ts b/src/app/test-sidebar/test-sidebar.component.spec.ts new file mode 100644 index 000000000..d7db81adc --- /dev/null +++ b/src/app/test-sidebar/test-sidebar.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TestSidebarComponent } from './test-sidebar.component'; + +describe('TestSidebarComponent', () => { + let component: TestSidebarComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ TestSidebarComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TestSidebarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/test-sidebar/test-sidebar.component.ts b/src/app/test-sidebar/test-sidebar.component.ts new file mode 100644 index 000000000..297a17112 --- /dev/null +++ b/src/app/test-sidebar/test-sidebar.component.ts @@ -0,0 +1,12 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {FormControl} from '@angular/forms'; + +@Component({ + selector: 'app-test-sidebar', + templateUrl: './test-sidebar.component.html', + styleUrls: ['./test-sidebar.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TestSidebarComponent { + currentPosition = new FormControl(); +}