Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add infObserveResize directive #60

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Array<ResizeObserverEntry>>();
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();
});
});
Original file line number Diff line number Diff line change
@@ -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<ResizeObserverEntry>();

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();
}
}
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
@@ -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: `
<h2>Content rect:</h2>
<pre>{{ measurements$ | async | json }}</pre>
<button (click)="appendText()">Append text</button>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

click handler naming -> onAppendTextClick(),

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<button (click)="toggleContent()">Toggle content</button>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

click handler naming -> onToggleContentClick(),

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<div *ngIf="renderContent$ | async" (infObserveResize)="onResize($event)" class="content">
{{ content$ | async }}
</div>
`,
styles: [
`
.content {
background: #cecece;
padding: 15px;
}
`,
],
})
class ObserveResizeStoryComponent {
private readonly _renderContent$ = new BehaviorSubject<boolean>(true);
public readonly renderContent$ = this._renderContent$.asObservable();
private readonly _measurements$ = new Subject<ResizeObserverEntry>();
public readonly measurements$ = this.createMeasurements(this._measurements$);
private readonly _content$ = new Subject<string>();
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<string>): Observable<string> {
return content$.pipe(scan((acc, value) => acc + value, ''));
}

public createMeasurements(measurements$: Observable<ResizeObserverEntry>): Observable<DOMRectReadOnly> {
return measurements$.pipe(map((measurement) => measurement.contentRect));
}
}

export default {
title: 'ObserveResize',
component: ObserveResizeStoryComponent,
decorators: [
moduleMetadata({
declarations: [ObserveResizeStoryComponent],
imports: [ObserveResizeModule],
}),
],
};

const Template: Story<ObserveResizeStoryComponent> = (args: ObserveResizeStoryComponent) => ({
component: ObserveResizeStoryComponent,
props: args,
});

export const Default = Template.bind({});
Default.args = {};
Original file line number Diff line number Diff line change
@@ -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<ObserveResizeDirective>, OnDestroy, AfterViewInit {
@Output('infObserveResize')
public readonly event = new EventEmitter<ResizeObserverEntry>();

public ngAfterViewInit = noop;
public ngOnDestroy = noop;
}
Original file line number Diff line number Diff line change
@@ -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 {}
130 changes: 130 additions & 0 deletions ngx-nuts-and-bolts-docs/docs/directives/observe-resize.md
Original file line number Diff line number Diff line change
@@ -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: `<div (infObserveResize)="resizeObserverEntry$.next($event)>{{ resizeObserverEntry$ | async }}</div>`,
})
class ExampleComponent {
public resizeObserverEntry$ = new Subject<ResizeObserverEntry>();
}
```

## 3. Testing

If you don't care about actual native `ResizeObserver` behavior in tests, just import the `ObserveResizeTestingModule` and use that in your tests. You will have to fire the events manually as the testing implementation is empty.

Jest's jsdom doesn't support `ResizeObserver` so you don't have much choice there.

On the other hand if you do want to test handling of resize events feel free to use the `ObserveResizeModule`, however be aware of certain gotchas that come with testing `ResizeObserver`.

### Issue

Let's assume we have this component:

```ts
@Component({
selector: 'app-root',
template: `
<div (infObserveResize)="onResize($event)"></div>
<output *ngIf="height$ | async as height">{{ height }}</output>
`,
})
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<AppComponent>;
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?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you maybe missing a word in this sentence ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TL;DR sentence isn't clear to me as well, Should it maybe be smth like "after layout gets updated, but before repaint ResizeObserver notification is fired?

Copy link
Contributor Author

@Erbenos Erbenos Mar 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to reword the paragraph a little bit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


## Test solution

```ts
// ✅ correct
function afterRepaint(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => setTimeout(() => resolve()));
});
}
describe('AppComponent', () => {
let fixture: ComponentFixture<AppComponent>;
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.
2 changes: 1 addition & 1 deletion ngx-nuts-and-bolts-docs/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const sidebars = {
{
type: 'category',
label: 'Directives',
items: ['directives/in-view'],
items: ['directives/in-view', 'directives/observe-resize'],
},
{
type: 'category',
Expand Down