From 2b6afbc3d4715b7629020f7b88041377f776a97c Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Tue, 5 May 2026 19:43:50 +0300 Subject: [PATCH 01/24] 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 1f5990798..21c191fb1 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 33cc3f7bd7fa465f631ef0d2e13c4a1e72225fb3 Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Tue, 12 May 2026 14:05:36 +0300 Subject: [PATCH 02/24] 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 516ecddcd1941556adcc3132586b62b8472fb0bd Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Tue, 12 May 2026 15:57:35 +0300 Subject: [PATCH 03/24] refactor(evo-tooltip): close tooltip inside directive --- .../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 ++++++++---------- 4 files changed, 95 insertions(+), 80 deletions(-) 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 498201c13..62c9f7665 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; @@ -80,9 +79,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 4c635df8c92ceb3b8548ab0b208cd94754b65872 Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Wed, 13 May 2026 00:35:09 +0300 Subject: [PATCH 04/24] feat(evo-tooltip): added touchstart handler --- .../directives/evo-tooltip.directive.spec.ts | 55 +++++++++++-------- .../directives/evo-tooltip.directive.ts | 8 ++- 2 files changed, 38 insertions(+), 25 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 8e3a94f72..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', @@ -120,4 +121,14 @@ describe('EvoTooltipDirective', () => { 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 1f363c9dc20da1565d8122c071cb3ed9a8df1947 Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Tue, 12 May 2026 23:09:52 +0300 Subject: [PATCH 05/24] 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 3ce4b0b11..84d4cc03c 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 62c9f7665..1eaa1f6fa 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'; @@ -45,8 +45,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 595284583ea34379b6bad13db6dd04199b0808ef Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Wed, 13 May 2026 09:44:55 +0300 Subject: [PATCH 06/24] 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 84d4cc03c..6338ae615 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 1eaa1f6fa..a5bc98536 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'; @@ -95,12 +95,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 1670b815ca867ac16a5bc4f890f6c086ff0beef3 Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Wed, 13 May 2026 17:06:46 +0300 Subject: [PATCH 07/24] 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 6338ae615..6a948344a 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 a5bc98536..e9ff4e3d4 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 @@ -36,12 +36,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 = { @@ -80,7 +88,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); @@ -96,20 +108,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 2eeb47913f26ad93b7fedbf5c3fc13fcb53ecddf Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Thu, 14 May 2026 12:19:04 +0300 Subject: [PATCH 08/24] refactor(evo-tooltip): tooltip as standalone and use signals --- .../directives/evo-tooltip.directive.spec.ts | 5 +- .../directives/evo-tooltip.directive.ts | 83 +++++++++-------- .../evo-tooltip/evo-tooltip.component.html | 26 +++--- .../evo-tooltip/evo-tooltip.component.spec.ts | 13 ++- .../evo-tooltip/evo-tooltip.component.ts | 90 +++++++++++-------- .../evo-tooltip/evo-tooltip.module.ts | 9 +- .../services/evo-tooltip.service.spec.ts | 5 +- 7 files changed, 122 insertions(+), 109 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..24a0b0ee6 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 @@ -8,7 +8,6 @@ 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 {By} from '@angular/platform-browser'; @Component({ @@ -52,8 +51,8 @@ describe('EvoTooltipDirective', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [TestHostComponent, EvoTooltipDirective, EvoTooltipComponent], - imports: [CommonModule, BrowserAnimationsModule], + declarations: [TestHostComponent], + imports: [CommonModule, BrowserAnimationsModule, EvoTooltipDirective], providers: [EvoTooltipService], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); 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..ac3714bf4 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 @@ -1,15 +1,17 @@ import { + DestroyRef, Directive, + effect, ElementRef, - EventEmitter, HostBinding, - Input, + inject, + input, OnDestroy, OnInit, - Output, + output, TemplateRef, } from '@angular/core'; -import {fromEvent, merge, Subject} from 'rxjs'; +import {fromEvent, merge} 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'; @@ -17,38 +19,44 @@ 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 {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @Directive({ selector: '[evoTooltip]', exportAs: 'evoTooltip', + standalone: true, providers: [EvoTooltipService], }) export class EvoTooltipDirective implements OnInit, OnDestroy { - @Input('evoTooltip') content: string | TemplateRef; - @Input('evoTooltipPosition') position: EvoTooltipPositionType | string = EvoTooltipPosition.BOTTOM; - @Input('evoTooltipDisabled') disabled = false; - @Input('evoTooltipConfig') config: Partial; + readonly content = input>('', {alias: 'evoTooltip'}); - @Input('evoTooltipVisibleArrow') visibleArrow = true; + readonly position = input(EvoTooltipPosition.BOTTOM, {alias: 'evoTooltipPosition'}); - @Input() set evoTooltipStyles(tooltipStyles: EvoTooltipStyles) { - this.tooltipService.setTooltipStyles(tooltipStyles); - } + readonly disabled = input(false, {alias: 'evoTooltipDisabled'}); - @Input() set evoTooltipClass(tooltipClass: string | string[]) { - this.tooltipService.setTooltipClass(tooltipClass); - } + readonly config = input>(EVO_TOOLTIP_CONFIG, {alias: 'evoTooltipConfig'}); + + readonly isVisibleArrow = input(true, {alias: 'evoTooltipVisibleArrow'}); + + readonly tooltipStyles = input({}, {alias: 'evoTooltipStyles'}); + + readonly tooltipClass = input([], {alias: 'evoTooltipClass'}); - @Output() evoTooltipOpen = new EventEmitter(); - @Output() evoTooltipClose = new EventEmitter(); + readonly evoTooltipOpen = output() + readonly evoTooltipClose = output() + + private readonly tooltipService = inject(EvoTooltipService); + private readonly destroyRef = inject(DestroyRef); + private readonly elementRef = inject(ElementRef); @HostBinding('class') get hostClasses(): string[] { - return ['evo-tooltip-trigger', ...(this.disabled ? ['evo-tooltip-trigger_disabled'] : [])]; + return ['evo-tooltip-trigger', ...(this.disabled() ? ['evo-tooltip-trigger_disabled'] : [])]; } - private readonly destroy$ = new Subject(); - - constructor(private readonly elementRef: ElementRef, private readonly tooltipService: EvoTooltipService) {} + constructor() { + effect((): void => this.tooltipService.setTooltipStyles(this.tooltipStyles())); + effect((): void => this.tooltipService.setTooltipClass(this.tooltipClass())); + } ngOnInit(): void { this.initSubscriptions(); @@ -56,8 +64,6 @@ export class EvoTooltipDirective implements OnInit, OnDestroy { ngOnDestroy(): void { this.hide(); - this.destroy$.next(); - this.destroy$.complete(); } hide(): void { @@ -65,15 +71,17 @@ export class EvoTooltipDirective implements OnInit, OnDestroy { } show(): void { - if (!this.content || this.tooltipService.hasAttached || this.disabled) { + const content = this.content(); + + if (!content || this.tooltipService.hasAttached || this.disabled()) { return; } const tooltip = this.tooltipService.showTooltip({ parentRef: this.elementRef, - content: this.content, - position: this.position as EvoTooltipPosition, - hasArrow: this.visibleArrow, + content: content, + position: this.position() as EvoTooltipPosition, + hasArrow: this.isVisibleArrow(), }); this.initHideSubscription(tooltip); @@ -81,19 +89,19 @@ export class EvoTooltipDirective implements OnInit, OnDestroy { private initSubscriptions(): void { const element = this.elementRef.nativeElement; - const showDelay = this.config?.showDelay ?? EVO_TOOLTIP_CONFIG.showDelay; + const showDelay = this.config()?.showDelay ?? EVO_TOOLTIP_CONFIG.showDelay; merge(fromEvent(element, 'mouseenter'), fromEvent(element, 'touchstart')) .pipe( throttleTime(showDelay), - tap(() => this.show()), - takeUntil(this.destroy$), + tap((): void => this.show()), + takeUntilDestroyed(this.destroyRef), ) .subscribe(); this.tooltipService.isOpen$ .pipe( - tap((isOpen) => { + tap((isOpen): void => { if (isOpen) { this.evoTooltipOpen.emit(); return; @@ -101,23 +109,24 @@ export class EvoTooltipDirective implements OnInit, OnDestroy { this.evoTooltipClose.emit(); }), - takeUntil(this.destroy$), + takeUntilDestroyed(this.destroyRef), ) .subscribe(); } private initHideSubscription(tooltip: HTMLElement): void { const trigger = this.elementRef.nativeElement; - const closed$ = this.tooltipService.isOpen$.pipe(filter((isOpen) => !isOpen)); + const closed$ = this.tooltipService.isOpen$.pipe(filter((isOpen): boolean => !isOpen)); - const hideDelay = this.config?.hideDelay ?? EVO_TOOLTIP_CONFIG.hideDelay; + 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$)), + filter((): boolean => !tooltip.matches(':hover') && !trigger.matches(':hover')), + tap((): void => this.tooltipService.hideTooltip()), + takeUntil(closed$), + takeUntilDestroyed(this.destroyRef), ) .subscribe(); } diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.html b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.html index dc12a1f5e..1199f8c07 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.html +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.component.html @@ -1,16 +1,12 @@ - -
- {{ content }} -
-
- - - - + @if (stringContent(); as content) { + {{ content }} + } @else if (templateContent()) { + + } +
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 6a948344a..7eedf496f 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,7 +1,7 @@ import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; import {EvoTooltipComponent} from './evo-tooltip.component'; import {EvoTooltipService} from './services/evo-tooltip.service'; -import {Component, ElementRef, getDebugNode, NO_ERRORS_SCHEMA, TemplateRef, ViewChild} from '@angular/core'; +import {Component, ElementRef, NO_ERRORS_SCHEMA, TemplateRef, ViewChild} from '@angular/core'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {CommonModule} from '@angular/common'; import {OverlayContainer} from '@angular/cdk/overlay'; @@ -35,8 +35,8 @@ describe('EvoTooltipComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [EvoTooltipComponent, TestHostComponent], - imports: [BrowserAnimationsModule, CommonModule], + declarations: [TestHostComponent], + imports: [BrowserAnimationsModule, CommonModule, EvoTooltipComponent], schemas: [NO_ERRORS_SCHEMA], providers: [EvoTooltipService], }).compileComponents(); @@ -56,7 +56,7 @@ describe('EvoTooltipComponent', () => { }); const showTooltip = ( - content: string | TemplateRef, + content: string | TemplateRef, position = EvoTooltipPosition.TOP, hasArrow = true, ): HTMLElement => { @@ -146,14 +146,11 @@ describe('EvoTooltipComponent', () => { it('should unsubscribe from service updates on destroy', fakeAsync(() => { const tooltipHost = showTooltip(textTooltipContent); - const tooltipComponent = getDebugNode(tooltipHost).componentInstance; - - tooltipComponent.ngOnDestroy(); + tooltipService.hideTooltip(); 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 9574670cc..5d41cc26f 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 @@ -2,20 +2,25 @@ import { AfterViewInit, ChangeDetectionStrategy, Component, + DestroyRef, ElementRef, HostBinding, + inject, OnDestroy, Renderer2, + Signal, TemplateRef, } from '@angular/core'; -import {combineLatest, Observable, Subject} from 'rxjs'; -import {map, pairwise, startWith, takeUntil, tap} from 'rxjs/operators'; +import {combineLatest} from 'rxjs'; +import {map, pairwise, startWith, 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 {EvoTooltipStyleVariable} from './enums/evo-tooltip-style-variable'; import {getTooltipArrowOffset} from './utils/get-tooltip-arrow-offset'; +import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop"; +import {NgTemplateOutlet} from "@angular/common"; @Component({ selector: 'evo-tooltip', @@ -23,60 +28,73 @@ import {getTooltipArrowOffset} from './utils/get-tooltip-arrow-offset'; styleUrls: ['./evo-tooltip.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, animations: [EVO_TOOLTIP_FADEIN_ANIMATION], + standalone: true, + imports: [ + NgTemplateOutlet + ] }) 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 position: Signal; + readonly stringContent: Signal; + readonly templateContent: Signal>; + readonly visibleArrow: Signal; - 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, - ), - ); + readonly styles: Signal; @HostBinding('@fadeIn') fadeIn = true; - private readonly _destroy$ = new Subject(); + private readonly tooltipService = inject(EvoTooltipService); + private readonly renderer = inject(Renderer2); + private readonly elementRef = inject(ElementRef); + private readonly destroyRef = inject(DestroyRef); + + constructor() { + this.position = toSignal(this.tooltipService.position$); + this.stringContent = toSignal(this.tooltipService.stringContent$); + this.templateContent = toSignal(this.tooltipService.templateContent$); + this.visibleArrow = toSignal(this.tooltipService.visibleArrow$); + + this.styles = this.getStyles(); + } - constructor( - private readonly elementRef: ElementRef, - private readonly tooltipService: EvoTooltipService, - private readonly renderer: Renderer2, - ) {} ngAfterViewInit(): void { this.tooltipService.tooltipClasses$ .pipe( startWith([]), pairwise(), - tap(([a, b]: [string[], string[]]) => { - (a || []).forEach((oldClass) => this.renderer.removeClass(this.elementRef.nativeElement, oldClass)); - (b || []).forEach((newClass) => this.renderer.addClass(this.elementRef.nativeElement, newClass)); + tap(([a, b]: [string[], string[]]): void => { + (a || []).forEach((oldClass): void => this.renderer.removeClass(this.elementRef.nativeElement, oldClass)); + (b || []).forEach((newClass): void => this.renderer.addClass(this.elementRef.nativeElement, newClass)); }), - takeUntil(this._destroy$), + takeUntilDestroyed(this.destroyRef), ) .subscribe(); } ngOnDestroy(): void { this.fadeIn = false; - this._destroy$.next(); - this._destroy$.complete(); + } + + private getStyles(): Signal { + return toSignal(combineLatest([ + this.tooltipService.position$, + this.tooltipService.styles$, + this.tooltipService.parentRef$, + this.tooltipService.visibleArrow$, + ]).pipe( + map( + ([position, baseStyles, parentRef, visibleArrow]: [ + EvoTooltipPosition, + EvoTooltipStyles, + ElementRef, + boolean, + ]): EvoTooltipStyles => + visibleArrow && parentRef + ? {...baseStyles, ...this.calculateArrowStyles(parentRef, position)} + : baseStyles, + ), + )); } private calculateArrowStyles(parentRef: ElementRef, position: EvoTooltipPosition): EvoTooltipStyles { diff --git a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.module.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.module.ts index 4c2c36cf7..b15de7170 100644 --- a/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.module.ts +++ b/projects/evo-ui-kit/src/lib/components/evo-tooltip/evo-tooltip.module.ts @@ -1,12 +1,9 @@ import {NgModule} from '@angular/core'; -import {CommonModule} from '@angular/common'; -import {EvoTooltipComponent} from './evo-tooltip.component'; import {EvoTooltipDirective} from './directives/evo-tooltip.directive'; -import {OverlayModule} from '@angular/cdk/overlay'; @NgModule({ - imports: [OverlayModule, CommonModule], - declarations: [EvoTooltipComponent, EvoTooltipDirective], + imports: [EvoTooltipDirective], exports: [EvoTooltipDirective], }) -export class EvoTooltipModule {} +export class EvoTooltipModule { +} 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 e9ff4e3d4..3889ba309 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 @@ -15,8 +15,7 @@ describe('EvoTooltipService', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [CommonModule, BrowserAnimationsModule], - declarations: [EvoTooltipComponent], + imports: [CommonModule, BrowserAnimationsModule, EvoTooltipComponent], schemas: [NO_ERRORS_SCHEMA], providers: [EvoTooltipService], }); @@ -26,8 +25,6 @@ describe('EvoTooltipService', () => { nativeElement: { offsetWidth: 100, offsetHeight: 50, - addEventListener: () => {}, - removeEventListener: () => {}, }, }; }); From 1afa16570787ccce47b89e657c0cdae79e0c18f8 Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Tue, 12 May 2026 13:24:47 +0300 Subject: [PATCH 09/24] 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..4838a2826 --- /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): boolean => { + 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 ff6bf57cd..cc100160e 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 e46b96acb716e848a996c547664deccec67339cd Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Wed, 13 May 2026 19:11:05 +0300 Subject: [PATCH 10/24] 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 24a0b0ee6..8dd47ad68 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 @@ -9,6 +9,7 @@ import {EvoTooltipService} from '../services/evo-tooltip.service'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {first} from 'rxjs/operators'; import {By} from '@angular/platform-browser'; +import {EvoScrollStrategyOptions} from '../../../common/scroll'; @Component({ template: ` @@ -53,7 +54,7 @@ describe('EvoTooltipDirective', () => { await TestBed.configureTestingModule({ declarations: [TestHostComponent], imports: [CommonModule, BrowserAnimationsModule, EvoTooltipDirective], - 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 ac3714bf4..b822379c0 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 @@ -20,12 +20,13 @@ import {EVO_TOOLTIP_CONFIG} from '../constants/evo-tooltip-config'; import {EvoTooltipPosition} from '../enums/evo-tooltip-position'; import {EvoTooltipStyles} from '../interfaces/evo-tooltip-styles'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {EvoScrollStrategyOptions} from '../../../common/scroll'; @Directive({ selector: '[evoTooltip]', exportAs: 'evoTooltip', standalone: true, - providers: [EvoTooltipService], + providers: [EvoTooltipService, EvoScrollStrategyOptions], }) export class EvoTooltipDirective implements OnInit, OnDestroy { readonly content = input>('', {alias: 'evoTooltip'}); @@ -82,6 +83,7 @@ export class EvoTooltipDirective implements OnInit, OnDestroy { content: content, position: this.position() as EvoTooltipPosition, hasArrow: this.isVisibleArrow(), + 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 7eedf496f..d871c6eda 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: [TestHostComponent], imports: [BrowserAnimationsModule, CommonModule, EvoTooltipComponent], 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 3889ba309..151d94f13 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; @@ -17,15 +18,20 @@ describe('EvoTooltipService', () => { TestBed.configureTestingModule({ imports: [CommonModule, BrowserAnimationsModule, 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 094139c053aa7bf65b0832b3c88516a2fa1b7edd Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Tue, 12 May 2026 13:25:23 +0300 Subject: [PATCH 11/24] feat(evo-dropdown): use scroll strategies in dropdown --- .../evo-dropdown/evo-dropdown.component.html | 1 + .../evo-dropdown/evo-dropdown.component.ts | 83 +++++-------------- 2 files changed, 24 insertions(+), 60 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 42110ee35..a05871dd3 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 b415d8c6e..fb3f9d5de 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,10 @@ import {EvoDropdownOriginDirective} from './evo-dropdown-origin.directive'; import {CdkConnectedOverlay, 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'; +import {AsyncPipe} from "@angular/common"; type Position = EvoDropdownPositions | ConnectedPosition; @@ -25,12 +24,12 @@ const DEFAULT_POSITION = [EVO_DROPDOWN_POSITION_DESCRIPTION['bottom-right']]; selector: 'evo-dropdown', templateUrl: './evo-dropdown.component.html', changeDetection: ChangeDetectionStrategy.OnPush, + providers: [EvoScrollStrategyOptions], standalone: true, - imports: [CdkConnectedOverlay], + imports: [CdkConnectedOverlay, AsyncPipe], }) -export class EvoDropdownComponent implements OnDestroy { +export class EvoDropdownComponent { @Input() closeOnOutsideClick = true; - @Input() scrollStrategy: 'noop' | 'close' = 'close'; @Input() dropdownOrigin!: EvoDropdownOriginDirective; @Output() isOpenChange = new EventEmitter(); @@ -38,8 +37,8 @@ export class EvoDropdownComponent implements OnDestroy { connectedPositions: ConnectedPosition[] = DEFAULT_POSITION; - private scrollEventSubscription: Subscription; - private readonly destroy$ = new Subject(); + readonly scrollStrategy$ = new BehaviorSubject(this.evoScrollStrategyOptions.close()); + private _isOpen = false; get isOpen(): boolean { @@ -48,10 +47,6 @@ export class EvoDropdownComponent implements OnDestroy { @Input() set isOpen(value: boolean) { this._isOpen = value; - - if (value) { - this.listenScroll(); - } } @Input() set positions(value: Position[] | Position) { @@ -60,19 +55,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, ) {} @@ -102,18 +103,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); @@ -124,33 +116,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 449330599dff94309a1683a8dddbef56e3b1d6a5 Mon Sep 17 00:00:00 2001 From: "a.kiselev" Date: Wed, 13 May 2026 19:15:20 +0300 Subject: [PATCH 12/24] feat(app): add test environment --- src/app/app.component.html | 36 ++++++ src/app/app.component.scss | 41 ++++++ src/app/app.component.ts | 31 ++++- src/app/app.module.ts | 33 +++-- .../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, 332 insertions(+), 9 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 a3fddf2be..8afa20db6 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,9 +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 { + 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 969140a7e..3a8b7b245 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,18 +1,35 @@ -import { BrowserModule } from '@angular/platform-browser'; -import { NgModule } from '@angular/core'; +import {BrowserModule} from '@angular/platform-browser'; +import {NgModule} from '@angular/core'; -import { AppComponent } from './app.component'; -import { ReactiveFormsModule } from '@angular/forms'; +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 {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'; +import { + EvoSidebarContentComponent, + EvoSidebarHeaderComponent, + provideSidebar +} from 'projects/evo-ui-kit/src/public_api'; +import {provideHttpClient} from "@angular/common/http"; @NgModule({ - declarations: [ - AppComponent, - ], + declarations: [AppComponent, TestSidebarComponent], imports: [ BrowserModule, ReactiveFormsModule, + EvoTooltipModule, + BrowserAnimationsModule, + EvoChipModule, + EvoDropdownModule, + EvoButtonModule, + EvoSidebarHeaderComponent, + EvoSidebarContentComponent, ], - providers: [], + providers: [provideSidebar(), provideHttpClient()], 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(); +} From f715973e28c55c836562fc1da17db51a1e74782a Mon Sep 17 00:00:00 2001 From: Artem Harlamenko <4.harlamenko@gmail.com> Date: Sun, 7 Jun 2026 15:09:33 +0300 Subject: [PATCH 13/24] fix(evo-tooltip): import scroll strategies via relative path Importing from the library's own public_api created a circular dependency (public_api re-exports the tooltip service) and breaks the ng-packagr build. Use the same relative path the directive and other specs already use. --- .../lib/components/evo-tooltip/services/evo-tooltip.service.ts | 2 +- .../evo-tooltip/utils/get-tooltip-arrow-offset.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 491f43de8..121d008e2 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 @@ -13,7 +13,7 @@ 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'; +import {EvoScrollStrategy, EvoScrollStrategyOptions} from '../../../common/scroll'; const DEFAULT_TOOLTIP_SCROLL_STRATEGY: EvoScrollStrategy = 'close'; 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 index 361587997..bcd760a97 100644 --- 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 @@ -1,4 +1,4 @@ -import {EvoTooltipPosition} from 'projects/evo-ui-kit/src/public_api'; +import {EvoTooltipPosition} from '../enums/evo-tooltip-position'; 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'; From 6249aa77a4f38060896e8a4c525c7d7240ee65f7 Mon Sep 17 00:00:00 2001 From: Artem Harlamenko <4.harlamenko@gmail.com> Date: Sun, 7 Jun 2026 15:09:54 +0300 Subject: [PATCH 14/24] docs(evo-scroll-strategies): explain why CDK strategies are reimplemented Document the reason the custom strategies wrap a capture-phase document scroll listener instead of reusing CDK's ScrollDispatcher-based strategies, and leave a TODO to revisit. --- .../src/lib/common/scroll/utils/create-scroll-stream.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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 index 4838a2826..e9968fb92 100644 --- 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 @@ -2,6 +2,15 @@ import {animationFrameScheduler, fromEvent, Observable} from 'rxjs'; import {filter, throttleTime} from 'rxjs/operators'; import {OverlayRef} from '@angular/cdk/overlay'; +/** + * TODO(MRK-4890): revisit whether CDK's own scroll strategies can be reused here. + * + * The custom strategies built on top of this stream exist because they listen to + * scroll events on the whole document in the capture phase. CDK's reposition/close + * strategies react only to containers registered in the ScrollDispatcher (i.e. those + * marked with `cdkScrollable`), so overlays anchored inside arbitrary `overflow: auto` + * containers are not handled. The capture-phase listener below covers those cases. + */ export function createScrollStream(document: Document, overlayRef: OverlayRef): Observable { return fromEvent(document, 'scroll', {capture: true, passive: true}).pipe( throttleTime(10, animationFrameScheduler, {leading: true, trailing: true}), From c2be289feef8dfffc7a20f96520f9c0f2e978a0e Mon Sep 17 00:00:00 2001 From: Artem Harlamenko <4.harlamenko@gmail.com> Date: Sun, 7 Jun 2026 15:10:56 +0300 Subject: [PATCH 15/24] refactor(evo-scroll-strategies): centralize strategy selection in create() The EvoScrollStrategy -> concrete strategy switch was duplicated in the tooltip service and the dropdown component. Move it into EvoScrollStrategyOptions.create() so the union has a single owner, and replace the magic threshold with a named constant in the tooltip service. --- .../classes/evo-scroll-strategy-options.ts | 16 ++++++++++++++ .../evo-dropdown/evo-dropdown.component.ts | 14 +------------ .../services/evo-tooltip.service.ts | 21 +++++-------------- 3 files changed, 22 insertions(+), 29 deletions(-) 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 index efc97e1a0..cd7623cbe 100644 --- 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 @@ -3,11 +3,27 @@ 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'; +import {EvoScrollStrategy} from '../types/evo-scroll-strategy'; @Injectable() export class EvoScrollStrategyOptions { constructor(private readonly injector: Injector) {} + create(strategy: EvoScrollStrategy, params?: EvoCloseScrollStrategyParams): ScrollStrategy { + switch (strategy) { + case 'noop': { + return this.noop(); + } + case 'reposition': { + return this.reposition(); + } + case 'close': + default: { + return this.close(params); + } + } + } + noop(): ScrollStrategy { return new NoopScrollStrategy(); }; 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 fb3f9d5de..ffdcffbe0 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 @@ -56,19 +56,7 @@ export class EvoDropdownComponent { } @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()); - } - } + this.scrollStrategy$.next(this.evoScrollStrategyOptions.create(strategy)); } constructor( 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 121d008e2..c8ad8e28b 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 @@ -16,6 +16,7 @@ import {getTooltipConnectedPositions} from '../utils/get-tooltip-connected-posit import {EvoScrollStrategy, EvoScrollStrategyOptions} from '../../../common/scroll'; const DEFAULT_TOOLTIP_SCROLL_STRATEGY: EvoScrollStrategy = 'close'; +const DEFAULT_TOOLTIP_CLOSE_THRESHOLD = 10; @Injectable() export class EvoTooltipService implements OnDestroy { @@ -143,22 +144,10 @@ export class EvoTooltipService implements OnDestroy { } 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, - }); - } - } + return this.evoScrollStrategyOptions.create(scrollStrategy, { + threshold: DEFAULT_TOOLTIP_CLOSE_THRESHOLD, + triggerRef: parentRef, + }); } private createPortal(): void { From 7b61a3312c3c0c871bdb467092f84886987dede9 Mon Sep 17 00:00:00 2001 From: Artem Harlamenko <4.harlamenko@gmail.com> Date: Sun, 7 Jun 2026 15:12:02 +0300 Subject: [PATCH 16/24] refactor(evo-scroll-strategies): provide options in root EvoScrollStrategyOptions is stateless, so provide it in root and drop the duplicated providers entries from the tooltip directive, the dropdown component and their specs. --- .../lib/common/scroll/classes/evo-scroll-strategy-options.ts | 2 +- .../src/lib/components/evo-dropdown/evo-dropdown.component.ts | 1 - .../evo-tooltip/directives/evo-tooltip.directive.spec.ts | 3 +-- .../components/evo-tooltip/directives/evo-tooltip.directive.ts | 3 +-- .../evo-tooltip/services/evo-tooltip.service.spec.ts | 3 +-- 5 files changed, 4 insertions(+), 8 deletions(-) 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 index cd7623cbe..1e50b4173 100644 --- 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 @@ -5,7 +5,7 @@ import {EvoRepositionScrollStrategy} from './evo-reposition-scroll-strategy'; import {EvoCloseScrollStrategyParams} from '../interfaces/evo-close-scroll-strategy-params'; import {EvoScrollStrategy} from '../types/evo-scroll-strategy'; -@Injectable() +@Injectable({providedIn: 'root'}) export class EvoScrollStrategyOptions { constructor(private readonly injector: Injector) {} 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 ffdcffbe0..a0879cb3d 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 @@ -24,7 +24,6 @@ const DEFAULT_POSITION = [EVO_DROPDOWN_POSITION_DESCRIPTION['bottom-right']]; selector: 'evo-dropdown', templateUrl: './evo-dropdown.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [EvoScrollStrategyOptions], standalone: true, imports: [CdkConnectedOverlay, AsyncPipe], }) 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 8dd47ad68..24a0b0ee6 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 @@ -9,7 +9,6 @@ import {EvoTooltipService} from '../services/evo-tooltip.service'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {first} from 'rxjs/operators'; import {By} from '@angular/platform-browser'; -import {EvoScrollStrategyOptions} from '../../../common/scroll'; @Component({ template: ` @@ -54,7 +53,7 @@ describe('EvoTooltipDirective', () => { await TestBed.configureTestingModule({ declarations: [TestHostComponent], imports: [CommonModule, BrowserAnimationsModule, EvoTooltipDirective], - providers: [EvoTooltipService, EvoScrollStrategyOptions], + providers: [EvoTooltipService], 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 b822379c0..519a5778f 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 @@ -20,13 +20,12 @@ import {EVO_TOOLTIP_CONFIG} from '../constants/evo-tooltip-config'; import {EvoTooltipPosition} from '../enums/evo-tooltip-position'; import {EvoTooltipStyles} from '../interfaces/evo-tooltip-styles'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {EvoScrollStrategyOptions} from '../../../common/scroll'; @Directive({ selector: '[evoTooltip]', exportAs: 'evoTooltip', standalone: true, - providers: [EvoTooltipService, EvoScrollStrategyOptions], + providers: [EvoTooltipService], }) export class EvoTooltipDirective implements OnInit, OnDestroy { readonly content = input>('', {alias: 'evoTooltip'}); 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 151d94f13..419c94552 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,7 +8,6 @@ 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,7 +17,7 @@ describe('EvoTooltipService', () => { TestBed.configureTestingModule({ imports: [CommonModule, BrowserAnimationsModule, EvoTooltipComponent], schemas: [NO_ERRORS_SCHEMA], - providers: [EvoTooltipService, EvoScrollStrategyOptions], + providers: [EvoTooltipService], }); service = TestBed.inject(EvoTooltipService); From 831982cb295df13806b7f0820753b5a466e68665 Mon Sep 17 00:00:00 2001 From: Artem Harlamenko <4.harlamenko@gmail.com> Date: Sun, 7 Jun 2026 15:12:28 +0300 Subject: [PATCH 17/24] refactor(evo-dropdown): remove unused viewContainerRef injection It was only used by the element getter / listenScroll that were removed when the dropdown switched to the shared scroll strategies; there are no subclasses. --- .../components/evo-dropdown/evo-dropdown.component.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) 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 a0879cb3d..e815b3c3d 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 @@ -1,12 +1,4 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - EventEmitter, - Input, - Output, - ViewContainerRef, -} from '@angular/core'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output} from '@angular/core'; import {EvoDropdownOriginDirective} from './evo-dropdown-origin.directive'; import {CdkConnectedOverlay, ConnectedPosition} from '@angular/cdk/overlay'; import {EVO_DROPDOWN_POSITION_DESCRIPTION} from './evo-dropdown-position-description'; @@ -60,7 +52,6 @@ export class EvoDropdownComponent { constructor( private readonly evoScrollStrategyOptions: EvoScrollStrategyOptions, - protected readonly viewContainerRef: ViewContainerRef, private readonly cdr: ChangeDetectorRef, ) {} From 9e05b4f0a8c59eeac2e7761afbf781d0f5549c36 Mon Sep 17 00:00:00 2001 From: Artem Harlamenko <4.harlamenko@gmail.com> Date: Sun, 7 Jun 2026 15:13:06 +0300 Subject: [PATCH 18/24] refactor(evo-dropdown): consolidate scroll module imports EvoScrollStrategy and EvoScrollStrategyOptions both live in the common/scroll barrel; import them from a single entry point instead of mixing a deep path with the barrel. --- .../src/lib/components/evo-dropdown/evo-dropdown.component.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 e815b3c3d..16f114277 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 @@ -4,8 +4,7 @@ import {CdkConnectedOverlay, ConnectedPosition} from '@angular/cdk/overlay'; import {EVO_DROPDOWN_POSITION_DESCRIPTION} from './evo-dropdown-position-description'; import {EvoDropdownPositions} from './types/evo-dropdown-positions'; import {BehaviorSubject} from 'rxjs'; -import {EvoScrollStrategy} from '../../common/scroll/types/evo-scroll-strategy'; -import {EvoScrollStrategyOptions} from '../../common/scroll'; +import {EvoScrollStrategy, EvoScrollStrategyOptions} from '../../common/scroll'; import {AsyncPipe} from "@angular/common"; type Position = EvoDropdownPositions | ConnectedPosition; From da8ba5951a6b1de8cc207e984860027cceb6a788 Mon Sep 17 00:00:00 2001 From: Artem Harlamenko <4.harlamenko@gmail.com> Date: Sun, 7 Jun 2026 15:14:10 +0300 Subject: [PATCH 19/24] refactor(evo-dropdown): store scroll strategy in a plain field CDK reads the scroll strategy only when the overlay opens, so the BehaviorSubject + async pipe bought nothing. Replace it with a plain field initialized in the constructor (no longer relying on a parameter being assigned before a field initializer) and drop the AsyncPipe import. --- .../evo-dropdown/evo-dropdown.component.html | 2 +- .../evo-dropdown/evo-dropdown.component.ts | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 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 a05871dd3..27e1e824d 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,7 +3,7 @@ [cdkConnectedOverlayOpen]="isOpen" [cdkConnectedOverlayOrigin]="dropdownOrigin" [cdkConnectedOverlayPositions]="connectedPositions" - [cdkConnectedOverlayScrollStrategy]="scrollStrategy$ | async" + [cdkConnectedOverlayScrollStrategy]="connectedScrollStrategy" (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 16f114277..72118ea35 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 @@ -1,22 +1,21 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output} from '@angular/core'; import {EvoDropdownOriginDirective} from './evo-dropdown-origin.directive'; -import {CdkConnectedOverlay, ConnectedPosition} from '@angular/cdk/overlay'; +import {CdkConnectedOverlay, ConnectedPosition, ScrollStrategy} from '@angular/cdk/overlay'; import {EVO_DROPDOWN_POSITION_DESCRIPTION} from './evo-dropdown-position-description'; import {EvoDropdownPositions} from './types/evo-dropdown-positions'; -import {BehaviorSubject} from 'rxjs'; import {EvoScrollStrategy, EvoScrollStrategyOptions} from '../../common/scroll'; -import {AsyncPipe} from "@angular/common"; type Position = EvoDropdownPositions | ConnectedPosition; const DEFAULT_POSITION = [EVO_DROPDOWN_POSITION_DESCRIPTION['bottom-right']]; +const DEFAULT_SCROLL_STRATEGY: EvoScrollStrategy = 'close'; @Component({ selector: 'evo-dropdown', templateUrl: './evo-dropdown.component.html', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [CdkConnectedOverlay, AsyncPipe], + imports: [CdkConnectedOverlay], }) export class EvoDropdownComponent { @Input() closeOnOutsideClick = true; @@ -27,7 +26,7 @@ export class EvoDropdownComponent { connectedPositions: ConnectedPosition[] = DEFAULT_POSITION; - readonly scrollStrategy$ = new BehaviorSubject(this.evoScrollStrategyOptions.close()); + connectedScrollStrategy: ScrollStrategy; private _isOpen = false; @@ -46,13 +45,15 @@ export class EvoDropdownComponent { } @Input() set scrollStrategy(strategy: EvoScrollStrategy) { - this.scrollStrategy$.next(this.evoScrollStrategyOptions.create(strategy)); + this.connectedScrollStrategy = this.evoScrollStrategyOptions.create(strategy); } constructor( private readonly evoScrollStrategyOptions: EvoScrollStrategyOptions, private readonly cdr: ChangeDetectorRef, - ) {} + ) { + this.connectedScrollStrategy = this.evoScrollStrategyOptions.create(DEFAULT_SCROLL_STRATEGY); + } toggle(): void { if (this.isOpen) { From e7ef21dc2091a3fece9723c7229aef1a9473f328 Mon Sep 17 00:00:00 2001 From: Artem Harlamenko <4.harlamenko@gmail.com> Date: Sun, 7 Jun 2026 15:14:41 +0300 Subject: [PATCH 20/24] test(evo-tooltip): assert open state via hasAttached in service spec isOpen$ is a plain Subject; in the show test it emitted inside showTooltip before the test subscribed, so first() never fired and the expectation was dead. Assert service.hasAttached synchronously instead. --- .../evo-tooltip/services/evo-tooltip.service.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 419c94552..dc7adfe74 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 @@ -104,9 +104,9 @@ describe('EvoTooltipService', () => { expect(value).toBe(position); }); - service.isOpen$.pipe(first()).subscribe((value) => { - expect(value).toBeTrue(); - }); + // isOpen$ is a plain Subject and already emitted inside showTooltip, so assert the + // current state directly instead of subscribing after the fact (which never fires). + expect(service.hasAttached).toBeTrue(); }); it('should hide tooltip', fakeAsync(() => { From 97aa0500ce5f787ede6c5203a6807617a5f4c52e Mon Sep 17 00:00:00 2001 From: Artem Harlamenko <4.harlamenko@gmail.com> Date: Sun, 7 Jun 2026 15:31:58 +0300 Subject: [PATCH 21/24] docs(evo-scroll-strategies): detail capture vs cdkScrollable rationale Replace the generic note with the concrete reason the custom stream exists: a capture-phase document listener catches scroll from any container (CDK's bubble listener + cdkScrollable registration misses unmarked overflow:auto blocks), and the close threshold is measured from the trigger rect rather than the viewport scroll position. --- .../scroll/utils/create-scroll-stream.ts | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) 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 index e9968fb92..951855c73 100644 --- 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 @@ -5,11 +5,28 @@ import {OverlayRef} from '@angular/cdk/overlay'; /** * TODO(MRK-4890): revisit whether CDK's own scroll strategies can be reused here. * - * The custom strategies built on top of this stream exist because they listen to - * scroll events on the whole document in the capture phase. CDK's reposition/close - * strategies react only to containers registered in the ScrollDispatcher (i.e. those - * marked with `cdkScrollable`), so overlays anchored inside arbitrary `overflow: auto` - * containers are not handled. The capture-phase listener below covers those cases. + * Why a custom stream instead of CDK's strategies: + * + * The overlay must follow / close when ANY container that moves the anchor scrolls, + * including arbitrary `overflow: auto` blocks (modal bodies, sidebars, nested lists). + * As a reusable library we cannot require consumers to mark every such container. + * + * CDK's reposition/close strategies subscribe to `ScrollDispatcher.scrolled()`, which is + * a *bubble-phase* listener on `document` PLUS each element explicitly registered via the + * `cdkScrollable` directive. The `scroll` event does not bubble, so the bubble-phase + * listener only sees page scroll — an unmarked inner container's scroll is invisible to + * CDK and the overlay stays glued to its old position. + * + * The listener below uses `capture: true`: a capture-phase listener on `document` receives + * scroll from every element in the tree (the capture phase runs document -> target even for + * non-bubbling events), so it covers every container with zero registration. + * + * Related: EvoCloseScrollStrategy measures the threshold from the trigger's + * getBoundingClientRect() displacement (both axes), whereas CDK's CloseScrollStrategy reads + * only the viewport's vertical scroll position — which never changes on inner-container scroll. + * + * Trade-off: this fires for every scroll on the page, not only relevant ones; mitigated by + * the animation-frame throttle and the `overlayElement.contains(target)` filter below. */ export function createScrollStream(document: Document, overlayRef: OverlayRef): Observable { return fromEvent(document, 'scroll', {capture: true, passive: true}).pipe( From e247c320d84493227b19102c2a475018905ce924 Mon Sep 17 00:00:00 2001 From: Artem Harlamenko <4.harlamenko@gmail.com> Date: Sun, 7 Jun 2026 21:51:49 +0300 Subject: [PATCH 22/24] feat(evo-scroll-strategies): observe scroll per anchor ancestor Replace the global capture-phase document listener with per-ancestor passive listeners: walk the anchor's DOM ancestors, keep only real scroll containers (getScrollableAncestors, ported from Floating UI's getOverflowAncestors), and attach one passive listener per ancestor plus the window. This reacts only to the few containers in the anchor's chain instead of every scroll on the page, still needs no cdkScrollable markup, and re-discovers ancestors on each enable(). The anchor is passed as a lazy getOrigin() resolved on enable(), so the dropdown origin input need not be set before the strategy is created. Unifies the close / reposition params into EvoScrollStrategyParams. --- .../classes/evo-close-scroll-strategy.ts | 18 ++++---- .../classes/evo-reposition-scroll-strategy.ts | 5 ++- .../classes/evo-scroll-strategy-options.ts | 12 ++--- .../evo-close-scroll-strategy-params.ts | 7 --- .../interfaces/evo-scroll-strategy-params.ts | 10 +++++ .../scroll/utils/create-scroll-stream.ts | 44 ++++++++----------- .../scroll/utils/get-scrollable-ancestors.ts | 34 ++++++++++++++ .../evo-dropdown/evo-dropdown.component.ts | 12 ++++- .../services/evo-tooltip.service.ts | 2 +- 9 files changed, 92 insertions(+), 52 deletions(-) delete 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/evo-scroll-strategy-params.ts create mode 100644 projects/evo-ui-kit/src/lib/common/scroll/utils/get-scrollable-ancestors.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 index e0ec12621..f03d4d827 100644 --- 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 @@ -1,14 +1,12 @@ 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 {EvoScrollStrategyParams} from '../interfaces/evo-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; @@ -16,8 +14,7 @@ export class EvoCloseScrollStrategy implements ScrollStrategy { private initialScrollPosition: ScrollPosition | null = null; - constructor(private readonly injector: Injector, private readonly params?: EvoCloseScrollStrategyParams) { - this.document = this.injector.get(DOCUMENT); + constructor(private readonly injector: Injector, private readonly params?: EvoScrollStrategyParams) { this.ngZone = this.injector.get(NgZone); } @@ -47,7 +44,7 @@ export class EvoCloseScrollStrategy implements ScrollStrategy { this.ngZone.runOutsideAngular(() => { this.initialScrollPosition = this.getCurrentScrollPosition(); - this.scrollSubscription = createScrollStream(this.document, this.overlayRef) + this.scrollSubscription = createScrollStream(this.overlayRef, this.getOriginElement()) .pipe( filter(() => this.checkThreshold()), first(), @@ -68,9 +65,8 @@ export class EvoCloseScrollStrategy implements ScrollStrategy { private checkThreshold(): boolean { const threshold = this.params?.threshold ?? 0; - const triggerEl = this.params?.triggerRef?.nativeElement; - if (!triggerEl || !this.initialScrollPosition) { + if (!this.getOriginElement() || !this.initialScrollPosition) { return true; } @@ -87,7 +83,7 @@ export class EvoCloseScrollStrategy implements ScrollStrategy { } private getCurrentScrollPosition(): ScrollPosition | null { - const element = this.params?.triggerRef?.nativeElement as Element; + const element = this.getOriginElement(); if (!element) { return null; @@ -100,4 +96,8 @@ export class EvoCloseScrollStrategy implements ScrollStrategy { horizontal: rect.left, }; } + + private getOriginElement(): Element | null { + return this.params?.getOrigin?.() ?? null; + } } 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 index 5a4ec4abf..7e466bbb1 100644 --- 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 @@ -4,6 +4,7 @@ import {OverlayRef, ScrollStrategy} from '@angular/cdk/overlay'; import {Subscription} from 'rxjs'; import {tap} from 'rxjs/operators'; import {createScrollStream} from '../utils/create-scroll-stream'; +import {EvoScrollStrategyParams} from '../interfaces/evo-scroll-strategy-params'; export class EvoRepositionScrollStrategy implements ScrollStrategy { private readonly document: Document; @@ -13,7 +14,7 @@ export class EvoRepositionScrollStrategy implements ScrollStrategy { private scrollSubscription: Subscription | null = null; - constructor(private readonly injector: Injector) { + constructor(private readonly injector: Injector, private readonly params?: EvoScrollStrategyParams) { this.document = this.injector.get(DOCUMENT); this.ngZone = this.injector.get(NgZone); } @@ -42,7 +43,7 @@ export class EvoRepositionScrollStrategy implements ScrollStrategy { } this.ngZone.runOutsideAngular(() => { - this.scrollSubscription = createScrollStream(this.document, this.overlayRef) + this.scrollSubscription = createScrollStream(this.overlayRef, this.params?.getOrigin?.() ?? null) .pipe( tap(() => { if (this.isOverlayScrolledOutsideView()) { 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 index 1e50b4173..237848694 100644 --- 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 @@ -2,20 +2,20 @@ 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'; +import {EvoScrollStrategyParams} from '../interfaces/evo-scroll-strategy-params'; import {EvoScrollStrategy} from '../types/evo-scroll-strategy'; @Injectable({providedIn: 'root'}) export class EvoScrollStrategyOptions { constructor(private readonly injector: Injector) {} - create(strategy: EvoScrollStrategy, params?: EvoCloseScrollStrategyParams): ScrollStrategy { + create(strategy: EvoScrollStrategy, params?: EvoScrollStrategyParams): ScrollStrategy { switch (strategy) { case 'noop': { return this.noop(); } case 'reposition': { - return this.reposition(); + return this.reposition(params); } case 'close': default: { @@ -28,11 +28,11 @@ export class EvoScrollStrategyOptions { return new NoopScrollStrategy(); }; - reposition(): ScrollStrategy { - return new EvoRepositionScrollStrategy(this.injector); + reposition(params?: EvoScrollStrategyParams): ScrollStrategy { + return new EvoRepositionScrollStrategy(this.injector, params); }; - close(params?: EvoCloseScrollStrategyParams): ScrollStrategy { + close(params?: EvoScrollStrategyParams): ScrollStrategy { return new EvoCloseScrollStrategy(this.injector, params); }; } 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 deleted file mode 100644 index d49c21d8b..000000000 --- a/projects/evo-ui-kit/src/lib/common/scroll/interfaces/evo-close-scroll-strategy-params.ts +++ /dev/null @@ -1,7 +0,0 @@ -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/evo-scroll-strategy-params.ts b/projects/evo-ui-kit/src/lib/common/scroll/interfaces/evo-scroll-strategy-params.ts new file mode 100644 index 000000000..2ac1d0e7a --- /dev/null +++ b/projects/evo-ui-kit/src/lib/common/scroll/interfaces/evo-scroll-strategy-params.ts @@ -0,0 +1,10 @@ +export interface EvoScrollStrategyParams { + /** + * Lazily resolves the overlay's anchor element. Its scroll ancestors are observed. + * Resolved on `enable()` (when the overlay is attached), so callers can pass a getter + * over an input that is set after the strategy is created (e.g. the dropdown origin). + */ + getOrigin?: () => Element | null; + /** Amount of pixels the anchor has to move before the overlay is closed (close strategy only). */ + threshold?: number; +} 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 index 951855c73..e4c832074 100644 --- 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 @@ -1,35 +1,29 @@ -import {animationFrameScheduler, fromEvent, Observable} from 'rxjs'; +import {animationFrameScheduler, fromEvent, merge, Observable} from 'rxjs'; import {filter, throttleTime} from 'rxjs/operators'; import {OverlayRef} from '@angular/cdk/overlay'; +import {getScrollableAncestors} from './get-scrollable-ancestors'; /** - * TODO(MRK-4890): revisit whether CDK's own scroll strategies can be reused here. + * Emits when any scroll container that can move the overlay's anchor is scrolled. * - * Why a custom stream instead of CDK's strategies: + * Why not CDK's strategies: the overlay must follow / close when ANY container that + * moves the anchor scrolls, including arbitrary `overflow: auto` blocks (modal bodies, + * sidebars, nested lists). CDK's reposition/close strategies only react to the window + * plus containers explicitly registered via the `cdkScrollable` directive — and as a + * reusable library we cannot require consumers to mark every such container. * - * The overlay must follow / close when ANY container that moves the anchor scrolls, - * including arbitrary `overflow: auto` blocks (modal bodies, sidebars, nested lists). - * As a reusable library we cannot require consumers to mark every such container. - * - * CDK's reposition/close strategies subscribe to `ScrollDispatcher.scrolled()`, which is - * a *bubble-phase* listener on `document` PLUS each element explicitly registered via the - * `cdkScrollable` directive. The `scroll` event does not bubble, so the bubble-phase - * listener only sees page scroll — an unmarked inner container's scroll is invisible to - * CDK and the overlay stays glued to its old position. - * - * The listener below uses `capture: true`: a capture-phase listener on `document` receives - * scroll from every element in the tree (the capture phase runs document -> target even for - * non-bubbling events), so it covers every container with zero registration. - * - * Related: EvoCloseScrollStrategy measures the threshold from the trigger's - * getBoundingClientRect() displacement (both axes), whereas CDK's CloseScrollStrategy reads - * only the viewport's vertical scroll position — which never changes on inner-container scroll. - * - * Trade-off: this fires for every scroll on the page, not only relevant ones; mitigated by - * the animation-frame throttle and the `overlayElement.contains(target)` filter below. + * Mechanism (mirrors Floating UI's getOverflowAncestors): walk the anchor's DOM + * ancestors, keep only real scroll containers, and attach one passive listener per + * ancestor plus the window. Unlike a global capture-phase document listener, this reacts + * only to the handful of containers in the anchor's chain rather than to every scroll on + * the page. The close strategy additionally measures movement from the anchor's + * getBoundingClientRect(), which is correct regardless of which container scrolled. */ -export function createScrollStream(document: Document, overlayRef: OverlayRef): Observable { - return fromEvent(document, 'scroll', {capture: true, passive: true}).pipe( +export function createScrollStream(overlayRef: OverlayRef, origin: Element | null): Observable { + const targets: (Element | Window)[] = origin ? getScrollableAncestors(origin) : [window]; + const scrollStreams = targets.map((target) => fromEvent(target, 'scroll', {passive: true})); + + return merge(...scrollStreams).pipe( throttleTime(10, animationFrameScheduler, {leading: true, trailing: true}), filter((event): boolean => { const target = event.target as Node; diff --git a/projects/evo-ui-kit/src/lib/common/scroll/utils/get-scrollable-ancestors.ts b/projects/evo-ui-kit/src/lib/common/scroll/utils/get-scrollable-ancestors.ts new file mode 100644 index 000000000..5dfb03e14 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/common/scroll/utils/get-scrollable-ancestors.ts @@ -0,0 +1,34 @@ +// Detects scroll containers from computed CSS, adapted from Floating UI's +// getOverflowAncestors (MIT). We only keep user-scrollable overflow values +// (auto|scroll|overlay); hidden/clip do not emit scroll events. +const OVERFLOW_REGEXP = /auto|scroll|overlay/; + +function isScrollableElement(element: Element): boolean { + const {overflow, overflowX, overflowY} = getComputedStyle(element); + + return OVERFLOW_REGEXP.test(overflow + overflowY + overflowX); +} + +/** + * Walks the DOM ancestors of `origin`, keeping only real scroll containers, and + * appends the window (which covers page scroll). The result is the set of targets + * whose scrolling can move the anchor on screen — no `cdkScrollable` markup needed. + */ +export function getScrollableAncestors(origin: Element): (Element | Window)[] { + const document = origin.ownerDocument; + const ancestors: (Element | Window)[] = []; + + let node: Element | null = origin.parentElement; + + while (node && node !== document.body && node !== document.documentElement) { + if (isScrollableElement(node)) { + ancestors.push(node); + } + + node = node.parentElement; + } + + ancestors.push(document.defaultView ?? window); + + return ancestors; +} 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 72118ea35..918611baf 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 @@ -45,14 +45,22 @@ export class EvoDropdownComponent { } @Input() set scrollStrategy(strategy: EvoScrollStrategy) { - this.connectedScrollStrategy = this.evoScrollStrategyOptions.create(strategy); + this.connectedScrollStrategy = this.createScrollStrategy(strategy); } constructor( private readonly evoScrollStrategyOptions: EvoScrollStrategyOptions, private readonly cdr: ChangeDetectorRef, ) { - this.connectedScrollStrategy = this.evoScrollStrategyOptions.create(DEFAULT_SCROLL_STRATEGY); + this.connectedScrollStrategy = this.createScrollStrategy(DEFAULT_SCROLL_STRATEGY); + } + + private createScrollStrategy(strategy: EvoScrollStrategy): ScrollStrategy { + // getOrigin is resolved lazily on enable(), so the dropdown origin input does not + // need to be set before the strategy is created. + return this.evoScrollStrategyOptions.create(strategy, { + getOrigin: () => this.dropdownOrigin?.elementRef?.nativeElement ?? null, + }); } toggle(): void { 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 c8ad8e28b..9c89c8e0a 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 @@ -146,7 +146,7 @@ export class EvoTooltipService implements OnDestroy { private getScrollStrategy(scrollStrategy: EvoScrollStrategy, parentRef: ElementRef): ScrollStrategy { return this.evoScrollStrategyOptions.create(scrollStrategy, { threshold: DEFAULT_TOOLTIP_CLOSE_THRESHOLD, - triggerRef: parentRef, + getOrigin: () => parentRef.nativeElement, }); } From 4e47876dcc9d879f949692441b2e75601453113a Mon Sep 17 00:00:00 2001 From: Artem Harlamenko <4.harlamenko@gmail.com> Date: Sun, 7 Jun 2026 21:51:50 +0300 Subject: [PATCH 23/24] test(evo-scroll-strategies): cover getScrollableAncestors Assert it collects overflow auto/scroll ancestors, skips overflow:visible, and always appends the window last. --- .../utils/get-scrollable-ancestors.spec.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 projects/evo-ui-kit/src/lib/common/scroll/utils/get-scrollable-ancestors.spec.ts diff --git a/projects/evo-ui-kit/src/lib/common/scroll/utils/get-scrollable-ancestors.spec.ts b/projects/evo-ui-kit/src/lib/common/scroll/utils/get-scrollable-ancestors.spec.ts new file mode 100644 index 000000000..94c187643 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/common/scroll/utils/get-scrollable-ancestors.spec.ts @@ -0,0 +1,46 @@ +import {getScrollableAncestors} from './get-scrollable-ancestors'; + +describe('getScrollableAncestors', () => { + let root: HTMLElement | null = null; + + afterEach(() => { + root?.remove(); + root = null; + }); + + function build(html: string): HTMLElement { + root = document.createElement('div'); + root.innerHTML = html; + document.body.appendChild(root); + + return root; + } + + it('should return only the window when there are no scrollable ancestors', () => { + const anchor = build('
').querySelector( + '#anchor', + ) as Element; + + expect(getScrollableAncestors(anchor)).toEqual([window]); + }); + + it('should collect overflow auto/scroll ancestors and append the window last', () => { + const host = build(` +
+
+
+ +
+
+
+ `); + + const anchor = host.querySelector('#anchor') as Element; + const ancestors = getScrollableAncestors(anchor); + + expect(ancestors).toContain(host.querySelector('.scroll-y') as Element); + expect(ancestors).toContain(host.querySelector('.auto') as Element); + expect(ancestors).not.toContain(host.querySelector('.visible') as Element); + expect(ancestors[ancestors.length - 1]).toBe(window); + }); +}); From cba7800c78d3c2b9fd995ad2a9c8437793f0e90c Mon Sep 17 00:00:00 2001 From: Artem Harlamenko <4.harlamenko@gmail.com> Date: Tue, 9 Jun 2026 13:26:38 +0300 Subject: [PATCH 24/24] refactor(evo-scroll-strategies): revert to global scroll listener, keep dropdown close fix Drop the per-ancestor discovery (getScrollableAncestors) and restore the single capture-phase document scroll listener. Keep the dropdown fix: the close strategy still receives the anchor via getOrigin and decides to close from the anchor's getBoundingClientRect() movement, so the dropdown closes only when its own container actually moves it, not on any page scroll. Reposition no longer takes params. --- .../classes/evo-close-scroll-strategy.ts | 5 +- .../classes/evo-reposition-scroll-strategy.ts | 5 +- .../classes/evo-scroll-strategy-options.ts | 6 +-- .../interfaces/evo-scroll-strategy-params.ts | 8 ++-- .../scroll/utils/create-scroll-stream.ts | 44 ++++++++++-------- .../utils/get-scrollable-ancestors.spec.ts | 46 ------------------- .../scroll/utils/get-scrollable-ancestors.ts | 34 -------------- 7 files changed, 39 insertions(+), 109 deletions(-) delete mode 100644 projects/evo-ui-kit/src/lib/common/scroll/utils/get-scrollable-ancestors.spec.ts delete mode 100644 projects/evo-ui-kit/src/lib/common/scroll/utils/get-scrollable-ancestors.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 index f03d4d827..e637e0a44 100644 --- 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 @@ -1,4 +1,5 @@ import {Injector, NgZone} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; import {OverlayRef, ScrollStrategy} from '@angular/cdk/overlay'; import {EvoScrollStrategyParams} from '../interfaces/evo-scroll-strategy-params'; import {filter, first, tap} from 'rxjs/operators'; @@ -7,6 +8,7 @@ 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; @@ -15,6 +17,7 @@ export class EvoCloseScrollStrategy implements ScrollStrategy { private initialScrollPosition: ScrollPosition | null = null; constructor(private readonly injector: Injector, private readonly params?: EvoScrollStrategyParams) { + this.document = this.injector.get(DOCUMENT); this.ngZone = this.injector.get(NgZone); } @@ -44,7 +47,7 @@ export class EvoCloseScrollStrategy implements ScrollStrategy { this.ngZone.runOutsideAngular(() => { this.initialScrollPosition = this.getCurrentScrollPosition(); - this.scrollSubscription = createScrollStream(this.overlayRef, this.getOriginElement()) + this.scrollSubscription = createScrollStream(this.document, this.overlayRef) .pipe( filter(() => this.checkThreshold()), first(), 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 index 7e466bbb1..5a4ec4abf 100644 --- 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 @@ -4,7 +4,6 @@ import {OverlayRef, ScrollStrategy} from '@angular/cdk/overlay'; import {Subscription} from 'rxjs'; import {tap} from 'rxjs/operators'; import {createScrollStream} from '../utils/create-scroll-stream'; -import {EvoScrollStrategyParams} from '../interfaces/evo-scroll-strategy-params'; export class EvoRepositionScrollStrategy implements ScrollStrategy { private readonly document: Document; @@ -14,7 +13,7 @@ export class EvoRepositionScrollStrategy implements ScrollStrategy { private scrollSubscription: Subscription | null = null; - constructor(private readonly injector: Injector, private readonly params?: EvoScrollStrategyParams) { + constructor(private readonly injector: Injector) { this.document = this.injector.get(DOCUMENT); this.ngZone = this.injector.get(NgZone); } @@ -43,7 +42,7 @@ export class EvoRepositionScrollStrategy implements ScrollStrategy { } this.ngZone.runOutsideAngular(() => { - this.scrollSubscription = createScrollStream(this.overlayRef, this.params?.getOrigin?.() ?? null) + this.scrollSubscription = createScrollStream(this.document, this.overlayRef) .pipe( tap(() => { if (this.isOverlayScrolledOutsideView()) { 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 index 237848694..f96418c13 100644 --- 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 @@ -15,7 +15,7 @@ export class EvoScrollStrategyOptions { return this.noop(); } case 'reposition': { - return this.reposition(params); + return this.reposition(); } case 'close': default: { @@ -28,8 +28,8 @@ export class EvoScrollStrategyOptions { return new NoopScrollStrategy(); }; - reposition(params?: EvoScrollStrategyParams): ScrollStrategy { - return new EvoRepositionScrollStrategy(this.injector, params); + reposition(): ScrollStrategy { + return new EvoRepositionScrollStrategy(this.injector); }; close(params?: EvoScrollStrategyParams): ScrollStrategy { diff --git a/projects/evo-ui-kit/src/lib/common/scroll/interfaces/evo-scroll-strategy-params.ts b/projects/evo-ui-kit/src/lib/common/scroll/interfaces/evo-scroll-strategy-params.ts index 2ac1d0e7a..a4847ebad 100644 --- a/projects/evo-ui-kit/src/lib/common/scroll/interfaces/evo-scroll-strategy-params.ts +++ b/projects/evo-ui-kit/src/lib/common/scroll/interfaces/evo-scroll-strategy-params.ts @@ -1,8 +1,10 @@ export interface EvoScrollStrategyParams { /** - * Lazily resolves the overlay's anchor element. Its scroll ancestors are observed. - * Resolved on `enable()` (when the overlay is attached), so callers can pass a getter - * over an input that is set after the strategy is created (e.g. the dropdown origin). + * Lazily resolves the overlay's anchor element. The close strategy measures the anchor's + * on-screen movement (getBoundingClientRect) to decide whether to close, so it reacts only + * when the anchor actually moves rather than on every page scroll. + * Resolved on `enable()` (when the overlay is attached), so callers can pass a getter over an + * input that is set after the strategy is created (e.g. the dropdown origin). */ getOrigin?: () => Element | null; /** Amount of pixels the anchor has to move before the overlay is closed (close strategy only). */ 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 index e4c832074..938c4ca28 100644 --- 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 @@ -1,29 +1,35 @@ -import {animationFrameScheduler, fromEvent, merge, Observable} from 'rxjs'; +import {animationFrameScheduler, fromEvent, Observable} from 'rxjs'; import {filter, throttleTime} from 'rxjs/operators'; import {OverlayRef} from '@angular/cdk/overlay'; -import {getScrollableAncestors} from './get-scrollable-ancestors'; /** - * Emits when any scroll container that can move the overlay's anchor is scrolled. + * TODO(MRK-4890): revisit whether CDK's own scroll strategies can be reused here. * - * Why not CDK's strategies: the overlay must follow / close when ANY container that - * moves the anchor scrolls, including arbitrary `overflow: auto` blocks (modal bodies, - * sidebars, nested lists). CDK's reposition/close strategies only react to the window - * plus containers explicitly registered via the `cdkScrollable` directive — and as a - * reusable library we cannot require consumers to mark every such container. + * Why a custom stream instead of CDK's strategies: * - * Mechanism (mirrors Floating UI's getOverflowAncestors): walk the anchor's DOM - * ancestors, keep only real scroll containers, and attach one passive listener per - * ancestor plus the window. Unlike a global capture-phase document listener, this reacts - * only to the handful of containers in the anchor's chain rather than to every scroll on - * the page. The close strategy additionally measures movement from the anchor's - * getBoundingClientRect(), which is correct regardless of which container scrolled. + * The overlay must follow / close when ANY container that moves the anchor scrolls, + * including arbitrary `overflow: auto` blocks (modal bodies, sidebars, nested lists). + * As a reusable library we cannot require consumers to mark every such container. + * + * CDK's reposition/close strategies subscribe to `ScrollDispatcher.scrolled()`, which is + * a *bubble-phase* listener on `document` PLUS each element explicitly registered via the + * `cdkScrollable` directive. The `scroll` event does not bubble, so the bubble-phase + * listener only sees page scroll — an unmarked inner container's scroll is invisible to + * CDK and the overlay stays glued to its old position. + * + * The listener below uses `capture: true`: a capture-phase listener on `document` receives + * scroll from every element in the tree (the capture phase runs document -> target even for + * non-bubbling events), so it covers every container with zero registration. + * + * Related: EvoCloseScrollStrategy measures the threshold from the anchor's + * getBoundingClientRect() displacement (both axes), so it closes only when the anchor + * actually moves — regardless of which container produced the scroll event. + * + * Trade-off: this fires for every scroll on the page, not only relevant ones; mitigated by + * the animation-frame throttle and the `overlayElement.contains(target)` filter below. */ -export function createScrollStream(overlayRef: OverlayRef, origin: Element | null): Observable { - const targets: (Element | Window)[] = origin ? getScrollableAncestors(origin) : [window]; - const scrollStreams = targets.map((target) => fromEvent(target, 'scroll', {passive: true})); - - return merge(...scrollStreams).pipe( +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): boolean => { const target = event.target as Node; diff --git a/projects/evo-ui-kit/src/lib/common/scroll/utils/get-scrollable-ancestors.spec.ts b/projects/evo-ui-kit/src/lib/common/scroll/utils/get-scrollable-ancestors.spec.ts deleted file mode 100644 index 94c187643..000000000 --- a/projects/evo-ui-kit/src/lib/common/scroll/utils/get-scrollable-ancestors.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import {getScrollableAncestors} from './get-scrollable-ancestors'; - -describe('getScrollableAncestors', () => { - let root: HTMLElement | null = null; - - afterEach(() => { - root?.remove(); - root = null; - }); - - function build(html: string): HTMLElement { - root = document.createElement('div'); - root.innerHTML = html; - document.body.appendChild(root); - - return root; - } - - it('should return only the window when there are no scrollable ancestors', () => { - const anchor = build('
').querySelector( - '#anchor', - ) as Element; - - expect(getScrollableAncestors(anchor)).toEqual([window]); - }); - - it('should collect overflow auto/scroll ancestors and append the window last', () => { - const host = build(` -
-
-
- -
-
-
- `); - - const anchor = host.querySelector('#anchor') as Element; - const ancestors = getScrollableAncestors(anchor); - - expect(ancestors).toContain(host.querySelector('.scroll-y') as Element); - expect(ancestors).toContain(host.querySelector('.auto') as Element); - expect(ancestors).not.toContain(host.querySelector('.visible') as Element); - expect(ancestors[ancestors.length - 1]).toBe(window); - }); -}); diff --git a/projects/evo-ui-kit/src/lib/common/scroll/utils/get-scrollable-ancestors.ts b/projects/evo-ui-kit/src/lib/common/scroll/utils/get-scrollable-ancestors.ts deleted file mode 100644 index 5dfb03e14..000000000 --- a/projects/evo-ui-kit/src/lib/common/scroll/utils/get-scrollable-ancestors.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Detects scroll containers from computed CSS, adapted from Floating UI's -// getOverflowAncestors (MIT). We only keep user-scrollable overflow values -// (auto|scroll|overlay); hidden/clip do not emit scroll events. -const OVERFLOW_REGEXP = /auto|scroll|overlay/; - -function isScrollableElement(element: Element): boolean { - const {overflow, overflowX, overflowY} = getComputedStyle(element); - - return OVERFLOW_REGEXP.test(overflow + overflowY + overflowX); -} - -/** - * Walks the DOM ancestors of `origin`, keeping only real scroll containers, and - * appends the window (which covers page scroll). The result is the set of targets - * whose scrolling can move the anchor on screen — no `cdkScrollable` markup needed. - */ -export function getScrollableAncestors(origin: Element): (Element | Window)[] { - const document = origin.ownerDocument; - const ancestors: (Element | Window)[] = []; - - let node: Element | null = origin.parentElement; - - while (node && node !== document.body && node !== document.documentElement) { - if (isScrollableElement(node)) { - ancestors.push(node); - } - - node = node.parentElement; - } - - ancestors.push(document.defaultView ?? window); - - return ancestors; -}