Skip to content
Closed
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {Injector, NgZone} from '@angular/core';
import {DOCUMENT} from '@angular/common';
import {ScrollStrategy} from '@angular/cdk/overlay';
import {OverlayReference} from '@angular/cdk/overlay/overlay-reference';
import {EvoCloseScrollStrategyParams} from '../interfaces/evo-close-scroll-strategy-params';
import {filter, first, tap} from 'rxjs/operators';
import {createScrollStream} from '../utils/create-scroll-stream';
import {Subscription} from 'rxjs';
import {ScrollPosition} from '../interfaces/scroll-position';

export class EvoCloseScrollStrategy implements ScrollStrategy {
private readonly document: Document;
private readonly ngZone: NgZone;

private overlayRef: OverlayReference | null = null;
private scrollSubscription: Subscription | null = null;

private readonly initialScrollPositionsOld = new Map<HTMLElement, ScrollPosition>();

private initialScrollPosition: ScrollPosition | null = null;

constructor(private readonly injector: Injector, private readonly params?: EvoCloseScrollStrategyParams) {
this.document = this.injector.get(DOCUMENT);
this.ngZone = this.injector.get(NgZone);
}

attach(overlayRef: OverlayReference): void {
this.overlayRef = overlayRef;
}

detach(): void {
this.disable();
this.overlayRef = null;
}

disable(): void {
this.initialScrollPositionsOld.clear();
if (!this.scrollSubscription) {
return;
}

this.scrollSubscription.unsubscribe();
this.scrollSubscription = null;
}

enable(): void {
this.ngZone.runOutsideAngular(() => {
this.initialScrollPosition = this.getCurrentScrollPosition();

this.scrollSubscription = createScrollStream(this.document, this.overlayRef)
.pipe(
filter(() => this.checkThreshold()),
first(),
tap(() => this.detachOverlay()),
)
.subscribe();
});
}

private detachOverlay(): void {
if (!this.overlayRef?.hasAttached()) {
return;
}

this.disable();
this.ngZone.run(() => this.overlayRef.detach());
}

private checkThreshold(): boolean {
const threshold = this.params?.threshold ?? 0;
const triggerEl = this.params?.triggerRef?.nativeElement;

if (!triggerEl || !this.initialScrollPosition) {
return true;
}

const scrollPosition = this.getCurrentScrollPosition();

if (!scrollPosition) {
return null;
}

const distanceY = Math.abs(scrollPosition.vertical - this.initialScrollPosition.vertical);
const distanceX = Math.abs(scrollPosition.horizontal - this.initialScrollPosition.horizontal);

return distanceY > threshold || distanceX > threshold;
}

private getCurrentScrollPosition(): ScrollPosition | null {
const element = this.params?.triggerRef?.nativeElement as Element;

if (!element) {
return null;
}

const rect = element.getBoundingClientRect();

return {
vertical: rect.top,
horizontal: rect.left,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {Injector, NgZone} from '@angular/core';
import {DOCUMENT} from '@angular/common';
import {ScrollStrategy} from '@angular/cdk/overlay';
import {OverlayReference} from '@angular/cdk/overlay/overlay-reference';
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: OverlayReference | 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: OverlayReference): 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,22 @@
import {NoopScrollStrategy, ScrollStrategy} from '@angular/cdk/overlay';
import {Injectable, Injector} from '@angular/core';
import {EvoCloseScrollStrategy} from './evo-close-scroll-strategy';
import {EvoRepositionScrollStrategy} from './evo-reposition-scroll-strategy';
import {EvoCloseScrollStrategyParams} from '../interfaces/evo-close-scroll-strategy-params';

@Injectable()
export class EvoScrollStrategyOptions {
constructor(private readonly injector: Injector) {}

noop(): ScrollStrategy {
return new NoopScrollStrategy();
};

reposition(): ScrollStrategy {
return new EvoRepositionScrollStrategy(this.injector);
};

close(params?: EvoCloseScrollStrategyParams): ScrollStrategy {
return new EvoCloseScrollStrategy(this.injector, params);
};
}
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,7 @@
import {ElementRef} from '@angular/core';

export interface EvoCloseScrollStrategyParams {
/** Amount of pixels the user has to scroll before the overlay is closed. */
threshold: number;
triggerRef: ElementRef;
}
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,14 @@
import {animationFrameScheduler, fromEvent, Observable} from 'rxjs';
import {filter, throttleTime} from 'rxjs/operators';
import {OverlayReference} from '@angular/cdk/overlay/overlay-reference';

export function createScrollStream(document: Document, overlayRef: OverlayReference): Observable<Event> {
return fromEvent(document, 'scroll', {capture: true, passive: true}).pipe(
throttleTime(10, animationFrameScheduler, {leading: true, trailing: true}),
filter((event) => {
const target = event.target as Node;

return !overlayRef.overlayElement.contains(target);
}),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
[cdkConnectedOverlayOpen]="isOpen"
[cdkConnectedOverlayOrigin]="dropdownOrigin"
[cdkConnectedOverlayPositions]="connectedPositions"
[cdkConnectedOverlayScrollStrategy]="scrollStrategy$ | async"
(detach)="close()"
(overlayOutsideClick)="onOverlayOutsideClick($event)"
>
Expand Down
Loading
Loading