@thejlifex/ngx-scroll-spy
Version:
An Angular library that can spy the position of elements (inside a scrollable container) and emit the currently active (visible) element in the viewport.
358 lines (349 loc) • 15.9 kB
JavaScript
import { isPlatformServer } from '@angular/common';
import * as i0 from '@angular/core';
import { PLATFORM_ID, Injectable, Inject, Directive, Input, HostBinding } from '@angular/core';
import { Subject, fromEvent, debounceTime, takeUntil, filter } from 'rxjs';
/**
* Encapsulates the logic for scroll spy on a scrollable element (`scrollContainer`).
*
* Scroll spy uses the [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API).
*
* Supports window resizing.
*
* Inspired by:
* - https://grafikart.fr/tutoriels/scrollspy-js-page-491
* - https://codepen.io/diaphragm/pen/bGNBVxw
* - https://github.com/chefomar/angular-scroll-spy
*/
class ScrollSpy {
constructor({ scrollContainer } = {}) {
this.spyTargetIsIntersectingSubject$ = new Subject();
/**
* Is emitted when a spy-target has transitioned into a state of intersection (`isIntersecting: true`) or out of a state of intersection (`isIntersecting: false`).
*/
this.spyTargetIsIntersecting$ = this.spyTargetIsIntersectingSubject$.asObservable();
this.spyTargets = [];
this.intersectionObserver = null;
this.resizeDebounceTime = 300;
/**
* Defines the (vertical) position of the IntersectionObserver line.
* Value between `0` (at the top) and `100` (at the bottom).
*
* @todo (For a future version of ngx-scroll-spy) This property should be configured by the user.
*/
this.offset = 20;
this.initIntersectionObserver(scrollContainer);
this.resizeSubscription = fromEvent(window, 'resize').pipe(debounceTime(this.resizeDebounceTime)).subscribe(() => this.initIntersectionObserver(scrollContainer));
}
/**
* Adds a spy-target and starts to observe that target.
*/
addTarget(spyTarget) {
var _a;
this.spyTargets.push(spyTarget);
(_a = this.intersectionObserver) === null || _a === void 0 ? void 0 : _a.observe(spyTarget.element);
}
/**
* Removes a spy-target and stops observing that target.
*/
removeTarget(spyTarget) {
var _a;
this.spyTargets = this.spyTargets.filter(spyTarget => spyTarget.id !== spyTarget.id);
(_a = this.intersectionObserver) === null || _a === void 0 ? void 0 : _a.unobserve(spyTarget.element);
}
/**
* Stops observing all spy-targets and completes spyTargetIsIntersecting$ subject.
*/
stopSpying() {
var _a, _b;
(_a = this.resizeSubscription) === null || _a === void 0 ? void 0 : _a.unsubscribe();
(_b = this.intersectionObserver) === null || _b === void 0 ? void 0 : _b.disconnect();
this.intersectionObserver = null;
this.spyTargets = [];
this.spyTargetIsIntersectingSubject$.complete();
}
/**
* Initialize IntersectionObserver.
*/
initIntersectionObserver(scrollContainer) {
var _a;
(_a = this.intersectionObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
this.intersectionObserver = new IntersectionObserver((entries) => this.intersectionObserverCallback(entries), {
root: scrollContainer,
rootMargin: `-${this.offset}% 0px -${100 - this.offset}% 0px` // IntersectionObserver as one line.
});
for (const spyTarget of this.spyTargets) {
this.intersectionObserver.observe(spyTarget.element);
}
}
/**
* A function which is called when one or more spy-targets have transitioned into a state of intersection (`isIntersecting: true`) or out of a state of intersection (`isIntersecting: false`).
*/
intersectionObserverCallback(entries) {
for (const entry of entries) {
const spyTarget = this.spyTargets.find((item) => item.element === entry.target);
this.spyTargetIsIntersectingSubject$.next({
spyTarget,
isIntersecting: entry.isIntersecting
});
}
}
}
/**
* Scroll spy service.
*
* Creates a scroll-spy on the Document/Body per default and destroys it when the service is destroyed.
*
* Allows you to create (and destroy) scroll-spy on a scroll-container (any scrollable element).
*/
class ScrollSpyService {
constructor(platformId) {
this.platformId = platformId;
if (isPlatformServer(this.platformId)) {
return;
}
this.defaultScrollSpy = new ScrollSpy();
this.scrollSpies = new Map();
}
ngOnDestroy() {
if (isPlatformServer(this.platformId)) {
return;
}
this.defaultScrollSpy.stopSpying();
this.scrollSpies.forEach((item) => item.stopSpying());
this.scrollSpies.clear();
}
/**
* Create a scroll-spy for the provided `scrollContainer`.
*/
createScrollSpy(scrollContainerId, scrollContainer) {
this.scrollSpies.set(scrollContainerId, new ScrollSpy({ scrollContainer }));
}
/**
* Destroy the scroll-spy with ID equals the provided `scrollContainerId`.
*/
destroyScrollSpy(scrollContainerId) {
var _a;
(_a = this.scrollSpies.get(scrollContainerId)) === null || _a === void 0 ? void 0 : _a.stopSpying();
this.scrollSpies.delete(scrollContainerId);
}
/**
* Get the `spyTargetIsIntersecting$` observable for the provided `scrollContainerId`,
*
* Or the `spyTargetIsIntersecting$` of the `defaultScrollSpy` when `scrollContainerId` is `undefined`.
*/
getSpyTargetIsIntersecting$(scrollContainerId) {
return this.getScrollSpy(scrollContainerId).spyTargetIsIntersecting$;
}
/**
* Adds a spy-target and starts to observe that target.
*/
addTarget(spyTarget) {
this.getScrollSpy(spyTarget.containerId).addTarget(spyTarget);
}
/**
* Removes a spy-target and stops observing that target.
*/
removeTarget(spyTarget) {
this.getScrollSpy(spyTarget.containerId).removeTarget(spyTarget);
}
/**
* Get the scroll-spy for the proivded `scrollContainerId`.
*
* When `scrollContainerId` is `undefined` the `defaultScrollSpy` is returned.
*/
getScrollSpy(scrollContainerId) {
if (!scrollContainerId) {
return this.defaultScrollSpy;
}
const scrollSpy = this.scrollSpies.get(scrollContainerId);
if (!scrollSpy) {
throw new Error(`Unknown spyTargetContainerId: "${scrollContainerId}". Please make sure that the spyTargetContainerId inputs have the same value on the three directives: spy, spyTarget and spyTargetContainer.`);
}
return scrollSpy;
}
}
ScrollSpyService.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.2.4", ngImport: i0, type: ScrollSpyService, deps: [{ token: PLATFORM_ID }], target: i0.ɵɵFactoryTarget.Injectable });
ScrollSpyService.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "14.2.4", ngImport: i0, type: ScrollSpyService, providedIn: 'root' });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.2.4", ngImport: i0, type: ScrollSpyService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root'
}]
}], ctorParameters: function () {
return [{ type: undefined, decorators: [{
type: Inject,
args: [PLATFORM_ID]
}] }];
} });
/**
* Spies on the spyTarget that has the provided `spyTargetId`.
*/
class SpyDirective {
constructor(scrollSpyService, platformId, changeDetectorRef) {
this.scrollSpyService = scrollSpyService;
this.platformId = platformId;
this.changeDetectorRef = changeDetectorRef;
/**
* Class name to add to this element when the spyTarget has transitioned into a state of intersection (is visisble).
*
* @default
* 'active'
*/
this.spyActiveClass = 'active';
this.directiveDestroyed$ = new Subject();
}
ngOnInit() {
if (isPlatformServer(this.platformId)) {
return;
}
if (!this.spyTargetId) {
throw new Error('SpyDirective: spyTargetId needs to be set.');
}
this.scrollSpyService.getSpyTargetIsIntersecting$(this.spyTargetContainerId)
.pipe(takeUntil(this.directiveDestroyed$), filter((event) => event.spyTarget.id === this.spyTargetId
&& event.spyTarget.containerId === this.spyTargetContainerId))
.subscribe((event) => {
this.activeClassInDOM = event.isIntersecting ? this.spyActiveClass : undefined;
/**
* Manually trigger change detection to update activeClass in the DOM
* (Needed for the case changeDetection OnPush is used).
*/
this.changeDetectorRef.detectChanges();
});
}
ngOnDestroy() {
this.directiveDestroyed$.next();
this.directiveDestroyed$.complete();
}
}
SpyDirective.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.2.4", ngImport: i0, type: SpyDirective, deps: [{ token: ScrollSpyService }, { token: PLATFORM_ID }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Directive });
SpyDirective.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "14.2.4", type: SpyDirective, isStandalone: true, selector: "[spy]", inputs: { spyTargetId: "spyTargetId", spyTargetContainerId: "spyTargetContainerId", spyActiveClass: "spyActiveClass" }, host: { properties: { "class": "this.activeClassInDOM" } }, ngImport: i0 });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.2.4", ngImport: i0, type: SpyDirective, decorators: [{
type: Directive,
args: [{
selector: '[spy]',
standalone: true
}]
}], ctorParameters: function () {
return [{ type: ScrollSpyService }, { type: undefined, decorators: [{
type: Inject,
args: [PLATFORM_ID]
}] }, { type: i0.ChangeDetectorRef }];
}, propDecorators: { spyTargetId: [{
type: Input
}], spyTargetContainerId: [{
type: Input
}], spyActiveClass: [{
type: Input
}], activeClassInDOM: [{
type: HostBinding,
args: ['class']
}] } });
/**
* Adds this element as spy-target and starts to observe this target.
*
* Automatically removes this element as spy-target and stops observing this target when the element is destroyed.
*/
class SpyTargetDirective {
constructor(elementRef, scrollSpyService, platformId) {
this.elementRef = elementRef;
this.scrollSpyService = scrollSpyService;
this.platformId = platformId;
}
ngOnInit() {
if (isPlatformServer(this.platformId)) {
return;
}
if (!this.spyTargetId) {
throw new Error('SpyTargetDirective: spyTargetId needs to be set.');
}
this.target = {
id: this.spyTargetId,
element: this.elementRef.nativeElement,
containerId: this.spyTargetContainerId
};
this.scrollSpyService.addTarget(this.target);
}
ngOnDestroy() {
if (isPlatformServer(this.platformId)) {
return;
}
this.scrollSpyService.removeTarget(this.target);
}
}
SpyTargetDirective.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.2.4", ngImport: i0, type: SpyTargetDirective, deps: [{ token: i0.ElementRef }, { token: ScrollSpyService }, { token: PLATFORM_ID }], target: i0.ɵɵFactoryTarget.Directive });
SpyTargetDirective.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "14.2.4", type: SpyTargetDirective, isStandalone: true, selector: "[spyTarget]", inputs: { spyTargetId: "spyTargetId", spyTargetContainerId: "spyTargetContainerId" }, ngImport: i0 });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.2.4", ngImport: i0, type: SpyTargetDirective, decorators: [{
type: Directive,
args: [{
selector: '[spyTarget]',
standalone: true
}]
}], ctorParameters: function () {
return [{ type: i0.ElementRef }, { type: ScrollSpyService }, { type: undefined, decorators: [{
type: Inject,
args: [PLATFORM_ID]
}] }];
}, propDecorators: { spyTargetId: [{
type: Input
}], spyTargetContainerId: [{
type: Input
}] } });
/**
* Creates a scroll-spy on this spy targets container (element).
*
* Automatically destroys the scroll-spy when the element is destroyed.
*
* This is useful:
*
* - when you have multiple (independent) scrollable elements on the same page,
* - or the scrollable element on your page is not the browser default scrollable element, for example `<mat-sidenav-content>` if you are using [Angular Material Sidenav](https://material.angular.io/components/sidenav)
*
* where you want to have a scroll-spy.
*/
class SpyTargetContainerDirective {
constructor(scrollSpyService, platformId, elementRef) {
this.scrollSpyService = scrollSpyService;
this.platformId = platformId;
this.elementRef = elementRef;
}
ngOnInit() {
if (isPlatformServer(this.platformId)) {
return;
}
if (!this.spyTargetContainerId) {
throw new Error('SpyTargetContainerDirective: spyTargetContainerId needs to be set.');
}
this.scrollSpyService.createScrollSpy(this.spyTargetContainerId, this.elementRef.nativeElement);
}
ngOnDestroy() {
if (isPlatformServer(this.platformId)) {
return;
}
this.scrollSpyService.destroyScrollSpy(this.spyTargetContainerId);
}
}
SpyTargetContainerDirective.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.2.4", ngImport: i0, type: SpyTargetContainerDirective, deps: [{ token: ScrollSpyService }, { token: PLATFORM_ID }, { token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Directive });
SpyTargetContainerDirective.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "14.2.4", type: SpyTargetContainerDirective, isStandalone: true, selector: "[spyTargetContainer]", inputs: { spyTargetContainerId: "spyTargetContainerId" }, ngImport: i0 });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.2.4", ngImport: i0, type: SpyTargetContainerDirective, decorators: [{
type: Directive,
args: [{
selector: '[spyTargetContainer]',
standalone: true
}]
}], ctorParameters: function () {
return [{ type: ScrollSpyService }, { type: undefined, decorators: [{
type: Inject,
args: [PLATFORM_ID]
}] }, { type: i0.ElementRef }];
}, propDecorators: { spyTargetContainerId: [{
type: Input
}] } });
/*
* Public API Surface of ngx-scroll-spy
*/
/**
* Generated bundle index. Do not edit.
*/
export { SpyDirective, SpyTargetContainerDirective, SpyTargetDirective };
//# sourceMappingURL=thejlifex-ngx-scroll-spy.mjs.map