From 5f174bea19880cf74a88ccc0251103204160c0c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Folt=C3=BDn?= Date: Tue, 1 Mar 2022 19:46:56 +0100 Subject: [PATCH 1/3] add infObserveResize directive --- .../observe-resize.directive.spec.ts | 63 +++++++++ .../observe-resize.directive.ts | 22 +++ .../observe-resize/observe-resize.module.ts | 10 ++ .../observe-resize/observe-resize.stories.ts | 72 ++++++++++ .../observe-resize.testing.directive.ts | 15 ++ .../observe-resize.testing.module.ts | 10 ++ .../docs/directives/observe-resize.md | 130 ++++++++++++++++++ ngx-nuts-and-bolts-docs/sidebars.js | 2 +- 8 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.directive.spec.ts create mode 100644 libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.directive.ts create mode 100644 libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.module.ts create mode 100644 libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.stories.ts create mode 100644 libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.testing.directive.ts create mode 100644 libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.testing.module.ts create mode 100644 ngx-nuts-and-bolts-docs/docs/directives/observe-resize.md diff --git a/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.directive.spec.ts b/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.directive.spec.ts new file mode 100644 index 00000000..ded76e94 --- /dev/null +++ b/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.directive.spec.ts @@ -0,0 +1,63 @@ +import { NgZone } from '@angular/core'; +import { ElementRef } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { noop } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { Subject, Subscription } from 'rxjs'; +import { ObserveResizeDirective } from './observe-resize.directive'; + +describe('ObserveResizeDirective', () => { + let directive: ObserveResizeDirective; + let elementRef: ElementRef; + let ngZone: NgZone; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [{ provide: ElementRef, useValue: {} }], + }).compileComponents(); + }); + + beforeEach(() => { + global.ResizeObserver ||= jest.fn(); + elementRef = TestBed.inject(ElementRef); + ngZone = TestBed.inject(NgZone); + }); + + it('should mirror rectangle height into output based on ResizeObserver', async () => { + const callbackParams$ = new Subject>(); + jest.spyOn(global, 'ResizeObserver').mockImplementation((callback) => { + let callbackCallsSub: Subscription; + const observer = { + observe: () => + (callbackCallsSub = callbackParams$.pipe(tap((params) => callback(params, observer))).subscribe()), + disconnect: () => callbackCallsSub?.unsubscribe(), + unobserve: noop, + }; + return observer; + }); + const observeResizeCallbackSpy = jest.fn(); + directive = new ObserveResizeDirective(elementRef, ngZone); + directive.ngAfterViewInit(); + const sub = directive.event.subscribe(observeResizeCallbackSpy); + + const resizeEntry1 = {} as ResizeObserverEntry; + callbackParams$.next([resizeEntry1]); + + expect(observeResizeCallbackSpy).toHaveBeenCalledTimes(1); + expect(observeResizeCallbackSpy.mock.calls[0][0]).toStrictEqual(resizeEntry1); + + const resizeEntry2 = {} as ResizeObserverEntry; + callbackParams$.next([resizeEntry2]); + + expect(observeResizeCallbackSpy).toHaveBeenCalledTimes(2); + expect(observeResizeCallbackSpy.mock.calls[0][0]).toStrictEqual(resizeEntry2); + + const resizeEntry3 = {} as ResizeObserverEntry; + callbackParams$.next([resizeEntry3]); + + expect(observeResizeCallbackSpy).toHaveBeenCalledTimes(3); + expect(observeResizeCallbackSpy.mock.calls[0][0]).toStrictEqual(resizeEntry3); + + sub.unsubscribe(); + }); +}); diff --git a/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.directive.ts b/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.directive.ts new file mode 100644 index 00000000..1b2a357b --- /dev/null +++ b/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.directive.ts @@ -0,0 +1,22 @@ +import { AfterViewInit, Directive, ElementRef, EventEmitter, NgZone, OnDestroy, Output } from '@angular/core'; + +@Directive({ + selector: '[infObserveResize]', +}) +export class ObserveResizeDirective implements OnDestroy, AfterViewInit { + @Output('infObserveResize') + public readonly event = new EventEmitter(); + + private observer?: ResizeObserver; + + constructor(private readonly elementRef: ElementRef, private readonly ngZone: NgZone) {} + + public ngAfterViewInit(): void { + this.observer = new ResizeObserver((entries) => this.ngZone.run(() => this.event.emit(entries[0]))); + this.observer?.observe(this.elementRef.nativeElement); + } + + public ngOnDestroy(): void { + this.observer?.disconnect(); + } +} diff --git a/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.module.ts b/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.module.ts new file mode 100644 index 00000000..549deb5c --- /dev/null +++ b/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ObserveResizeDirective } from './observe-resize.directive'; + +@NgModule({ + declarations: [ObserveResizeDirective], + imports: [CommonModule], + exports: [ObserveResizeDirective], +}) +export class ObserveResizeModule {} diff --git a/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.stories.ts b/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.stories.ts new file mode 100644 index 00000000..afbff07f --- /dev/null +++ b/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.stories.ts @@ -0,0 +1,72 @@ +import { Component } from '@angular/core'; +import { moduleMetadata, Story } from '@storybook/angular'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import { map, scan } from 'rxjs/operators'; +import { ObserveResizeModule } from './observe-resize.module'; + +@Component({ + template: ` +

Content rect:

+
{{ measurements$ | async | json }}
+ + +
+ {{ content$ | async }} +
+ `, + styles: [ + ` + .content { + background: #cecece; + padding: 15px; + } + `, + ], +}) +class ObserveResizeStoryComponent { + private readonly _renderContent$ = new BehaviorSubject(true); + public readonly renderContent$ = this._renderContent$.asObservable(); + private readonly _measurements$ = new Subject(); + public readonly measurements$ = this.createMeasurements(this._measurements$); + private readonly _content$ = new Subject(); + public readonly content$ = this.createContent(this._content$); + + public appendText(): void { + this._content$.next('Lorem ipsum dolor sit amet. '); + } + + public onResize(entries: ResizeObserverEntry): void { + this._measurements$.next(entries); + } + + public toggleContent(): void { + this._renderContent$.next(!this._renderContent$.value); + } + + public createContent(content$: Observable): Observable { + return content$.pipe(scan((acc, value) => acc + value, '')); + } + + public createMeasurements(measurements$: Observable): Observable { + return measurements$.pipe(map((measurement) => measurement.contentRect)); + } +} + +export default { + title: 'ObserveResize', + component: ObserveResizeStoryComponent, + decorators: [ + moduleMetadata({ + declarations: [ObserveResizeStoryComponent], + imports: [ObserveResizeModule], + }), + ], +}; + +const Template: Story = (args: ObserveResizeStoryComponent) => ({ + component: ObserveResizeStoryComponent, + props: args, +}); + +export const Default = Template.bind({}); +Default.args = {}; diff --git a/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.testing.directive.ts b/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.testing.directive.ts new file mode 100644 index 00000000..0f5ca651 --- /dev/null +++ b/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.testing.directive.ts @@ -0,0 +1,15 @@ +import { AfterViewInit, Directive, EventEmitter, OnDestroy, Output } from '@angular/core'; +import { noop } from 'rxjs'; +import { ExtractPublic } from '../../testing/extract-public/extract-public.type'; +import { ObserveResizeDirective } from './observe-resize.directive'; + +@Directive({ + selector: '[infObserveResize]', +}) +export class ObserveResizeTestingDirective implements ExtractPublic, OnDestroy, AfterViewInit { + @Output('infObserveResize') + public readonly event = new EventEmitter(); + + public ngAfterViewInit = noop; + public ngOnDestroy = noop; +} diff --git a/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.testing.module.ts b/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.testing.module.ts new file mode 100644 index 00000000..2eb5ff9c --- /dev/null +++ b/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.testing.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ObserveResizeTestingDirective } from './observe-resize.testing.directive'; + +@NgModule({ + declarations: [ObserveResizeTestingDirective], + imports: [CommonModule], + exports: [ObserveResizeTestingDirective], +}) +export class ObserveResizeTestingModule {} diff --git a/ngx-nuts-and-bolts-docs/docs/directives/observe-resize.md b/ngx-nuts-and-bolts-docs/docs/directives/observe-resize.md new file mode 100644 index 00000000..8688d62b --- /dev/null +++ b/ngx-nuts-and-bolts-docs/docs/directives/observe-resize.md @@ -0,0 +1,130 @@ +--- +id: observe-resize +title: ObserveResize directive +sidebar_label: ObserveResize directive +--- + +## 1. Features + +`infObserveResize` directive allows you to react to changes in dimensions of its host, using native `ResizeObserver`. + +## 2. Usage + +Import `ObserveResizeModule` which contains the `infObserveResize` directive. + +Simply add `infObserveResize` directive to whatever DOM node you care about. You can then handle notifications from `(infObserveResize)` EventEmitter. + +```ts +@Component({ + selector: 'app-example', + template: `
+ {{ height }} + `, +}) +class AppComponent implements AfterViewInit, OnDestroy { + private readonly _height$ = new BehaviorSubject(0); + public readonly height$ = this._height.asObservable(); + + public onResize(entry: ResizeObserverEntry): void { + this._height$.next(entry.contentRect.height); + } +} +``` + +Suppose we attempt to test `infObserveResize` with following case: + +```ts +// ❌ incorrect +describe('AppComponent', () => { + let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ObserveResizeModule], + declarations: [AppComponent], + }).compileComponents(); + fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + }); + it('should mirror rectangle height into output', async () => { + const resizableDebugEl = fixture.debugElement.query(By.css('div')); + const heightOutputDebugEl = fixture.debugElement.query(By.css('output')); + let newHeight = '250'; + resizableDebugEl.nativeElement.style.height = `${newHeight}px`; + await fixture.whenStable(); + fixture.detectChanges(); + expect(heightOutputDebugEl.nativeElement.innerText).toBe(newHeight); + newHeight = '400'; + resizableDebugEl.nativeElement.style.height = `${newHeight}px`; + await fixture.whenStable(); + fixture.detectChanges(); + expect(heightOutputDebugEl.nativeElement.innerText).toBe(newHeight); + }); +}); +``` + +Unfortunately above won't as well as when we run the tests, we will see it complaining that expected `heightOutputDebugEl` innerText doesn't match `newHeight`. Why is that? + +Clearly the browser doesn't fire ResizeObserver notifications the instant one of the elements gets resized or to make it clearer, it doesn't trigger the moment anything which could cause a resize happens. Instead, it runs as described in [spec](https://www.w3.org/TR/resize-observer/#html-event-loop), TL;DR after layout gets updated, but before repaint. Closest the end of that would be using `requestAnimationFrame` with `setTimeout`, where `requestAnimationFrame` runs its callback just before the repaint happens, we can defer it to next macrotask with `setTimeout` (which is bound to happen after repaint). We could use artificial delay as well, but that wouldn't guarantee the execution after the next repaint. + +## Test solution + +```ts +// ✅ correct +function afterRepaint(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => setTimeout(() => resolve())); + }); +} +describe('AppComponent', () => { + let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ObserveResizeModule], + declarations: [AppComponent], + }).compileComponents(); + fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + }); + it('should mirror rectangle height into output', async () => { + const resizableDebugEl = fixture.debugElement.query(By.css('div')); + const heightOutputDebugEl = fixture.debugElement.query(By.css('output')); + let newHeight = '250'; + resizableDebugEl.nativeElement.style.height = `${newHeight}px`; + await afterRepaint(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(heightOutputDebugEl.nativeElement.innerText).toBe(newHeight); + newHeight = '400'; + resizableDebugEl.nativeElement.style.height = `${newHeight}px`; + await afterRepaint(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(heightOutputDebugEl.nativeElement.innerText).toBe(newHeight); + }); +}); +``` + +By awaiting `afterRepaint` we are guaranteeing that the test case is synchronized properly. diff --git a/ngx-nuts-and-bolts-docs/sidebars.js b/ngx-nuts-and-bolts-docs/sidebars.js index d5e7d58d..ea2006a2 100644 --- a/ngx-nuts-and-bolts-docs/sidebars.js +++ b/ngx-nuts-and-bolts-docs/sidebars.js @@ -24,7 +24,7 @@ const sidebars = { { type: 'category', label: 'Directives', - items: ['directives/in-view'], + items: ['directives/in-view', 'directives/observe-resize'], }, { type: 'category', From a35cf9e51e47af7167c4df475fbd22888834b18f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Folt=C3=BDn?= Date: Thu, 24 Mar 2022 20:30:24 +0100 Subject: [PATCH 2/3] add observe resize to exports --- libs/ngx-nuts-and-bolts/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libs/ngx-nuts-and-bolts/src/index.ts b/libs/ngx-nuts-and-bolts/src/index.ts index 08e3441b..19fbc896 100644 --- a/libs/ngx-nuts-and-bolts/src/index.ts +++ b/libs/ngx-nuts-and-bolts/src/index.ts @@ -11,6 +11,10 @@ export * from './lib/directives/in-view/in-view.module'; export * from './lib/directives/in-view/in-view.directive'; export * from './lib/directives/in-view/in-view.testing.module'; export * from './lib/directives/in-view/in-view.testing.directive'; +export * from './lib/directives/observe-resize/observe-resize.module'; +export * from './lib/directives/observe-resize/observe-resize.directive'; +export * from './lib/directives/observe-resize/observe-resize.testing.module'; +export * from './lib/directives/observe-resize/observe-resize.testing.directive'; // Utilities export * from './lib/utilities/loading-state/loading-state'; From 90d0b1286244dfab22cd27e453b050fc27bb2f2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Folt=C3=BDn?= Date: Thu, 24 Mar 2022 20:40:24 +0100 Subject: [PATCH 3/3] address PR feedback --- .../directives/observe-resize/observe-resize.stories.ts | 8 ++++---- ngx-nuts-and-bolts-docs/docs/directives/observe-resize.md | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.stories.ts b/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.stories.ts index afbff07f..070b3e0d 100644 --- a/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.stories.ts +++ b/libs/ngx-nuts-and-bolts/src/lib/directives/observe-resize/observe-resize.stories.ts @@ -8,8 +8,8 @@ import { ObserveResizeModule } from './observe-resize.module'; template: `

Content rect:

{{ measurements$ | async | json }}
- - + +
{{ content$ | async }}
@@ -31,7 +31,7 @@ class ObserveResizeStoryComponent { private readonly _content$ = new Subject(); public readonly content$ = this.createContent(this._content$); - public appendText(): void { + public onAppendTextClick(): void { this._content$.next('Lorem ipsum dolor sit amet. '); } @@ -39,7 +39,7 @@ class ObserveResizeStoryComponent { this._measurements$.next(entries); } - public toggleContent(): void { + public onToggleContentClick(): void { this._renderContent$.next(!this._renderContent$.value); } diff --git a/ngx-nuts-and-bolts-docs/docs/directives/observe-resize.md b/ngx-nuts-and-bolts-docs/docs/directives/observe-resize.md index 8688d62b..5c5ec0b1 100644 --- a/ngx-nuts-and-bolts-docs/docs/directives/observe-resize.md +++ b/ngx-nuts-and-bolts-docs/docs/directives/observe-resize.md @@ -85,9 +85,9 @@ describe('AppComponent', () => { }); ``` -Unfortunately above won't as well as when we run the tests, we will see it complaining that expected `heightOutputDebugEl` innerText doesn't match `newHeight`. Why is that? +Unfortunately above won't work well, as when we run the tests, we will see it is complaining that expected `heightOutputDebugEl` innerText doesn't match `newHeight`. Why is that? -Clearly the browser doesn't fire ResizeObserver notifications the instant one of the elements gets resized or to make it clearer, it doesn't trigger the moment anything which could cause a resize happens. Instead, it runs as described in [spec](https://www.w3.org/TR/resize-observer/#html-event-loop), TL;DR after layout gets updated, but before repaint. Closest the end of that would be using `requestAnimationFrame` with `setTimeout`, where `requestAnimationFrame` runs its callback just before the repaint happens, we can defer it to next macrotask with `setTimeout` (which is bound to happen after repaint). We could use artificial delay as well, but that wouldn't guarantee the execution after the next repaint. +Clearly the browser doesn't fire ResizeObserver notifications the instant one of the elements gets resized or to make it clearer, it doesn't trigger the moment anything which could cause a resize happens. Instead, it runs as described in [spec](https://www.w3.org/TR/resize-observer/#html-event-loop), in other words: after layout gets updated, but before repaint. Closest to that would be using `requestAnimationFrame` with `setTimeout`, where `requestAnimationFrame` runs its callback just before the repaint happens, which in turn we can defer to next macrotask with `setTimeout`. We could use artificial delay as well, but that wouldn't guarantee the execution after the next repaint. ## Test solution