-
Notifications
You must be signed in to change notification settings - Fork 0
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
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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> | ||
<button (click)="toggleContent()">Toggle content</button> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. click handler naming -> onToggleContentClick(), There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 {} |
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? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are you maybe missing a word in this sentence ? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried to reword the paragraph a little bit. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
click handler naming -> onAppendTextClick(),
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
90d0b12