Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2b6afbc
feat(evo-tooltip-trigger): use styles in main
May 5, 2026
33cc3f7
feat(evo-tooltip): stories added
May 12, 2026
516ecdd
refactor(evo-tooltip): close tooltip inside directive
May 12, 2026
4c635df
feat(evo-tooltip): added touchstart handler
May 12, 2026
1f363c9
fix(evo-tooltip): fixed arrow position logic
May 12, 2026
5952845
fix(evo-tooltip): fix tests
May 13, 2026
1670b81
refactor(evo-tooltip-service): isolate position logic in utils
May 13, 2026
2eeb479
refactor(evo-tooltip): tooltip as standalone and use signals
May 14, 2026
1afa165
feat(evo-scroll-strategies): created strategies
May 12, 2026
e46b96a
feat(evo-tooltip): use scroll strategies in tooltip
May 13, 2026
094139c
feat(evo-dropdown): use scroll strategies in dropdown
May 12, 2026
4493305
feat(app): add test environment
May 13, 2026
f715973
fix(evo-tooltip): import scroll strategies via relative path
harlamenko Jun 7, 2026
6249aa7
docs(evo-scroll-strategies): explain why CDK strategies are reimpleme…
harlamenko Jun 7, 2026
c2be289
refactor(evo-scroll-strategies): centralize strategy selection in cre…
harlamenko Jun 7, 2026
7b61a33
refactor(evo-scroll-strategies): provide options in root
harlamenko Jun 7, 2026
831982c
refactor(evo-dropdown): remove unused viewContainerRef injection
harlamenko Jun 7, 2026
9e05b4f
refactor(evo-dropdown): consolidate scroll module imports
harlamenko Jun 7, 2026
da8ba59
refactor(evo-dropdown): store scroll strategy in a plain field
harlamenko Jun 7, 2026
e7ef21d
test(evo-tooltip): assert open state via hasAttached in service spec
harlamenko Jun 7, 2026
97aa050
docs(evo-scroll-strategies): detail capture vs cdkScrollable rationale
harlamenko Jun 7, 2026
e247c32
feat(evo-scroll-strategies): observe scroll per anchor ancestor
harlamenko Jun 7, 2026
4e47876
test(evo-scroll-strategies): cover getScrollableAncestors
harlamenko Jun 7, 2026
cba7800
refactor(evo-scroll-strategies): revert to global scroll listener, ke…
harlamenko Jun 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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
);
}
}
Original file line number Diff line number Diff line change
@@ -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);
};
}
5 changes: 5 additions & 0 deletions projects/evo-ui-kit/src/lib/common/scroll/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface ScrollPosition {
vertical: number;
horizontal: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type EvoScrollStrategy = 'noop' | 'close' | 'reposition';
Original file line number Diff line number Diff line change
@@ -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<Event> {
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);
}),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
[cdkConnectedOverlayOpen]="isOpen"
[cdkConnectedOverlayOrigin]="dropdownOrigin"
[cdkConnectedOverlayPositions]="connectedPositions"
[cdkConnectedOverlayScrollStrategy]="connectedScrollStrategy"
(detach)="close()"
(overlayOutsideClick)="onOverlayOutsideClick($event)"
>
Expand Down
Loading
Loading