From a160a1e0fe7152dde336ed5a019a50a28a418ca2 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 {fromDimension} from '@scion/components/dimension'; const element: HTMLElement = ...; const dimension = fromDimension(element); ``` --- docs/site/scion-components.md | 2 +- docs/site/tools/dimension.md | 64 +++--- projects/scion/components/README.md | 4 +- .../dimension/src/dimension.directive.ts | 20 +- .../dimension/src/dimension.signal.ts | 50 +++++ .../dimension/src/dimension.spec.ts | 203 +++++++++++++++++- .../components/dimension/src/dimension.ts | 20 ++ .../components/dimension/src/public_api.ts | 4 +- 8 files changed, 314 insertions(+), 53 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..32545bd1 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 primitives for observing size changes 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..87658609 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 signal and directive for observing size changes 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 - ``` +Observes the size of the host element. 1. Import `SciDimensionDirective` in your component. - ```typescript + ```ts import {SciDimensionDirective} from '@scion/components/dimension'; @Component({ @@ -29,40 +32,41 @@ 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 the `sciDimension` directive to the HTML element to observe. ```html
``` -1. Add the following method to the component: - ```typescript - public onDimensionChange(dimension: Dimension): void { +1. Add method to be notified about size changes. + ```ts + public onDimensionChange(dimension: SciDimension): void { console.log(dimension); } ``` + +The directive can be configured with `emitOutsideAngular` set to `true` to emit 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 + +Creates a signal to observe the size of an element. + +```ts +import {fromDimension} from '@scion/components/dimension'; + +const element: HTMLElement = ...; +const dimension = fromDimension(element); +``` + +The signal subscribes to the native [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) to monitor element size changes. The subscription is automatically disposed of when the current context is destroyed. + +**Notes:** +- The function must be called within an injection context or with a `DestroyRef` passed for automatic unsubscription. +- The function must NOT be called within a reactive context to avoid creating a new subscription each time it is called. +
[menu-home]: /README.md diff --git a/projects/scion/components/README.md b/projects/scion/components/README.md index 3835ea51..18c9c669 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 primitives for observing size changes 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..be1dc018 --- /dev/null +++ b/projects/scion/components/dimension/src/dimension.signal.ts @@ -0,0 +1,50 @@ +/* + * 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, DestroyRef, ElementRef, inject, signal, Signal} from '@angular/core'; +import {coerceElement} from '@angular/cdk/coercion'; +import {SciDimension} from './dimension'; + +/** + * Creates a signal to observe the size of an element. + * + * The signal subscribes to the native {@link ResizeObserver} to monitor element size changes. + * The subscription is automatically disposed of when the current context is destroyed. + * + * The function: + * - must be called within an injection context or with a `DestroyRef` passed for automatic unsubscription. + * - must NOT be called within a reactive context to avoid repeated subscriptions to the native {@link ResizeObserver}. + */ +export function fromDimension(elementRef: HTMLElement | ElementRef, options?: {destroyRef?: DestroyRef}): Signal { + assertNotInReactiveContext(fromDimension, 'Invoking `fromDimension` causes new subscriptions every time. Move `fromDimension` outside of the reactive context and read the signal value where needed.'); + if (!options?.destroyRef) { + assertInInjectionContext(fromDimension); + } + + const element = coerceElement(elementRef); + const dimension = signal(getDimension(element)); + // Update signal in animation frame to not block the resize callback (ResizeObserver loop completed with undelivered notifications). + const resizeObserver = new ResizeObserver(() => requestAnimationFrame(() => dimension.set(getDimension(element)))); + resizeObserver.observe(element); + + const destroyRef = options?.destroyRef ?? inject(DestroyRef); + destroyRef.onDestroy(() => resizeObserver.disconnect()); + return dimension; +} + +function getDimension(element: HTMLElement): SciDimension { + return { + clientWidth: element.clientWidth, + offsetWidth: element.offsetWidth, + clientHeight: element.clientHeight, + offsetHeight: element.offsetHeight, + element: element, + }; +} diff --git a/projects/scion/components/dimension/src/dimension.spec.ts b/projects/scion/components/dimension/src/dimension.spec.ts index 5c1fe5ea..59b4735c 100644 --- a/projects/scion/components/dimension/src/dimension.spec.ts +++ b/projects/scion/components/dimension/src/dimension.spec.ts @@ -9,10 +9,13 @@ */ 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, DestroyRef, ElementRef, EnvironmentInjector, NgZone, runInInjectionContext, Signal, viewChild} from '@angular/core'; +import {SciDimensionDirective} from './dimension.directive'; +import {SciDimension} from './dimension'; +import {fromDimension} from './dimension.signal'; +import {firstValueFrom, mergeMap, NEVER, race, skip, Subject, throwError, timer} from 'rxjs'; import {ObserveCaptor} from '@scion/toolkit/testing'; +import {toObservable} from '@angular/core/rxjs-interop'; describe('Dimension Directive', () => { @@ -158,3 +161,197 @@ 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'); + + public setSize(size: {width: number; height: number}): void { + this.testee().nativeElement.style.width = `${size.width}px`; + this.testee().nativeElement.style.height = `${size.height}px`; + } + } + + const fixture = TestBed.createComponent(TestComponent); + const dimension = fromDimension(fixture.componentInstance.testee(), {destroyRef: TestBed.inject(DestroyRef)}); + + // Expect initial size emission. + expect(dimension()).toEqual({ + clientWidth: 300, + offsetWidth: 302, + clientHeight: 150, + offsetHeight: 152, + element: fixture.componentInstance.testee().nativeElement, + }); + + // Change size. + fixture.componentInstance.setSize({width: 200, height: 100}); + + // Expect changed size. + await waitForSignalChange(dimension); + expect(dimension()).toEqual({ + clientWidth: 200, + offsetWidth: 202, + clientHeight: 100, + offsetHeight: 102, + element: fixture.componentInstance.testee().nativeElement, + }); + }); + + it('should disconnect when `DestroyRef` 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'); + + public setSize(size: {width: number; height: number}): void { + this.testee().nativeElement.style.width = `${size.width}px`; + this.testee().nativeElement.style.height = `${size.height}px`; + } + } + + const injector = createEnvironmentInjector([], TestBed.inject(EnvironmentInjector)); + const fixture = TestBed.createComponent(TestComponent); + const dimension = fromDimension(fixture.componentInstance.testee(), {destroyRef: injector.get(DestroyRef)}); + + // Expect initial size emission. + expect(dimension()).toEqual({ + clientWidth: 300, + offsetWidth: 302, + clientHeight: 150, + offsetHeight: 152, + element: fixture.componentInstance.testee().nativeElement, + }); + + // Destroy injector (and DestroyRef). + injector.destroy(); + + // Change size. + fixture.componentInstance.setSize({width: 200, height: 100}); + + // Expect signal to be disconnected. + await expectAsync(waitForSignalChange(dimension, {timeout: 500})).toBeRejected(); + }); + + it('should disconnect when 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'); + + public setSize(size: {width: number; height: number}): void { + this.testee().nativeElement.style.width = `${size.width}px`; + this.testee().nativeElement.style.height = `${size.height}px`; + } + } + + const injector = createEnvironmentInjector([], TestBed.inject(EnvironmentInjector)); + const fixture = TestBed.createComponent(TestComponent); + const dimension = runInInjectionContext(injector, () => fromDimension(fixture.componentInstance.testee())); + + // Expect initial size emission. + expect(dimension()).toEqual({ + clientWidth: 300, + offsetWidth: 302, + clientHeight: 150, + offsetHeight: 152, + element: fixture.componentInstance.testee().nativeElement, + }); + + // Destroy injector. + injector.destroy(); + + // Change size. + fixture.componentInstance.setSize({width: 200, height: 100}); + + // Expect signal to be disconnected. + await expectAsync(waitForSignalChange(dimension, {timeout: 500})).toBeRejected(); + }); + + it('should error if called from within a reactive context', () => { + const dimension = computed(() => fromDimension(document.body)); + expect(() => dimension()).toThrowError(/fromDimension\(\) cannot be called from within a reactive context/); + }); + + it('should error if not passing a `DestroyRef` and not calling it from an injection context', () => { + expect(() => fromDimension(document.body)()).toThrowError(/fromDimension\(\) 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 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(skip(1)); + await firstValueFrom(race([onError, onChange])).finally(() => injector.destroy()); +} 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..6a646309 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 {fromDimension} from './dimension.signal'; +export {SciDimension} from './dimension';