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..699e86a3 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,80 @@ 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 using the native [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver). Unsubscribes when the injection context is destroyed.
+
+
+**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.
+> - 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, in particular useful for observing view children as they 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..d879160d
--- /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, in particular useful for observing view children as they 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';