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..e637e0a44 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/common/scroll/classes/evo-close-scroll-strategy.ts @@ -0,0 +1,106 @@ +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'; +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?: EvoScrollStrategyParams) { + 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; + + if (!this.getOriginElement() || !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.getOriginElement(); + + if (!element) { + return null; + } + + const rect = element.getBoundingClientRect(); + + return { + vertical: rect.top, + 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 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..f96418c13 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/common/scroll/classes/evo-scroll-strategy-options.ts @@ -0,0 +1,38 @@ +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 {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?: EvoScrollStrategyParams): 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(); + }; + + reposition(): ScrollStrategy { + return new EvoRepositionScrollStrategy(this.injector); + }; + + close(params?: EvoScrollStrategyParams): 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-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..a4847ebad --- /dev/null +++ b/projects/evo-ui-kit/src/lib/common/scroll/interfaces/evo-scroll-strategy-params.ts @@ -0,0 +1,12 @@ +export interface EvoScrollStrategyParams { + /** + * 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). */ + threshold?: number; +} 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..938c4ca28 --- /dev/null +++ b/projects/evo-ui-kit/src/lib/common/scroll/utils/create-scroll-stream.ts @@ -0,0 +1,40 @@ +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. + * + * 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 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(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/lib/components/evo-dropdown/evo-dropdown.component.html b/projects/evo-ui-kit/src/lib/components/evo-dropdown/evo-dropdown.component.html index 42110ee35..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,6 +3,7 @@ [cdkConnectedOverlayOpen]="isOpen" [cdkConnectedOverlayOrigin]="dropdownOrigin" [cdkConnectedOverlayPositions]="connectedPositions" + [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 b415d8c6e..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 @@ -1,25 +1,14 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - EventEmitter, - Input, - NgZone, - OnDestroy, - 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 {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 {fromEvent, Subject, Subscription} from 'rxjs'; -import {filter, take, takeUntil, throttleTime} from 'rxjs/operators'; +import {EvoScrollStrategy, EvoScrollStrategyOptions} from '../../common/scroll'; type Position = EvoDropdownPositions | ConnectedPosition; const DEFAULT_POSITION = [EVO_DROPDOWN_POSITION_DESCRIPTION['bottom-right']]; +const DEFAULT_SCROLL_STRATEGY: EvoScrollStrategy = 'close'; @Component({ selector: 'evo-dropdown', @@ -28,9 +17,8 @@ const DEFAULT_POSITION = [EVO_DROPDOWN_POSITION_DESCRIPTION['bottom-right']]; standalone: true, imports: [CdkConnectedOverlay], }) -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 +26,8 @@ export class EvoDropdownComponent implements OnDestroy { connectedPositions: ConnectedPosition[] = DEFAULT_POSITION; - private scrollEventSubscription: Subscription; - private readonly destroy$ = new Subject(); + connectedScrollStrategy: ScrollStrategy; + private _isOpen = false; get isOpen(): boolean { @@ -48,10 +36,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,21 +44,24 @@ export class EvoDropdownComponent implements OnDestroy { : DEFAULT_POSITION; } - private get element(): HTMLElement | null { - if (!this.viewContainerRef) { - return; - } - - return this.viewContainerRef?.element instanceof ElementRef - ? (this.viewContainerRef.element?.nativeElement as HTMLElement) - : (this.viewContainerRef.element as HTMLElement); + @Input() set scrollStrategy(strategy: EvoScrollStrategy) { + this.connectedScrollStrategy = this.createScrollStrategy(strategy); } constructor( - protected readonly viewContainerRef: ViewContainerRef, - private readonly ngZone: NgZone, + private readonly evoScrollStrategyOptions: EvoScrollStrategyOptions, private readonly cdr: ChangeDetectorRef, - ) {} + ) { + 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 { if (this.isOpen) { @@ -102,18 +89,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 +102,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(); - }); - }); - }); - } } 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..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 @@ -1,39 +1,41 @@ -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, 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'; +import {CommonModule} from '@angular/common'; +import {EvoTooltipService} from '../services/evo-tooltip.service'; +import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; +import {first} from 'rxjs/operators'; +import {By} from '@angular/platform-browser'; @Component({ template: ` -
+
Hover me
`, }) class TestHostComponent { + tooltipContent = 'Tooltip content'; position = EvoTooltipPosition.BOTTOM; disabled = false; - config = { showDelay: 0, hideDelay: 0 }; + config = {showDelay: 0, hideDelay: 0}; visibleArrow = true; styles: EvoTooltipStyles = { - [EvoTooltipVariableArrowPosition.VERTICAL_POSITION_ARROW]: '10px', - [EvoTooltipVariableArrowPosition.HORIZONTAL_POSITION_ARROW]: '20px', + [EvoTooltipStyleVariable.VERTICAL_POSITION_ARROW]: '10px', + [EvoTooltipStyleVariable.HORIZONTAL_POSITION_ARROW]: '20px', }; classes = ['class-1', 'class-2']; onOpen = jasmine.createSpy('onOpen'); @@ -43,20 +45,22 @@ class TestHostComponent { describe('EvoTooltipDirective', () => { let component: TestHostComponent; let fixture: ComponentFixture; + let directiveDebugEl: DebugElement; let directive: EvoTooltipDirective; let tooltipService: EvoTooltipService; beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [TestHostComponent, EvoTooltipDirective, EvoTooltipComponent], - imports: [CommonModule, BrowserAnimationsModule], + declarations: [TestHostComponent], + imports: [CommonModule, BrowserAnimationsModule, EvoTooltipDirective], providers: [EvoTooltipService], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(TestHostComponent); component = fixture.componentInstance; - directive = fixture.debugElement.children[0].injector.get(EvoTooltipDirective); + directiveDebugEl = fixture.debugElement.query(By.directive(EvoTooltipDirective)); + directive = directiveDebugEl.injector.get(EvoTooltipDirective); tooltipService = TestBed.inject(EvoTooltipService); fixture.detectChanges(); }); @@ -66,15 +70,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(() => { @@ -104,7 +111,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(); @@ -112,8 +121,16 @@ 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) => { + expect(isOpen).toBeTrue(); + }); + })); + + it('should handle touchstart event', fakeAsync(() => { + 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/directives/evo-tooltip.directive.ts b/projects/evo-ui-kit/src/lib/components/evo-tooltip/directives/evo-tooltip.directive.ts index 9a59ffdf2..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 @@ -1,55 +1,62 @@ import { + DestroyRef, Directive, + effect, ElementRef, - EventEmitter, HostBinding, - Input, + inject, + input, OnDestroy, OnInit, - Output, + output, TemplateRef, } from '@angular/core'; -import {fromEvent, Observable, Subject} from 'rxjs'; -import {takeUntil, tap, throttleTime} from 'rxjs/operators'; +import {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'; 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; - @Input() set evoTooltipVisibleArrow(visibleArrow: boolean) { - this.tooltipService.setArrowVisibility(visibleArrow); - } - @Input() set evoTooltipStyles(tooltipStyles: EvoTooltipStyles) { - this.tooltipService.setTooltipStyles(tooltipStyles); - } - @Input() set evoTooltipClass(tooltipClass: string | string[]) { - this.tooltipService.setTooltipClass(tooltipClass); - } + readonly content = input>('', {alias: 'evoTooltip'}); - @Output() evoTooltipOpen = new EventEmitter(); - @Output() evoTooltipClose = new EventEmitter(); + readonly position = input(EvoTooltipPosition.BOTTOM, {alias: 'evoTooltipPosition'}); - @HostBinding('class') get hostClasses(): string[] { - return ['evo-tooltip-trigger', ...(this.disabled ? ['evo-tooltip-trigger_disabled'] : [])]; - } + readonly disabled = input(false, {alias: 'evoTooltipDisabled'}); + + readonly config = input>(EVO_TOOLTIP_CONFIG, {alias: 'evoTooltipConfig'}); + + readonly isVisibleArrow = input(true, {alias: 'evoTooltipVisibleArrow'}); - readonly isOpen$: Observable = this.tooltipService.isOpen$; + readonly tooltipStyles = input({}, {alias: 'evoTooltipStyles'}); - private readonly destroy$ = new Subject(); + readonly tooltipClass = input([], {alias: 'evoTooltipClass'}); - constructor(private readonly elementRef: ElementRef, private readonly tooltipService: EvoTooltipService) {} + 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'] : [])]; + } + + constructor() { + effect((): void => this.tooltipService.setTooltipStyles(this.tooltipStyles())); + effect((): void => this.tooltipService.setTooltipClass(this.tooltipClass())); + } ngOnInit(): void { this.initSubscriptions(); @@ -57,45 +64,70 @@ export class EvoTooltipDirective implements OnInit, OnDestroy { ngOnDestroy(): void { this.hide(); - this.destroy$.next(); - this.destroy$.complete(); } hide(): void { this.tooltipService.hideTooltip(); } - show(event?: MouseEvent): void { - if (!this.content || this.tooltipService.hasAttached || this.disabled) { + show(): void { + const content = this.content(); + + if (!content || this.tooltipService.hasAttached || this.disabled()) { return; } - this.tooltipService.showTooltip( - this.elementRef, - this.content, - this.position as EvoTooltipPosition, - {...EVO_TOOLTIP_CONFIG, ...this.config}, - event?.target, - ); + const tooltip = this.tooltipService.showTooltip({ + parentRef: this.elementRef, + content: content, + position: this.position() as EvoTooltipPosition, + hasArrow: this.isVisibleArrow(), + scrollStrategy: this.config()?.scrollStrategy, + }); + + 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(); - }), - takeUntil(this.destroy$), + throttleTime(showDelay), + tap((): void => this.show()), + takeUntilDestroyed(this.destroyRef), ) .subscribe(); this.tooltipService.isOpen$ .pipe( - tap((isOpen) => { - isOpen ? this.evoTooltipOpen.emit() : this.evoTooltipClose.emit(); + tap((isOpen): void => { + if (isOpen) { + this.evoTooltipOpen.emit(); + return; + } + + 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): boolean => !isOpen)); + + const hideDelay = this.config()?.hideDelay ?? EVO_TOOLTIP_CONFIG.hideDelay; + + merge(fromEvent(tooltip, 'mouseleave'), fromEvent(trigger, 'mouseleave')) + .pipe( + debounceTime(hideDelay), + 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/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.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.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..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 @@ -1,112 +1,158 @@ -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, NO_ERRORS_SCHEMA, TemplateRef, ViewChild} from '@angular/core'; 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 {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', 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: [TestHostComponent], + imports: [BrowserAnimationsModule, CommonModule, EvoTooltipComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [EvoTooltipService, EvoScrollStrategyOptions], + }).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, + hasArrow = true, + ): HTMLElement => { + tooltipService.showTooltip({ + parentRef: testHostComponent.triggerEl, + content, + position, + hasArrow, + }); + 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 = { - [EvoTooltipVariableArrowPosition.VERTICAL_POSITION_ARROW]: '10px', - [EvoTooltipVariableArrowPosition.HORIZONTAL_POSITION_ARROW]: '20px', - }; - 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'); - tooltipComponent.ngOnDestroy(); - expect(destroySpy).toHaveBeenCalled(); - }); + 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(() => { + 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(() => { + 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); + + tooltipService.hideTooltip(); + + const classTest = 'should-not-be-applied'; + + tooltipService.setTooltipClass([classTest]); + + 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 9fb9f2eea..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,22 +2,25 @@ import { AfterViewInit, ChangeDetectionStrategy, Component, + DestroyRef, ElementRef, HostBinding, + inject, OnDestroy, - OnInit, Renderer2, + Signal, TemplateRef, } from '@angular/core'; -import {BehaviorSubject, combineLatest, EMPTY, Observable, Subject} from 'rxjs'; -import {filter, 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 {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'; +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', @@ -25,101 +28,98 @@ import {EvoTooltipVariableArrowPosition} from './enums/evo-tooltip-variable-arro styleUrls: ['./evo-tooltip.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, animations: [EVO_TOOLTIP_FADEIN_ANIMATION], + standalone: true, + imports: [ + NgTemplateOutlet + ] }) -export class EvoTooltipComponent implements OnInit, 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; +export class EvoTooltipComponent implements AfterViewInit, OnDestroy { + readonly position: Signal; + readonly stringContent: Signal; + readonly templateContent: Signal>; + readonly visibleArrow: Signal; + + readonly styles: Signal; @HostBinding('@fadeIn') fadeIn = true; - private readonly _positionArrowStyles$ = new BehaviorSubject(null); - 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( - 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})), - ); - } + 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$); - 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(); + this.styles = this.getStyles(); } + 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 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; + 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, + ), + )); + } - 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); + private calculateArrowStyles(parentRef: ElementRef, position: EvoTooltipPosition): EvoTooltipStyles { + const parentRect = (parentRef.nativeElement as HTMLElement).getBoundingClientRect(); + const tooltipRect = (this.elementRef.nativeElement as HTMLElement).getBoundingClientRect(); - // Проверка на минимальное смещение, смещение стрелки не должно быть меньше размера радиуса тултипа 8px - horizontalPositionArrow = - horizontalPositionArrow > EVO_TOOLTIP_RADIUS ? horizontalPositionArrow : EVO_TOOLTIP_RADIUS; - verticalPositionArrow = verticalPositionArrow > EVO_TOOLTIP_RADIUS ? verticalPositionArrow : EVO_TOOLTIP_RADIUS; + const vertical = getTooltipArrowOffset({ + parentStart: parentRect.top, + parentEnd: parentRect.bottom, + tooltipStart: tooltipRect.top, + tooltipEnd: tooltipRect.bottom, + position, + }); - this._positionArrowStyles$.next({ - [EvoTooltipVariableArrowPosition.VERTICAL_POSITION_ARROW]: `${verticalPositionArrow}px`, - [EvoTooltipVariableArrowPosition.HORIZONTAL_POSITION_ARROW]: `${horizontalPositionArrow}px`, + const horizontal = getTooltipArrowOffset({ + 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/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/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/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/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/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..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 @@ -1,14 +1,13 @@ -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'; -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 {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 {NO_ERRORS_SCHEMA} from '@angular/core'; describe('EvoTooltipService', () => { let service: EvoTooltipService; @@ -16,20 +15,22 @@ describe('EvoTooltipService', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [CommonModule, BrowserAnimationsModule], - declarations: [EvoTooltipComponent], + imports: [CommonModule, BrowserAnimationsModule, EvoTooltipComponent], schemas: [NO_ERRORS_SCHEMA], providers: [EvoTooltipService], }); service = TestBed.inject(EvoTooltipService); + + const mockElement = document.createElement('div'); + + Object.defineProperties(mockElement, { + offsetWidth: {value: 100}, + offsetHeight: {value: 50}, + }); + elementRef = { - nativeElement: { - offsetWidth: 100, - offsetHeight: 50, - addEventListener: () => {}, - removeEventListener: () => {}, - }, + nativeElement: mockElement, }; }); @@ -37,17 +38,25 @@ 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 = { - [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) => { @@ -80,9 +89,12 @@ 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({ + parentRef: elementRef, + content, + position, + }); service.stringContent$.pipe(first()).subscribe((value) => { expect(value).toBe(content); @@ -92,17 +104,30 @@ 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', () => { - service.hideTooltip(); + it('should hide tooltip', fakeAsync(() => { + service.showTooltip({ + parentRef: elementRef, + content: 'Test content', + position: EvoTooltipPosition.TOP, + hasArrow: true, + }); + + tick(); + service.isOpen$.pipe(first()).subscribe((value) => { expect(value).toBeFalse(); + expect(service.hasAttached).toBeFalse(); }); - }); + + service.hideTooltip(); + + tick(); + })); it('should check if tooltip is attached', () => { expect(service.hasAttached).toBeFalse(); 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..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 @@ -1,28 +1,25 @@ -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, FlexibleConnectedPositionStrategy, Overlay, OverlayPositionBuilder, OverlayRef, + ScrollStrategy, } 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'; -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'; +import {EvoScrollStrategy, EvoScrollStrategyOptions} from '../../../common/scroll'; + +const DEFAULT_TOOLTIP_SCROLL_STRATEGY: EvoScrollStrategy = 'close'; +const DEFAULT_TOOLTIP_CLOSE_THRESHOLD = 10; @Injectable() -export class EvoTooltipService { +export class EvoTooltipService implements OnDestroy { readonly stringContent$: Observable = EMPTY; readonly templateContent$: Observable | null> = EMPTY; readonly position$: Observable = EMPTY; @@ -37,19 +34,23 @@ 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 | null = null; + private positionStrategy: FlexibleConnectedPositionStrategy | null = null; + private tooltipComponentRef: ComponentRef | null = null; - private overlayRef: OverlayRef; - private positionStrategy: FlexibleConnectedPositionStrategy; - private tooltipComponentRef: ComponentRef | null; - private targetElement: EventTarget | null; + get hasAttached(): boolean { + return this.overlayRef?.hasAttached() ?? false; + } constructor( private readonly overlay: Overlay, private readonly overlayPositionBuilder: OverlayPositionBuilder, + private readonly evoScrollStrategyOptions: EvoScrollStrategyOptions, private readonly injector: Injector, ) { this.stringContent$ = this._stringContent$.asObservable(); @@ -62,24 +63,35 @@ export class EvoTooltipService { this.isOpen$ = this._isOpen$.asObservable(); } - showTooltip( - parentRef: ElementRef, - content: string | TemplateRef, - position: EvoTooltipPosition, - config: EvoTooltipConfig, - targetElement?: EventTarget | null, - ): void { + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + showTooltip(params: { + parentRef: ElementRef; + content: string | TemplateRef; + position?: EvoTooltipPosition; + hasArrow?: boolean; + scrollStrategy?: EvoScrollStrategy; + }): HTMLElement { + const {parentRef, content, position = EvoTooltipPosition.BOTTOM, hasArrow = true} = params; + this._parentRef$.next(parentRef); - this.targetElement = targetElement ?? null; + this._visibleArrow$.next(hasArrow); this.setContent(content); this._position$.next(position); - this._config$.next(config); - this.createOverlay(parentRef, position); + + const scrollStrategy = params.scrollStrategy || DEFAULT_TOOLTIP_SCROLL_STRATEGY; + this.createOverlay(parentRef, position, hasArrow, scrollStrategy); + this.createPortal(); - this._isOpen$.next(this.overlayRef.hasAttached()); + this._isOpen$.next(this.hasAttached); this.initSubscriptions(); + + return this.overlayRef.overlayElement; } hideTooltip(): void { @@ -92,10 +104,6 @@ export class EvoTooltipService { this._isOpen$.next(!!this.overlayRef?.hasAttached()); } - setArrowVisibility(hasArrow: boolean): void { - this._visibleArrow$.next(hasArrow); - } - setTooltipStyles(tooltipStyles: EvoTooltipStyles): void { this._styles$.next(tooltipStyles); } @@ -110,18 +118,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,13 +126,28 @@ export class EvoTooltipService { } } - private createOverlay(elementRef: ElementRef, position: EvoTooltipPosition): void { + private createOverlay( + parentRef: ElementRef, + position: EvoTooltipPosition, + hasArrow: boolean, + scrollStrategy: EvoScrollStrategy, + ): void { this.positionStrategy = this.overlayPositionBuilder - .flexibleConnectedTo(elementRef) - .withPositions(this.getPositions(position)); + .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 { + return this.evoScrollStrategyOptions.create(scrollStrategy, { + threshold: DEFAULT_TOOLTIP_CLOSE_THRESHOLD, + getOrigin: () => parentRef.nativeElement, + }); } private createPortal(): void { @@ -145,87 +156,27 @@ 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(); - }); - - this.positionStrategy.positionChanges.subscribe((value) => { - this._position$.next(value.connectionPair.panelClass as EvoTooltipPosition); - }); - } + if (this.positionStrategy) { + const closed$ = this._isOpen$.pipe(filter((isOpened: boolean) => !isOpened)); - private getPositions(position: EvoTooltipPosition): 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 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), - ), - ]; + this.positionStrategy.positionChanges.pipe(takeUntil(merge(this.destroy$, closed$))).subscribe((value) => { + this._position$.next(value.connectionPair.panelClass as EvoTooltipPosition); + }); } - const indexPositionKey: number = EVO_DEFAULT_POSITIONS_ORDER.indexOf(position); - return EVO_DEFAULT_POSITIONS_ORDER.slice(indexPositionKey).concat( - EVO_DEFAULT_POSITIONS_ORDER.slice(0, indexPositionKey), - ); + if (this.overlayRef) { + this.overlayRef + .detachments() + .pipe( + take(1), + tap(() => { + this.positionStrategy = null; + this.overlayRef = null; + this._isOpen$.next(false); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } } } 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..bcd760a97 --- /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 '../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'; +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))); +} 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), + ); +} 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"; 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'; 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(); +} 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';