From 81a83649cffd4c8be1ef72250ad7b1fdd421025b Mon Sep 17 00:00:00 2001 From: danielwiehl Date: Wed, 9 Oct 2024 16:59:29 +0200 Subject: [PATCH] feat(components/dimension): provide signal to observe element size ```ts import {Component, effect, ElementRef, inject} from '@angular/core'; import {dimension} from '@scion/components/dimension'; @Component({...}) class YourComponent { private host = inject(ElementRef); private dimension = dimension(this.host); constructor() { effect(() => console.log('size', this.dimension())); } } ``` --- docs/site/scion-components.md | 2 +- docs/site/tools/dimension.md | 106 +++- projects/scion/components/README.md | 4 +- .../dimension/src/dimension.directive.ts | 20 +- .../dimension/src/dimension.signal.ts | 70 +++ .../dimension/src/dimension.spec.ts | 531 +++++++++++++++++- .../components/dimension/src/dimension.ts | 20 + .../components/dimension/src/public_api.ts | 4 +- 8 files changed, 691 insertions(+), 66 deletions(-) create mode 100644 projects/scion/components/dimension/src/dimension.signal.ts create mode 100644 projects/scion/components/dimension/src/dimension.ts diff --git a/docs/site/scion-components.md b/docs/site/scion-components.md index 5791839d..63f27c35 100644 --- a/docs/site/scion-components.md +++ b/docs/site/scion-components.md @@ -21,7 +21,7 @@ Provides Angular-based components and directives with a focus on SCION requireme You can choose between different presentations: `ellipsis`, `ripple`, `roller`, `spinner`. - [**Dimension**][link-tool-dimension]\ - Provides a directive for observing changes in the size of the host element. + Provides a set of tools for observing the size of an element. - [**SCION Design Tokens**][link-scion-design-tokens]\ SCION provides a set of design tokens to enable consistent design and theming of SCION components. diff --git a/docs/site/tools/dimension.md b/docs/site/tools/dimension.md index f3dd3d65..1406656b 100644 --- a/docs/site/tools/dimension.md +++ b/docs/site/tools/dimension.md @@ -5,19 +5,22 @@ ## [SCION Toolkit][menu-home] > [@scion/components][link-scion-components] > Dimension -The NPM sub-module `@scion/components/dimension` provides an Angular directive for observing the size of an HTML element. The directive emits the element's initial size, and then continuously emits when its size changes. It never completes. +The NPM sub-module `@scion/components/dimension` provides a set of tools for observing the size of an element. + +Install the NPM module `@scion/components` as following: + +``` +npm install @scion/components @scion/toolkit @angular/cdk +```
- Installation and Usage + Dimension Directive -1. Install `@scion/components` using the NPM command-line tool: - ``` - npm install @scion/components @scion/toolkit @angular/cdk - ``` +Directive to observe the size of an element in the HTML template. -1. Import `SciDimensionDirective` in your component. +1. Import `SciDimensionDirective`. - ```typescript + ```ts import {SciDimensionDirective} from '@scion/components/dimension'; @Component({ @@ -29,40 +32,81 @@ The NPM sub-module `@scion/components/dimension` provides an Angular directive f } ``` - Alternatively, import `SciDimensionModule` in the `NgModule` that declares your component. - - ```typescript - import {SciDimensionModule} from '@scion/components/dimension'; - - @NgModule({ - imports: [SciDimensionModule] - }) - export class AppModule { - } - ``` - -1. Add the `sciDimension` directive to the HTML element for which you want to observe its size: +1. Add `sciDimension` directive to an element in the template. ```html
``` -1. Add the following method to the component: - ```typescript - public onDimensionChange(dimension: Dimension): void { +1. Add method to be notified about size changes of the element. + ```ts + public onDimensionChange(dimension: SciDimension): void { console.log(dimension); } ``` + +The directive can be configured with `emitOutsideAngular` to control whether to emit inside or outside the Angular zone. Defaults to `false`. +
- Control if to emit outside of the Angular zone - -You can control if to emit a dimension change inside or outside of the Angular zone by passing a `boolean` value to the input parameter `emitOutsideAngular`. If emitting outside of the Angular zone, the directive does not trigger an Angular change detection cycle. By default, dimension changes are emitted inside of the Angular zone. - - ```html -
- ``` + Dimension Signal + +Signal to observe the size of an element. + +The signal uses to the native [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) to monitor element size changes. Destroying the injection context will unsubscribe from {@link ResizeObserver}. + + +**Usage:** +```ts +import {dimension} from '@scion/components/dimension'; + +const element: HTMLElement = ...; +const size = dimension(element); + +console.log('size', size()); +``` + +> The function must be called within an injection context or with an injector provided for automatic unsubscription. +> The function must NOT be called within a reactive context to avoid repeated subscriptions. + +**Example of observing the size of a component:** + +```ts +import {Component, effect, ElementRef, inject} from '@angular/core'; +import {dimension} from '@scion/components/dimension'; + +@Component({...}) +class YourComponent { + + private host = inject(ElementRef); + private dimension = dimension(this.host); + + constructor() { + effect(() => console.log('size', this.dimension())); + } +} +``` + +**Example of observing the size of a view child:** + +The element can be passed as a signal, useful for observing a view child as view children cannot be read in the constructor. + +```ts +import {Component, effect, ElementRef, viewChild} from '@angular/core'; +import {dimension} from '@scion/components/dimension'; + +@Component({...}) +class YourComponent { + private viewChild = viewChild>('view_child'); + private dimension = dimension(this.viewChild); + + constructor() { + effect(() => console.log('size', this.dimension())); + } +} +``` +
[menu-home]: /README.md diff --git a/projects/scion/components/README.md b/projects/scion/components/README.md index 3835ea51..d87f86e2 100644 --- a/projects/scion/components/README.md +++ b/projects/scion/components/README.md @@ -9,7 +9,7 @@ The SCION Components is a collection of Angular components and directives primar Provides a viewport component with scrollbars that sit on top of the viewport client. - [**Dimension**][link-tool-dimension]\ - Provides a directive for observing changes in the size of the host element. + Provides a set of tools for observing the size of an element. - [**Sashbox**][link-tool-sashbox]\ Provides a sashbox component for splitting content into multiple parts, which the user can resize by moving the splitter between the parts. @@ -37,4 +37,4 @@ License: EPL-2.0 [link-tool-dimension]: https://github.com/SchweizerischeBundesbahnen/scion-toolkit/blob/master/docs/site/tools/dimension.md [link-tool-sashbox]: https://github.com/SchweizerischeBundesbahnen/scion-toolkit/blob/master/docs/site/tools/sashbox.md [link-tool-splitter]: https://github.com/SchweizerischeBundesbahnen/scion-toolkit/blob/master/docs/site/tools/splitter.md -[link-tool-throbber]: https://github.com/SchweizerischeBundesbahnen/scion-toolkit/blob/master/docs/site/tools/throbber.md \ No newline at end of file +[link-tool-throbber]: https://github.com/SchweizerischeBundesbahnen/scion-toolkit/blob/master/docs/site/tools/throbber.md diff --git a/projects/scion/components/dimension/src/dimension.directive.ts b/projects/scion/components/dimension/src/dimension.directive.ts index f457adf5..9674a414 100644 --- a/projects/scion/components/dimension/src/dimension.directive.ts +++ b/projects/scion/components/dimension/src/dimension.directive.ts @@ -13,11 +13,10 @@ import {fromResize$} from '@scion/toolkit/observable'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {observeInside, subscribeInside} from '@scion/toolkit/operators'; import {animationFrameScheduler, observeOn, subscribeOn} from 'rxjs'; +import {SciDimension} from './dimension'; /** - * Observes changes to the size of the host element. - * - * Upon subscription, emits the current size, and then continuously when the size changes. The Observable never completes. + * Observes the size of the host element. * * --- * Usage: @@ -33,12 +32,12 @@ import {animationFrameScheduler, observeOn, subscribeOn} from 'rxjs'; export class SciDimensionDirective { /** - * Controls if to emit outside the Angular zone. Defaults to `false`. + * Controls if to output outside the Angular zone. Defaults to `false`. */ public emitOutsideAngular = input(false); /** - * Upon subscription, emits the current size, and then continuously when the size changes. The Observable never completes. + * Outputs the size of the element. */ public dimensionChange = output({alias: 'sciDimensionChange'}); @@ -65,14 +64,3 @@ export class SciDimensionDirective { }); } } - -/** - * Represents the dimension of an element. - */ -export interface SciDimension { - offsetWidth: number; - offsetHeight: number; - clientWidth: number; - clientHeight: number; - element: HTMLElement; -} diff --git a/projects/scion/components/dimension/src/dimension.signal.ts b/projects/scion/components/dimension/src/dimension.signal.ts new file mode 100644 index 00000000..86d0dab7 --- /dev/null +++ b/projects/scion/components/dimension/src/dimension.signal.ts @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2018-2024 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import {assertInInjectionContext, assertNotInReactiveContext, computed, DestroyRef, effect, ElementRef, inject, Injector, isSignal, signal, Signal} from '@angular/core'; +import {SciDimension} from './dimension'; +import {coerceElement} from '@angular/cdk/coercion'; +import {Objects} from '@scion/toolkit/util'; + +/** + * Creates a signal to observe the size of an element. + * + * The element can be passed as a signal, useful for observing a view child as view children cannot be read in the constructor. + * + * The signal uses to the native {@link ResizeObserver} to monitor element size changes. Destroying the injection context will unsubscribe from {@link ResizeObserver}. + * + * Usage: + * - Must be called within an injection context or with an injector provided for automatic unsubscription. + * - Must NOT be called within a reactive context to avoid repeated subscriptions. + */ +export function dimension(elementLike: HTMLElement | ElementRef | Signal>, options?: {injector?: Injector}): Signal; +export function dimension(elementLike: HTMLElement | ElementRef | Signal | undefined>, options?: {injector?: Injector}): Signal; +export function dimension(elementLike: HTMLElement | ElementRef | Signal | undefined>, options?: {injector?: Injector}): Signal { + assertNotInReactiveContext(dimension, 'Invoking `dimension` causes new subscriptions every time. Move `dimension` outside of the reactive context and read the signal value where needed.'); + if (!options?.injector) { + assertInInjectionContext(dimension); + } + + const injector = options?.injector ?? inject(Injector); + const element = computed(() => coerceElement(isSignal(elementLike) ? elementLike() : elementLike)); + const onResize = signal(undefined, {equal: () => false}); + + // Signal 'onResize' when the element size changes. + // Note: Run callback in animation frame to avoid the error: "ResizeObserver loop completed with undelivered notifications". + const resizeObserver = new ResizeObserver(() => requestAnimationFrame(() => onResize.set())); + injector.get(DestroyRef).onDestroy(() => resizeObserver.disconnect()); + + // Connnect to the element. + effect(onCleanup => { + const el = element(); + if (el) { + resizeObserver.observe(el); + onCleanup(() => resizeObserver.unobserve(el)); + } + }, {injector}); + + return computed(() => { + const el = element(); + if (!el) { + return undefined; + } + + // Track when the element is resized. + onResize(); + + return { + clientWidth: el.clientWidth, + offsetWidth: el.offsetWidth, + clientHeight: el.clientHeight, + offsetHeight: el.offsetHeight, + element: el, + }; + }, {equal: Objects.isEqual}); +} diff --git a/projects/scion/components/dimension/src/dimension.spec.ts b/projects/scion/components/dimension/src/dimension.spec.ts index 5c1fe5ea..6480bf3f 100644 --- a/projects/scion/components/dimension/src/dimension.spec.ts +++ b/projects/scion/components/dimension/src/dimension.spec.ts @@ -9,10 +9,14 @@ */ import {ComponentFixtureAutoDetect, TestBed} from '@angular/core/testing'; -import {Component, ElementRef, NgZone, viewChild} from '@angular/core'; -import {SciDimension, SciDimensionDirective} from './dimension.directive'; -import {Subject} from 'rxjs'; +import {Component, computed, createEnvironmentInjector, effect, ElementRef, EnvironmentInjector, Injector, NgZone, runInInjectionContext, signal, Signal, viewChild} from '@angular/core'; +import {SciDimensionDirective} from './dimension.directive'; +import {SciDimension} from './dimension'; +import {dimension} from './dimension.signal'; +import {firstValueFrom, mergeMap, NEVER, race, Subject, throwError, timer} from 'rxjs'; import {ObserveCaptor} from '@scion/toolkit/testing'; +import {toObservable} from '@angular/core/rxjs-interop'; +import {first} from 'rxjs/operators'; describe('Dimension Directive', () => { @@ -47,11 +51,6 @@ describe('Dimension Directive', () => { public testee = viewChild.required>('testee'); public emissions$ = new Subject(); - public setSize(size: {width: number; height: number}): void { - this.testee().nativeElement.style.width = `${size.width}px`; - this.testee().nativeElement.style.height = `${size.height}px`; - } - protected onDimensionChange(dimension: SciDimension): void { this.emissions$.next({...dimension, insideAngular: NgZone.isInAngularZone()}); } @@ -73,7 +72,7 @@ describe('Dimension Directive', () => { }); // Change size. - fixture.componentInstance.setSize({width: 200, height: 100}); + setSize(fixture.componentInstance.testee(), {width: 200, height: 100}); // Expect emission because size has changed. await captor.waitUntilEmitCount(2); @@ -118,11 +117,6 @@ describe('Dimension Directive', () => { public testee = viewChild.required>('testee'); public emissions$ = new Subject(); - public setSize(size: {width: number; height: number}): void { - this.testee().nativeElement.style.width = `${size.width}px`; - this.testee().nativeElement.style.height = `${size.height}px`; - } - protected onDimensionChange(dimension: SciDimension): void { this.emissions$.next({...dimension, insideAngular: NgZone.isInAngularZone()}); } @@ -144,7 +138,7 @@ describe('Dimension Directive', () => { }); // Change size. - fixture.componentInstance.setSize({width: 200, height: 100}); + setSize(fixture.componentInstance.testee(), {width: 200, height: 100}); // Expect emission because size has changed. await captor.waitUntilEmitCount(2); @@ -158,3 +152,510 @@ describe('Dimension Directive', () => { }); }); }); + +describe('Dimension Signal', () => { + + it('should detect size change', async () => { + TestBed.configureTestingModule({ + providers: [ + {provide: ComponentFixtureAutoDetect, useValue: true}, + ], + }); + + @Component({ + selector: 'spec-component', + template: ` +
+ `, + styles: ` + div.testee { + width: 300px; + height: 150px; + box-sizing: content-box; + border: 1px solid red; + background-color: blue; + } + `, + standalone: true, + }) + class TestComponent { + public testee = viewChild.required>('testee'); + } + + const fixture = TestBed.createComponent(TestComponent); + const size = TestBed.runInInjectionContext(() => dimension(fixture.componentInstance.testee())); + + // Expect initial size emission. + expect(size()).toEqual({ + clientWidth: 300, + offsetWidth: 302, + clientHeight: 150, + offsetHeight: 152, + element: fixture.componentInstance.testee().nativeElement, + }); + + // Change size. + setSize(fixture.componentInstance.testee(), {width: 200, height: 100}); + + // Expect changed size. + await waitForSignalChange(size); + expect(size()).toEqual(jasmine.objectContaining({ + clientWidth: 200, + clientHeight: 100, + element: fixture.componentInstance.testee().nativeElement, + })); + }); + + it('should observe element from signal', async () => { + TestBed.configureTestingModule({ + providers: [ + {provide: ComponentFixtureAutoDetect, useValue: true}, + ], + }); + + @Component({ + selector: 'spec-component', + template: ` +
+ `, + styles: ` + div.testee { + width: 300px; + height: 150px; + box-sizing: content-box; + border: 1px solid red; + background-color: blue; + } + `, + standalone: true, + }) + class TestComponent { + public testee = viewChild.required>('testee'); + } + + const fixture = TestBed.createComponent(TestComponent); + const size = TestBed.runInInjectionContext(() => dimension(fixture.componentInstance.testee)); + + // Expect size. + expect(size()).toEqual(jasmine.objectContaining({ + clientWidth: 300, + clientHeight: 150, + element: fixture.componentInstance.testee().nativeElement, + })); + + // Change size. + setSize(fixture.componentInstance.testee(), {width: 350, height: 250}); + await waitForSignalChange(size); + expect(size()).toEqual(jasmine.objectContaining({ + clientWidth: 350, + clientHeight: 250, + })); + }); + + it('should observe required view child in component constructor', async () => { + TestBed.configureTestingModule({ + providers: [ + {provide: ComponentFixtureAutoDetect, useValue: true}, + ], + }); + + @Component({ + selector: 'spec-component', + template: ` +
+ `, + styles: ` + div.testee { + width: 300px; + height: 150px; + box-sizing: content-box; + border: 1px solid red; + background-color: blue; + } + `, + standalone: true, + }) + class TestComponent { + public testee = viewChild.required>('testee'); + public size = dimension(this.testee); + } + + const fixture = TestBed.createComponent(TestComponent); + const size = fixture.componentInstance.size; + + // Expect typing that dimension is not `undefined` for required view child. + expect(size().element).toBe(fixture.componentInstance.testee().nativeElement); + + // Expect size. + expect(size()).toEqual(jasmine.objectContaining({ + clientWidth: 300, + clientHeight: 150, + element: fixture.componentInstance.testee().nativeElement, + })); + + // Change size. + setSize(fixture.componentInstance.testee(), {width: 350, height: 250}); + await waitForSignalChange(size); + expect(size()).toEqual(jasmine.objectContaining({ + clientWidth: 350, + clientHeight: 250, + })); + }); + + it('should observe optional view child in component constructor', async () => { + TestBed.configureTestingModule({ + providers: [ + {provide: ComponentFixtureAutoDetect, useValue: true}, + ], + }); + + @Component({ + selector: 'spec-component', + template: ` + @if (observeViewChild === 'testee-1') { +
+ } + @if (observeViewChild === 'testee-2') { +
+ } + `, + styles: ` + div.testee-1 { + width: 300px; + height: 150px; + box-sizing: content-box; + border: 1px solid red; + background-color: blue; + } + + div.testee-2 { + width: 200px; + height: 50px; + box-sizing: content-box; + border: 1px solid red; + background-color: green; + } + `, + standalone: true, + }) + class TestComponent { + public observeViewChild: 'testee-1' | 'testee-2' | 'none' = 'none'; + public testee = viewChild>('testee'); + public size = dimension(this.testee); + + public testee1 = viewChild>('testee_1'); + public testee2 = viewChild>('testee_2'); + } + + const fixture = TestBed.createComponent(TestComponent); + const size = fixture.componentInstance.size; + + // Capture emissions. + const emissions = new Array; + effect(() => emissions.push(size()), {injector: TestBed.inject(Injector)}); + + // Expect null dimension. + expect(size()).toBeUndefined(); + + // Observe testee 1. + fixture.componentInstance.observeViewChild = 'testee-1'; + await waitForSignalChange(fixture.componentInstance.size); + + // Expect size. + expect(size()).toEqual(jasmine.objectContaining({ + clientWidth: 300, + clientHeight: 150, + element: fixture.componentInstance.testee1()!.nativeElement, + })); + + // Resize testee 1. + setSize(fixture.componentInstance.testee1()!, {width: 350, height: 250}); + await waitForSignalChange(fixture.componentInstance.size); + expect(size()).toEqual(jasmine.objectContaining({ + clientWidth: 350, + clientHeight: 250, + element: fixture.componentInstance.testee1()!.nativeElement, + })); + + // Observe testee 2. + fixture.componentInstance.observeViewChild = 'testee-2'; + await waitForSignalChange(fixture.componentInstance.size); + + // Expect size. + expect(size()).toEqual(jasmine.objectContaining({ + clientWidth: 200, + clientHeight: 50, + element: fixture.componentInstance.testee2()!.nativeElement, + })); + + // Resize testee 2. + setSize(fixture.componentInstance.testee2()!, {width: 275, height: 75}); + await waitForSignalChange(size); + expect(size()).toEqual(jasmine.objectContaining({ + clientWidth: 275, + clientHeight: 75, + element: fixture.componentInstance.testee2()!.nativeElement, + })); + + // Unobserve view child. + fixture.componentInstance.observeViewChild = 'none'; + await waitForSignalChange(size); + + // Expect dimension to be undefined. + expect(size()).toBeUndefined(); + + // Assert captured emissions. + expect(emissions).toEqual([ + undefined, + jasmine.objectContaining({ + clientWidth: 300, + clientHeight: 150, + }), + jasmine.objectContaining({ + clientWidth: 350, + clientHeight: 250, + }), + jasmine.objectContaining({ + clientWidth: 200, + clientHeight: 50, + }), + jasmine.objectContaining({ + clientWidth: 275, + clientHeight: 75, + }), + undefined, + ]); + }); + + it('should error if reading signal for required view child in constructor', async () => { + TestBed.configureTestingModule({ + providers: [ + {provide: ComponentFixtureAutoDetect, useValue: true}, + ], + }); + + @Component({ + selector: 'spec-component', + template: ` +
+ `, + standalone: true, + }) + class TestComponent { + + public testee = viewChild.required>('testee'); + public dimension = dimension(this.testee); + + constructor() { + this.dimension(); + } + } + + expect(() => TestBed.createComponent(TestComponent)).toThrowError(/NG0951: Child query result is required but no value is available/); + }); + + it('should disconnect when the passed injection context is destroyed', async () => { + TestBed.configureTestingModule({ + providers: [ + {provide: ComponentFixtureAutoDetect, useValue: true}, + ], + }); + + @Component({ + selector: 'spec-component', + template: ` +
+ `, + styles: ` + div.testee { + width: 300px; + height: 150px; + box-sizing: content-box; + border: 1px solid red; + background-color: blue; + } + `, + standalone: true, + }) + class TestComponent { + public testee = viewChild.required>('testee'); + } + + const injector = createEnvironmentInjector([], TestBed.inject(EnvironmentInjector)); + const fixture = TestBed.createComponent(TestComponent); + const size = dimension(fixture.componentInstance.testee(), {injector}); + + // Expect initial size emission. + expect(size()).toEqual(jasmine.objectContaining({ + clientWidth: 300, + clientHeight: 150, + })); + + // Destroy injector. + injector.destroy(); + + // Change size. + setSize(fixture.componentInstance.testee(), {width: 200, height: 100}); + + // Expect signal to be disconnected. + await expectAsync(waitForSignalChange(size, {timeout: 500})).toBeRejected(); + }); + + it('should disconnect when the current injection context is destroyed', async () => { + TestBed.configureTestingModule({ + providers: [ + {provide: ComponentFixtureAutoDetect, useValue: true}, + ], + }); + + @Component({ + selector: 'spec-component', + template: ` +
+ `, + styles: ` + div.testee { + width: 300px; + height: 150px; + box-sizing: content-box; + border: 1px solid red; + background-color: blue; + } + `, + standalone: true, + }) + class TestComponent { + public testee = viewChild.required>('testee'); + } + + const injector = createEnvironmentInjector([], TestBed.inject(EnvironmentInjector)); + const fixture = TestBed.createComponent(TestComponent); + const size = runInInjectionContext(injector, () => dimension(fixture.componentInstance.testee())); + + // Expect initial size emission. + expect(size()).toEqual(jasmine.objectContaining({ + clientWidth: 300, + clientHeight: 150, + })); + + // Destroy injector. + injector.destroy(); + + // Change size. + setSize(fixture.componentInstance.testee(), {width: 200, height: 100}); + + // Expect signal to be disconnected. + await expectAsync(waitForSignalChange(size, {timeout: 500})).toBeRejected(); + }); + + it('should disconnect from previous element', async () => { + TestBed.configureTestingModule({ + providers: [ + {provide: ComponentFixtureAutoDetect, useValue: true}, + ], + }); + + @Component({ + selector: 'spec-component', + template: ` +
+
+ `, + styles: ` + div.testee-1 { + width: 300px; + height: 150px; + box-sizing: content-box; + border: 1px solid red; + background-color: blue; + } + + div.testee-2 { + width: 200px; + height: 50px; + box-sizing: content-box; + border: 1px solid green; + background-color: black; + } + `, + standalone: true, + }) + class TestComponent { + public testee1 = viewChild.required>('testee_1'); + public testee2 = viewChild.required>('testee_2'); + } + + const fixture = TestBed.createComponent(TestComponent); + const element = signal | undefined>(undefined); + const size = TestBed.runInInjectionContext(() => dimension(element)); + + // Observe testee 1. + element.set(fixture.componentInstance.testee1()); + + // Expect size. + expect(size()).toEqual(jasmine.objectContaining({ + clientWidth: 300, + clientHeight: 150, + element: fixture.componentInstance.testee1().nativeElement, + })); + + // Resize testee 1. + setSize(fixture.componentInstance.testee1(), {width: 350, height: 250}); + await waitForSignalChange(size); + expect(size()).toEqual(jasmine.objectContaining({ + clientWidth: 350, + clientHeight: 250, + element: fixture.componentInstance.testee1().nativeElement, + })); + + // Change element to testee 2. + element.set(fixture.componentInstance.testee2()); + + // Expect size. + expect(size()).toEqual(jasmine.objectContaining({ + clientWidth: 200, + clientHeight: 50, + element: fixture.componentInstance.testee2().nativeElement, + })); + + // Resize testee 2. + setSize(fixture.componentInstance.testee2(), {width: 275, height: 75}); + await waitForSignalChange(size); + expect(size()).toEqual(jasmine.objectContaining({ + clientWidth: 275, + clientHeight: 75, + element: fixture.componentInstance.testee2().nativeElement, + })); + + // Resize testee 1. + setSize(fixture.componentInstance.testee1(), {width: 350, height: 250}); + // Expect testee 1 to be disconnected. + await expectAsync(waitForSignalChange(size, {timeout: 500})).toBeRejected(); + }); + + it('should error if called from within a reactive context', () => { + const size = computed(() => dimension(document.body)); + expect(() => size()).toThrowError(/dimension\(\) cannot be called from within a reactive context/); + }); + + it('should error if not passing an `Injector` and not calling it from an injection context', () => { + expect(() => dimension(document.body)()).toThrowError(/dimension\(\) can only be used within an injection context/); + }); +}); + +/** + * Waits for the signal to change its value. + */ +async function waitForSignalChange(signal: Signal, options?: {timeout?: number}): Promise { + const signalValue = signal(); + const injector = createEnvironmentInjector([], TestBed.inject(EnvironmentInjector)); + const timeout = options?.timeout; + const onError = timeout ? timer(timeout).pipe(mergeMap(() => throwError(() => `Timeout ${timeout}ms elapsed.`))) : NEVER; + const onChange = toObservable(signal, {injector}).pipe(first(value => value !== signalValue)); + await firstValueFrom(race([onError, onChange])).finally(() => injector.destroy()); +} + +function setSize(element: ElementRef, size: {width: number; height: number}): void { + element.nativeElement.style.width = `${size.width}px`; + element.nativeElement.style.height = `${size.height}px`; +} diff --git a/projects/scion/components/dimension/src/dimension.ts b/projects/scion/components/dimension/src/dimension.ts new file mode 100644 index 00000000..f0216754 --- /dev/null +++ b/projects/scion/components/dimension/src/dimension.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2018-2024 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +/** + * Represents the dimension of an element. + */ +export interface SciDimension { + offsetWidth: number; + offsetHeight: number; + clientWidth: number; + clientHeight: number; + element: HTMLElement; +} diff --git a/projects/scion/components/dimension/src/public_api.ts b/projects/scion/components/dimension/src/public_api.ts index ef86c3e2..61733681 100644 --- a/projects/scion/components/dimension/src/public_api.ts +++ b/projects/scion/components/dimension/src/public_api.ts @@ -14,4 +14,6 @@ * @see https://github.com/ng-packagr/ng-packagr/blob/master/docs/secondary-entrypoints.md */ export {SciDimensionModule} from './dimension.module'; -export {SciDimensionDirective, SciDimension} from './dimension.directive'; +export {SciDimensionDirective} from './dimension.directive'; +export {dimension} from './dimension.signal'; +export {SciDimension} from './dimension';