Skip to content

Commit

Permalink
feat: added inject-is-intersecting
Browse files Browse the repository at this point in the history
  • Loading branch information
eneajaho committed Oct 13, 2023
1 parent 4ea0062 commit af94c79
Show file tree
Hide file tree
Showing 9 changed files with 366 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
title: injectIsIntersecting
description: ngxtension/inject-is-intersecting
---

`injectIsIntersecting` is a helper function that returns an observable that emits when the component/directive or a given element is being intersected.

To handle this, `IntersectionObserver` is being used underneath. The intersection logic runs outside zone for better performance.

```ts
import { injectIsIntersecting } from 'ngxtension/inject-is-intersecting';
```

## Usage

We can use it to listen to component itself being intersected.

```ts
@Component({})
export class MyComponent {
private destroyRef = inject(DestroyRef);

isIntersecting$ = injectIsIntersecting();

isInViewport$ = this.isIntersecting$.pipe(filter(x.intersectionRatio > 0), take(1));

ngOnInit() {
this.getData().subscribe();
}

getData() {
// Only fetch data when the element is in the viewport
return this.isInViewport$.pipe(
switchMap(() => this.service.getData()),
takeUntil(this.destroy$)
);
}
}
```

Or, we can use it to listen to a given element being intersected.

```ts
@Component({
template: `
<div #myDivRef></div>
`,
})
export class MyComponent implements OnInit {
@ViewChild('myDivRef', { static: true }) myDivEl!: HTMLDivElement;

private injector = inject(Injector);

ngOnInit() {
const divInViewport$ = injectIsIntersecting({
element: this.myDivEl,
injector: this.injector,
}).pipe(filter(x.intersectionRatio > 0));

// Only fetch data when the element is in the viewport
divInViewport$
.pipe(
switchMap(() => this.service.getData()),
take(1)
)
.subscribe();
}
}
```
3 changes: 3 additions & 0 deletions libs/ngxtension/inject-is-intersecting/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# ngxtension/inject-is-intersecting

Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/inject-is-intersecting`.
5 changes: 5 additions & 0 deletions libs/ngxtension/inject-is-intersecting/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "src/index.ts"
}
}
33 changes: 33 additions & 0 deletions libs/ngxtension/inject-is-intersecting/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "ngxtension/inject-is-intersecting",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "libs/ngxtension/inject-is-intersecting/src",
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/ngxtension/jest.config.ts",
"testPathPattern": ["inject-is-intersecting"],
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
},
"lint": {
"executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": [
"libs/ngxtension/inject-is-intersecting/**/*.ts",
"libs/ngxtension/inject-is-intersecting/**/*.html"
]
}
}
}
}
2 changes: 2 additions & 0 deletions libs/ngxtension/inject-is-intersecting/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './inject-is-intersecting';
export * from './is-in-viewport.service';
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Component, inject, Injector, OnInit, ViewChild } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Subject, take } from 'rxjs';
import { injectIsIntersecting } from './inject-is-intersecting';
import {
IsInViewportService,
IsInViewportServiceInterface,
} from './is-in-viewport.service';

describe(injectIsIntersecting.name, () => {
describe('should emit when component itself is intersecting', () => {
@Component({ standalone: true, template: '' })
class TestComponent {
isIntersecting$ = injectIsIntersecting();
}

let component: TestComponent;
let fixture: ComponentFixture<TestComponent>;
let isInViewportService: IsInViewportServiceInterface;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestComponent],
providers: [
{
provide: IsInViewportService,
useValue: new MockIsInViewportService(),
},
],
}).compileComponents();

fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
isInViewportService = TestBed.inject(IsInViewportService);
});

it('in injection context', (done) => {
component.isIntersecting$.pipe(take(1)).subscribe((x) => {
expect(x.isIntersecting).toBe(true);
done();
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const entry: IntersectionObserverEntry = { isIntersecting: true };
isInViewportService.intersect(fixture.nativeElement, entry);
});
});

describe('should emit when given element is intersecting', () => {
@Component({
standalone: true,
template: `
<div #el></div>
`,
})
class TestComponent implements OnInit {
private injector = inject(Injector);
@ViewChild('el', { static: true }) divEl!: HTMLDivElement;

intersected = false;

ngOnInit() {
injectIsIntersecting({ element: this.divEl, injector: this.injector })
.pipe(take(1))
.subscribe((entry) => {
this.intersected = entry.target === this.divEl;
});
}
}

let component: TestComponent;
let fixture: ComponentFixture<TestComponent>;
let isInViewportService: IsInViewportServiceInterface;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestComponent],
providers: [
{
provide: IsInViewportService,
useValue: new MockIsInViewportService(),
},
],
}).compileComponents();

fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
isInViewportService = TestBed.inject(IsInViewportService);
});

it('not in injection context when provided with an injector', () => {
expect(component.intersected).toBe(false);
component.ngOnInit();

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const entry: IntersectionObserverEntry = {
isIntersecting: true,
target: component.divEl,
// target: document.createElement('div') --> uncomment this to fail the test
};
isInViewportService.intersect(component.divEl, entry);

expect(component.intersected).toBe(true);
});
});
});

class MockIsInViewportService implements IsInViewportServiceInterface {
elMap = new Map<Element, Subject<IntersectionObserverEntry>>();

observe(element: Element): Subject<IntersectionObserverEntry> {
const subject = new Subject<IntersectionObserverEntry>();
this.elMap.set(element, subject);
return subject;
}

unobserve(element: Element): void {
this.elMap.delete(element);
}

intersect(element: Element, entry: IntersectionObserverEntry) {
this.elMap.get(element)?.next(entry);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
DestroyRef,
ElementRef,
inject,
Injector,
runInInjectionContext,
} from '@angular/core';
import { assertInjector } from 'ngxtension/assert-injector';
import { injectDestroy } from 'ngxtension/inject-destroy';
import { IsInViewportService } from './is-in-viewport.service';

export interface InjectIsIntersectingOptions {
injector?: Injector;
element?: Element;
}

/**
* Injects an observable that emits whenever the element is intersecting the viewport.
* The observable will complete when the element is destroyed.
* @param options
*
* @example
* export class MyComponent {
* private destroyRef = inject(DestroyRef);
*
* isIntersecting$ = injectIsIntersecting();
* isInViewport$ = this.isIntersecting$.pipe(
* filter(x.intersectionRatio > 0),
* take(1),
* );
*
* ngOnInit() {
* this.getData().subscribe();
* }
*
* getData() {
* // Only fetch data when the element is in the viewport
* return this.isInViewport$.pipe(
* switchMap(() => this.service.getData()),
* takeUntil(this.destroy$)
* );
* }
* }
*/
export const injectIsIntersecting = (options?: InjectIsIntersectingOptions) => {
const injector = assertInjector(injectDestroy, options?.injector);

return runInInjectionContext(injector, () => {
const el = options?.element ?? inject(ElementRef).nativeElement;
const inInViewportService = inject(IsInViewportService);
const destroyRef = inject(DestroyRef);

const sub = inInViewportService.observe(el);

destroyRef.onDestroy(() => {
inInViewportService.unobserve(el);
});

return sub;
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { inject, Injectable, NgZone } from '@angular/core';
import { Subject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class IsInViewportService implements IsInViewportServiceInterface {
private ngZone = inject(NgZone);

#observerListeners = new Map<Element, Subject<IntersectionObserverEntry>>();

#observer?: IntersectionObserver;

#createObserver() {
this.#observer = this.ngZone.runOutsideAngular(() => {
return new IntersectionObserver((entries) => {
for (const entry of entries) {
this.intersect(entry.target, entry);
}
});
});
}

observe(element: Element) {
if (!this.#observer) {
this.#createObserver();
}

if (this.#observerListeners.has(element)) {
return this.#observerListeners.get(element)!;
}

this.#observerListeners.set(
element,
new Subject<IntersectionObserverEntry>()
);
this.#observer?.observe(element);

return this.#observerListeners.get(element)!;
}

unobserve(element: Element) {
this.#observer?.unobserve(element);

this.#observerListeners.get(element)?.complete();
this.#observerListeners.delete(element);

if (this.#observerListeners.size === 0) {
this.#disconnect();
}
}

intersect(element: Element, entry: IntersectionObserverEntry) {
this.#observerListeners.get(element)?.next(entry);
}

#disconnect() {
this.#observer?.disconnect();
this.#observer = undefined;
}
}

export interface IsInViewportServiceInterface {
observe(element: Element): Subject<IntersectionObserverEntry>;
unobserve(element: Element): void;
intersect(element: Element, entry: IntersectionObserverEntry): void;
}
3 changes: 3 additions & 0 deletions tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
"ngxtension/inject-destroy": [
"libs/ngxtension/inject-destroy/src/index.ts"
],
"ngxtension/inject-is-intersecting": [
"libs/ngxtension/inject-is-intersecting/src/index.ts"
],
"ngxtension/intl": ["libs/ngxtension/intl/src/index.ts"],
"ngxtension/map-array": ["libs/ngxtension/map-array/src/index.ts"],
"ngxtension/navigation-end": [
Expand Down

0 comments on commit af94c79

Please sign in to comment.