-
Notifications
You must be signed in to change notification settings - Fork 93
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
9 changed files
with
366 additions
and
0 deletions.
There are no files selected for viewing
69 changes: 69 additions & 0 deletions
69
docs/src/content/docs/utilities/Injectors/inject-is-intersecting.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{ | ||
"lib": { | ||
"entryFile": "src/index.ts" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
] | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
125 changes: 125 additions & 0 deletions
125
libs/ngxtension/inject-is-intersecting/src/inject-is-intersecting.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
61 changes: 61 additions & 0 deletions
61
libs/ngxtension/inject-is-intersecting/src/inject-is-intersecting.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}); | ||
}; |
65 changes: 65 additions & 0 deletions
65
libs/ngxtension/inject-is-intersecting/src/is-in-viewport.service.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters