diff --git a/libs/rx/platform/README.md b/libs/rx/platform/README.md new file mode 100644 index 0000000..901fb19 --- /dev/null +++ b/libs/rx/platform/README.md @@ -0,0 +1,3 @@ +# @code-workers.io/angular-kit/rx/platform + +A set of reactive helpers wrapping browser APIs. diff --git a/libs/rx/platform/ng-package.json b/libs/rx/platform/ng-package.json new file mode 100644 index 0000000..c781f0d --- /dev/null +++ b/libs/rx/platform/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/rx/platform/src/index.ts b/libs/rx/platform/src/index.ts new file mode 100644 index 0000000..48e5e0f --- /dev/null +++ b/libs/rx/platform/src/index.ts @@ -0,0 +1,6 @@ +export * from './lib/create-resize-observer'; +export * from './lib/create-intersection-observer'; +export * from './lib/create-mutation-observer'; + +export * from './lib/directives/observe-resize.directive'; +export * from './lib/directives/observe-intersection.directive'; diff --git a/libs/rx/platform/src/lib/create-intersection-observer.spec.ts b/libs/rx/platform/src/lib/create-intersection-observer.spec.ts new file mode 100644 index 0000000..44724fb --- /dev/null +++ b/libs/rx/platform/src/lib/create-intersection-observer.spec.ts @@ -0,0 +1,26 @@ +import {createElementRef, mockIntersectionObserver} from '@angular-kit/testing'; +import {createIntersectionObserver} from './create-intersection-observer'; +import {subscribeSpyTo} from '@hirez_io/observer-spy'; +import {fakeAsync, tick} from '@angular/core/testing'; + +describe('createIntersectionObserver', () => { + describe('supported', () => { + beforeEach(() => mockIntersectionObserver()); + it('should create', () => { + const observer = createIntersectionObserver(createElementRef()); + expect(observer).toBeTruthy(); + }); + + it('should emit on intersect', fakeAsync(() => { + const elementRef = createElementRef(); + const observer = createIntersectionObserver(elementRef); + + const result = subscribeSpyTo(observer); + elementRef.nativeElement.dispatchEvent(new Event('intersect')); + + tick(1000); + + expect(result.getValues().length).toEqual(1); + })); + }); +}); diff --git a/libs/rx/platform/src/lib/create-intersection-observer.ts b/libs/rx/platform/src/lib/create-intersection-observer.ts new file mode 100644 index 0000000..67cf687 --- /dev/null +++ b/libs/rx/platform/src/lib/create-intersection-observer.ts @@ -0,0 +1,40 @@ +import {debounceTime, Observable, ReplaySubject, SchedulerLike, share} from 'rxjs'; +import {ElementRef} from '@angular/core'; + +const DEFAULT_THROTTLE_TIME = 125; + +export function supportsIntersectionObserver() { + return typeof window.IntersectionObserver !== 'undefined'; +} + +export function createIntersectionObserver( + observeElement: ElementRef, + options?: IntersectionObserverInit, + cfg?: { + throttleMs?: number; + scheduler?: SchedulerLike; + } +): Observable { + if (!supportsIntersectionObserver()) { + throw new Error('[AngularKit] IntersectionObserver is not supported in this browser'); + } + const obs$ = new Observable((subscriber) => { + const intersectionObserver = new IntersectionObserver((entries) => { + subscriber.next(entries); + }, options ?? {}); + + intersectionObserver.observe(observeElement.nativeElement); + + return () => intersectionObserver.disconnect(); + }); + + return obs$.pipe( + cfg?.throttleMs ? debounceTime(cfg?.throttleMs, cfg?.scheduler) : debounceTime(DEFAULT_THROTTLE_TIME), + share({ + connector: () => new ReplaySubject(1), + resetOnComplete: false, + resetOnError: false, + resetOnRefCountZero: false, + }) + ); +} diff --git a/libs/rx/platform/src/lib/create-mutation-observer.spec.ts b/libs/rx/platform/src/lib/create-mutation-observer.spec.ts new file mode 100644 index 0000000..766463d --- /dev/null +++ b/libs/rx/platform/src/lib/create-mutation-observer.spec.ts @@ -0,0 +1,26 @@ +import {createMutationObserver} from './create-mutation-observer'; +import {subscribeSpyTo} from '@hirez_io/observer-spy'; +import {createElementRef, mockMutationObserver} from '@angular-kit/testing'; +import {fakeAsync, tick} from '@angular/core/testing'; + +describe('createMutationObserver', () => { + describe('supported', () => { + beforeEach(() => mockMutationObserver()); + it('should create', () => { + const observer = createMutationObserver(createElementRef()); + expect(observer).toBeTruthy(); + }); + + it('should emit on resize', fakeAsync(() => { + const elementRef = createElementRef(); + const observer = createMutationObserver(elementRef); + + const result = subscribeSpyTo(observer); + elementRef.nativeElement.dispatchEvent(new Event('mutate')); + + tick(1000); + + expect(result.getValues().length).toEqual(1); + })); + }); +}); diff --git a/libs/rx/platform/src/lib/create-mutation-observer.ts b/libs/rx/platform/src/lib/create-mutation-observer.ts new file mode 100644 index 0000000..992b9fd --- /dev/null +++ b/libs/rx/platform/src/lib/create-mutation-observer.ts @@ -0,0 +1,40 @@ +import {debounceTime, Observable, ReplaySubject, SchedulerLike, share} from 'rxjs'; +import {ElementRef} from '@angular/core'; + +const DEFAULT_THROTTLE_TIME = 125; + +export function supportsMutationObserver() { + return typeof window.MutationObserver !== 'undefined'; +} + +export function createMutationObserver( + observeElement: ElementRef, + options?: MutationObserverInit, + cfg?: { + throttleMs?: number; + scheduler?: SchedulerLike; + } +): Observable { + if (!supportsMutationObserver()) { + throw new Error('[AngularKit] MutationObserver is not supported in this browser'); + } + const obs$ = new Observable((subscriber) => { + const mutationObserver = new MutationObserver((entries) => { + subscriber.next(entries); + }); + + mutationObserver.observe(observeElement.nativeElement, options ?? {}); + + return () => mutationObserver.disconnect(); + }); + + return obs$.pipe( + cfg?.throttleMs ? debounceTime(cfg?.throttleMs, cfg?.scheduler) : debounceTime(DEFAULT_THROTTLE_TIME), + share({ + connector: () => new ReplaySubject(1), + resetOnComplete: false, + resetOnError: false, + resetOnRefCountZero: false, + }) + ); +} diff --git a/libs/rx/platform/src/lib/create-resize-observer.spec.ts b/libs/rx/platform/src/lib/create-resize-observer.spec.ts new file mode 100644 index 0000000..452977c --- /dev/null +++ b/libs/rx/platform/src/lib/create-resize-observer.spec.ts @@ -0,0 +1,26 @@ +import {createResizeObserver} from './create-resize-observer'; +import {fakeAsync, tick} from '@angular/core/testing'; +import {subscribeSpyTo} from '@hirez_io/observer-spy'; +import {createElementRef, mockResizeObserver} from '@angular-kit/testing'; + +describe('createResizeObserver', () => { + describe('supported', () => { + beforeEach(() => mockResizeObserver()); + it('should create', () => { + const observer = createResizeObserver(createElementRef()); + expect(observer).toBeTruthy(); + }); + + it('should emit on resize', fakeAsync(() => { + const elementRef = createElementRef(); + const observer = createResizeObserver(elementRef); + + const result = subscribeSpyTo(observer); + elementRef.nativeElement.dispatchEvent(new Event('resize')); + + tick(1000); + + expect(result.getValues().length).toEqual(1); + })); + }); +}); diff --git a/libs/rx/platform/src/lib/create-resize-observer.ts b/libs/rx/platform/src/lib/create-resize-observer.ts new file mode 100644 index 0000000..0944df6 --- /dev/null +++ b/libs/rx/platform/src/lib/create-resize-observer.ts @@ -0,0 +1,42 @@ +import {ElementRef} from '@angular/core'; +import {debounceTime, distinctUntilChanged, Observable, ReplaySubject, SchedulerLike, share} from 'rxjs'; + +const DEFAULT_THROTTLE_TIME = 50; + +export function supportsResizeObserver() { + return typeof window.ResizeObserver !== 'undefined'; +} + +export type ResizeObserverConfig = { + throttleMs?: number; + scheduler?: SchedulerLike; +}; + +export function createResizeObserver( + observeElement: ElementRef, + cfg?: ResizeObserverConfig +): Observable { + if (!supportsResizeObserver()) { + throw new Error('[AngularKit] ResizeObserver is not supported in this browser'); + } + const obs$ = new Observable((subscriber) => { + const resizeObserver = new ResizeObserver((entries) => { + subscriber.next(entries); + }); + + resizeObserver.observe(observeElement.nativeElement); + + return () => resizeObserver.disconnect(); + }); + + return obs$.pipe( + distinctUntilChanged(), + cfg?.throttleMs ? debounceTime(cfg?.throttleMs, cfg?.scheduler) : debounceTime(DEFAULT_THROTTLE_TIME), + share({ + connector: () => new ReplaySubject(1), + resetOnComplete: false, + resetOnError: false, + resetOnRefCountZero: false, + }) + ); +}