diff --git a/docs/site/scion-components.md b/docs/site/scion-components.md
index 63f27c35..3a5a6c81 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 set of tools for observing the size of an element.
+ Provides a set of tools for observing the size and position 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 562fe45e..3ca06ff0 100644
--- a/docs/site/tools/dimension.md
+++ b/docs/site/tools/dimension.md
@@ -5,7 +5,7 @@
## [SCION Toolkit][menu-home] > [@scion/components][link-scion-components] > Dimension
-The NPM sub-module `@scion/components/dimension` provides a set of tools for observing the size of an element.
+The NPM sub-module `@scion/components/dimension` provides a set of tools for observing the size and position of an element.
Install the NPM module `@scion/components` as following:
@@ -50,11 +50,11 @@ The directive can be configured with `emitOutsideAngular` to control whether to
- Dimension Signal
+ 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 `ResizeObserver`.
+The signal subscribes 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 `ResizeObserver`.
```ts
@@ -63,13 +63,15 @@ import {dimension} from '@scion/components/dimension';
const element: HTMLElement = ...;
const size = dimension(element);
-console.log('size', size());
+console.log(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.
+***
+> - The function must be called within an injection context or an injector provided. Destroying the injector will unsubscribe the signal.
+> - The function must NOT be called within a reactive context to avoid repeated subscriptions.
+***
-**Example of observing the size of a component:**
+**Example of observing the size of the component:**
```ts
import {Component, effect, ElementRef, inject} from '@angular/core';
@@ -82,14 +84,14 @@ class YourComponent {
private dimension = dimension(this.host);
constructor() {
- effect(() => console.log('size', this.dimension()));
+ effect(() => console.log(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.
+The element can be passed as a signal, enabling observation of view children in the component constructor.
```ts
import {Component, effect, ElementRef, viewChild} from '@angular/core';
@@ -101,13 +103,80 @@ class YourComponent {
private dimension = dimension(this.viewChild);
constructor() {
- effect(() => console.log('size', this.dimension()));
+ effect(() => console.log(this.dimension()));
}
}
```
+
+ BoundingClientRect Signal
+
+Signal to observe the bounding box of an element.
+
+The [bounding box](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) includes the element's position relative to the top-left of the viewport and its size.
+
+
+```ts
+import {boundingClientRect} from '@scion/components/dimension';
+
+const element: HTMLElement = ...;
+const boundingBox = boundingClientRect(element);
+
+console.log(boundingBox());
+```
+
+***
+> - The function must be called within an injection context or an injector provided. Destroying the injector will unsubscribe the signal.
+> - The function must not be called within a reactive context to avoid repeated subscriptions.
+> - The element and the document root (``) must be positioned `relative` or `absolute`. If not, a warning is logged, and positioning changed to `relative`.
+***
+
+*Note:*
+There is no native browser API to observe the position of an element. The observable uses [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) and [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) to detect position changes. For tracking only size changes, use [`Dimension Signal`](#dimension) signal instead.
+
+
+**Example of observing the bounding box of the component:**
+
+```ts
+import {Component, effect, ElementRef, inject} from '@angular/core';
+import {boundingClientRect} from '@scion/components/dimension';
+
+@Component({...})
+class YourComponent {
+
+ private host = inject(ElementRef);
+ private boundingBox = boundingClientRect(this.host);
+
+ constructor() {
+ effect(() => console.log(this.boundingBox()));
+ }
+}
+```
+
+**Example of observing the bounding box of a view child:**
+
+The element can be passed as a signal, enabling observation of view children in the component constructor.
+
+```ts
+import {Component, effect, ElementRef, viewChild} from '@angular/core';
+import {boundingClientRect} from '@scion/components/dimension';
+
+@Component({...})
+class YourComponent {
+ private viewChild = viewChild>('view_child');
+ private boundingBox = boundingClientRect(this.viewChild);
+
+ constructor() {
+ effect(() => console.log(this.boundingBox()));
+ }
+}
+```
+
+
+
+
[menu-home]: /README.md
[menu-projects-overview]: /docs/site/projects-overview.md
[menu-changelog]: /docs/site/changelog.md
diff --git a/docs/site/tools/observable.md b/docs/site/tools/observable.md
index 8598aeca..71a672f3 100644
--- a/docs/site/tools/observable.md
+++ b/docs/site/tools/observable.md
@@ -85,13 +85,11 @@ fromBoundingClientRect$(element).subscribe((clientRect: DOMRect) => {
```
***
-The target element and the document root (``) must be positioned `relative` or `absolute`. If not, a warning is logged and positioning is changed to `relative`.
+The element and the document root (``) must be positioned `relative` or `absolute`. If not, a warning is logged, and positioning changed to `relative`.
***
*Note:*
-As of 2024, there is no native browser API to observe the position of an element. This implementation uses
-[`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) and [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) to detect position changes.
-For tracking only size changes, use `fromResize$` instead.
+There is no native browser API to observe the position of an element. The observable uses [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) and [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) to detect position changes. For tracking only size changes, use `fromResize$` instead.
[menu-home]: /README.md
diff --git a/projects/scion/components/README.md b/projects/scion/components/README.md
index d87f86e2..8f1bdcb9 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 set of tools for observing the size of an element.
+ Provides a set of tools for observing the size and position 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.
diff --git a/projects/scion/components/dimension/src/bounding-client-rect.signal.spec.ts b/projects/scion/components/dimension/src/bounding-client-rect.signal.spec.ts
new file mode 100644
index 00000000..d24c9639
--- /dev/null
+++ b/projects/scion/components/dimension/src/bounding-client-rect.signal.spec.ts
@@ -0,0 +1,561 @@
+/*
+ * 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 {ComponentFixtureAutoDetect, TestBed} from '@angular/core/testing';
+import {Component, computed, createEnvironmentInjector, effect, ElementRef, EnvironmentInjector, Injector, runInInjectionContext, signal, Signal, viewChild} from '@angular/core';
+import {firstValueFrom, mergeMap, NEVER, race, throwError, timer} from 'rxjs';
+import {toObservable} from '@angular/core/rxjs-interop';
+import {first} from 'rxjs/operators';
+import {boundingClientRect} from '@scion/components/dimension';
+
+describe('Bounding Client Rect Signal', () => {
+
+ it('should detect bounding client rect change', async () => {
+ TestBed.configureTestingModule({
+ providers: [
+ {provide: ComponentFixtureAutoDetect, useValue: true},
+ ],
+ });
+
+ @Component({
+ selector: 'spec-component',
+ template: `
+
+ `,
+ styles: `
+ div.testee {
+ position: relative;
+ width: 300px;
+ height: 150px;
+ box-sizing: border-box;
+ border: 1px solid red;
+ background-color: blue;
+ }
+ `,
+ standalone: true,
+ })
+ class TestComponent {
+ public testee = viewChild.required>('testee');
+ }
+
+ const fixture = TestBed.createComponent(TestComponent);
+ const boundingBox = TestBed.runInInjectionContext(() => boundingClientRect(fixture.componentInstance.testee()));
+ const {x, y} = boundingBox();
+
+ // Expect initial bounding box.
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 300,
+ height: 150,
+ x,
+ y,
+ }));
+
+ // Change size.
+ setSize(fixture.componentInstance.testee(), {width: 200, height: 100});
+ // Change position.
+ setTransform(fixture.componentInstance.testee(), {x: 10, y: 10});
+
+ // Expect changed size.
+ await waitForSignalChange(boundingBox);
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 200,
+ height: 100,
+ x: x + 10,
+ y: y + 10,
+ }));
+ });
+
+ it('should observe element from signal', async () => {
+ TestBed.configureTestingModule({
+ providers: [
+ {provide: ComponentFixtureAutoDetect, useValue: true},
+ ],
+ });
+
+ @Component({
+ selector: 'spec-component',
+ template: `
+
+ `,
+ styles: `
+ div.testee {
+ position: relative;
+ width: 300px;
+ height: 150px;
+ box-sizing: border-box;
+ border: 1px solid red;
+ background-color: blue;
+ }
+ `,
+ standalone: true,
+ })
+ class TestComponent {
+ public testee = viewChild.required>('testee');
+ }
+
+ const fixture = TestBed.createComponent(TestComponent);
+ const boundingBox = TestBed.runInInjectionContext(() => boundingClientRect(fixture.componentInstance.testee));
+ const {x, y} = boundingBox();
+
+ // Expect initial bounding box.
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 300,
+ height: 150,
+ x,
+ y,
+ }));
+
+ // Change size.
+ setSize(fixture.componentInstance.testee(), {width: 200, height: 100});
+ // Change position.
+ setTransform(fixture.componentInstance.testee(), {x: 10, y: 10});
+
+ await waitForSignalChange(boundingBox);
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 200,
+ height: 100,
+ x: x + 10,
+ y: y + 10,
+ }));
+ });
+
+ 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 {
+ position: relative;
+ width: 300px;
+ height: 150px;
+ box-sizing: border-box;
+ border: 1px solid red;
+ background-color: blue;
+ }
+ `,
+ standalone: true,
+ })
+ class TestComponent {
+ public testee = viewChild.required>('testee');
+ public boundingBox = boundingClientRect(this.testee);
+ }
+
+ const fixture = TestBed.createComponent(TestComponent);
+ const boundingBox = fixture.componentInstance.boundingBox;
+ const {x, y} = boundingBox();
+
+ // Expect typing that bounding box is not `undefined` for required view child.
+ expect(boundingBox().height).toEqual(150);
+
+ // Expect size.
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 300,
+ height: 150,
+ x,
+ y,
+ }));
+
+ // Change size.
+ setSize(fixture.componentInstance.testee(), {width: 200, height: 100});
+ // Change position.
+ setTransform(fixture.componentInstance.testee(), {x: 10, y: 10});
+
+ await waitForSignalChange(boundingBox);
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 200,
+ height: 100,
+ x: x + 10,
+ y: y + 10,
+ }));
+ });
+
+ 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 {
+ position: relative;
+ width: 300px;
+ height: 150px;
+ box-sizing: border-box;
+ border: 1px solid red;
+ background-color: blue;
+ }
+
+ div.testee-2 {
+ position: relative;
+ width: 200px;
+ height: 50px;
+ box-sizing: border-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 boundingBox = boundingClientRect(this.testee);
+
+ public testee1 = viewChild>('testee_1');
+ public testee2 = viewChild>('testee_2');
+ }
+
+ const fixture = TestBed.createComponent(TestComponent);
+ const boundingBox = fixture.componentInstance.boundingBox;
+
+ // Capture emissions.
+ const emissions = new Array;
+ effect(() => emissions.push(boundingBox()), {injector: TestBed.inject(Injector)});
+
+ // Expect null bounding box.
+ expect(boundingBox()).toBeUndefined();
+
+ // Observe testee 1.
+ fixture.componentInstance.observeViewChild = 'testee-1';
+ await waitForSignalChange(fixture.componentInstance.boundingBox);
+
+ // Expect bounding box.
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 300,
+ height: 150,
+ }));
+
+ // Resize testee 1.
+ setSize(fixture.componentInstance.testee1()!, {width: 350, height: 250});
+ await waitForSignalChange(fixture.componentInstance.boundingBox);
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 350,
+ height: 250,
+ }));
+
+ // Observe testee 2.
+ fixture.componentInstance.observeViewChild = 'testee-2';
+ await waitForSignalChange(fixture.componentInstance.boundingBox);
+
+ // Expect bounding box.
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 200,
+ height: 50,
+ }));
+
+ // Resize testee 2.
+ setSize(fixture.componentInstance.testee2()!, {width: 275, height: 75});
+ await waitForSignalChange(boundingBox);
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 275,
+ height: 75,
+ }));
+
+ // Unobserve view child.
+ fixture.componentInstance.observeViewChild = 'none';
+ await waitForSignalChange(boundingBox);
+
+ // Expect bounding box to be undefined.
+ expect(boundingBox()).toBeUndefined();
+
+ // Assert captured emissions.
+ expect(emissions).toEqual([
+ undefined,
+ jasmine.objectContaining({
+ width: 300,
+ height: 150,
+ }),
+ jasmine.objectContaining({
+ width: 350,
+ height: 250,
+ }),
+ jasmine.objectContaining({
+ width: 200,
+ height: 50,
+ }),
+ jasmine.objectContaining({
+ width: 275,
+ height: 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 boundingBox = boundingClientRect(this.testee);
+
+ constructor() {
+ this.boundingBox();
+ }
+ }
+
+ 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 {
+ position: relative;
+ width: 300px;
+ height: 150px;
+ box-sizing: border-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 boundingBox = boundingClientRect(fixture.componentInstance.testee(), {injector});
+
+ // Expect initial bounding box.
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 300,
+ height: 150,
+ }));
+
+ // Change size.
+ setSize(fixture.componentInstance.testee(), {width: 301, height: 151});
+ await waitForSignalChange(boundingBox);
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 301,
+ height: 151,
+ }));
+
+ // Destroy injector.
+ injector.destroy();
+
+ // Change size.
+ setSize(fixture.componentInstance.testee(), {width: 302, height: 152});
+
+ // Expect signal to be disconnected.
+ await expectAsync(waitForSignalChange(boundingBox, {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 {
+ position: relative;
+ width: 300px;
+ height: 150px;
+ box-sizing: border-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 boundingBox = runInInjectionContext(injector, () => boundingClientRect(fixture.componentInstance.testee()));
+
+ // Expect initial bounding box.
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 300,
+ height: 150,
+ }));
+
+ // Change size.
+ setSize(fixture.componentInstance.testee(), {width: 301, height: 151});
+ await waitForSignalChange(boundingBox);
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 301,
+ height: 151,
+ }));
+
+ // Destroy injector.
+ injector.destroy();
+
+ // Change size.
+ setSize(fixture.componentInstance.testee(), {width: 302, height: 152});
+
+ // Expect signal to be disconnected.
+ await expectAsync(waitForSignalChange(boundingBox, {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 {
+ position: relative;
+ width: 300px;
+ height: 150px;
+ box-sizing: border-box;
+ border: 1px solid red;
+ background-color: blue;
+ }
+
+ div.testee-2 {
+ position: relative;
+ width: 200px;
+ height: 50px;
+ box-sizing: border-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 boundingBox = TestBed.runInInjectionContext(() => boundingClientRect(element));
+
+ // Observe testee 2.
+ element.set(fixture.componentInstance.testee2());
+
+ // Expect bounding box.
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 200,
+ height: 50,
+ }));
+
+ // Resize testee 2.
+ setSize(fixture.componentInstance.testee2(), {width: 201, height: 51});
+ await waitForSignalChange(boundingBox);
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 201,
+ height: 51,
+ }));
+
+ // Change element to testee 1.
+ element.set(fixture.componentInstance.testee1());
+
+ // Expect bounding box.
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 300,
+ height: 150,
+ }));
+
+ // Resize testee 1.
+ setSize(fixture.componentInstance.testee1(), {width: 301, height: 151});
+ await waitForSignalChange(boundingBox);
+ expect(boundingBox()).toEqual(jasmine.objectContaining({
+ width: 301,
+ height: 151,
+ }));
+
+ // Resize testee 2.
+ setSize(fixture.componentInstance.testee2(), {width: 999, height: 999});
+ // Expect testee 2 to be disconnected.
+ await expectAsync(waitForSignalChange(boundingBox, {timeout: 500})).toBeRejected();
+ });
+
+ it('should error if called from within a reactive context', () => {
+ const boundingBox = computed(() => boundingClientRect(document.body));
+ expect(() => boundingBox()).toThrowError(/boundingClientRect\(\) 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(() => boundingClientRect(document.body)()).toThrowError(/boundingClientRect\(\) 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`;
+}
+
+function setTransform(element: ElementRef, translation: {x: number; y: number}): void {
+ element.nativeElement.style.transform = `translate(${translation.x}px,${translation.y}px)`;
+}
diff --git a/projects/scion/components/dimension/src/bounding-client-rect.signal.ts b/projects/scion/components/dimension/src/bounding-client-rect.signal.ts
new file mode 100644
index 00000000..14b047fc
--- /dev/null
+++ b/projects/scion/components/dimension/src/bounding-client-rect.signal.ts
@@ -0,0 +1,68 @@
+/*
+ * 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, effect, ElementRef, inject, Injector, isSignal, signal, Signal, untracked} from '@angular/core';
+import {coerceElement} from '@angular/cdk/coercion';
+import {fromBoundingClientRect$} from '@scion/toolkit/observable';
+
+/**
+ * Creates a signal observing the bounding box of an element.
+ *
+ * The bounding box includes the element's position relative to the top-left of the viewport and its size.
+ * Refer to https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect for more details.
+ *
+ * The element can be passed as a signal, enabling observation of view children in the component constructor.
+ *
+ * There is no native browser API to observe the position of an element. The signal uses {@link IntersectionObserver} and
+ * {@link ResizeObserver} to detect position changes. For tracking only size changes, use the {@link dimension} signal instead.
+ *
+ * Usage:
+ * - Must be called within an injection context or an injector provided. Destroying the injector will unsubscribe the signal.
+ * - Must not be called within a reactive context to avoid repeated subscriptions.
+ * - The element and the document root (``) must be positioned `relative` or `absolute`. If not, a warning is logged, and positioning changed to `relative`.
+ */
+export function boundingClientRect(elementLike: HTMLElement | ElementRef | Signal>, options?: {injector?: Injector}): Signal;
+export function boundingClientRect(elementLike: HTMLElement | ElementRef | Signal | undefined>, options?: {injector?: Injector}): Signal;
+export function boundingClientRect(elementLike: HTMLElement | ElementRef | Signal | undefined>, options?: {injector?: Injector}): Signal {
+ assertNotInReactiveContext(boundingClientRect, 'Invoking `boundingClientRect` causes new subscriptions every time. Move `boundingClientRect` outside of the reactive context and read the signal value where needed.');
+ if (!options?.injector) {
+ assertInInjectionContext(boundingClientRect);
+ }
+
+ const injector = options?.injector ?? inject(Injector);
+ const elementAsSignal = computed(() => coerceElement(isSignal(elementLike) ? elementLike() : elementLike));
+ const onChange = signal(undefined, {equal: () => false});
+
+ effect(onCleanup => {
+ const element = elementAsSignal();
+ untracked(() => {
+ if (element) {
+ const subscription = fromBoundingClientRect$(element).subscribe(() => onChange.set());
+ onCleanup(() => subscription.unsubscribe());
+ }
+ })
+ }, {injector});
+
+ return computed(() => {
+ const element = elementAsSignal();
+ if (!element) {
+ return undefined;
+ }
+
+ // Track when the bounding box changes.
+ onChange();
+
+ return element.getBoundingClientRect();
+ }, {equal: isEqualDomRect});
+}
+
+function isEqualDomRect(a: DOMRect | undefined, b: DOMRect | undefined): boolean {
+ return a?.top === b?.top && a?.right === b?.right && a?.bottom === b?.bottom && a?.left === b?.left;
+}
diff --git a/projects/scion/components/dimension/src/dimension.directive.spec.ts b/projects/scion/components/dimension/src/dimension.directive.spec.ts
new file mode 100644
index 00000000..12f70f6c
--- /dev/null
+++ b/projects/scion/components/dimension/src/dimension.directive.spec.ts
@@ -0,0 +1,156 @@
+/*
+ * 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 {ComponentFixtureAutoDetect, TestBed} from '@angular/core/testing';
+import {Component, ElementRef, NgZone, viewChild} from '@angular/core';
+import {SciDimensionDirective} from './dimension.directive';
+import {SciDimension} from './dimension';
+import {Subject} from 'rxjs';
+import {ObserveCaptor} from '@scion/toolkit/testing';
+
+describe('Dimension Directive', () => {
+
+ 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;
+ }
+ `,
+ imports: [
+ SciDimensionDirective,
+ ],
+ standalone: true,
+ })
+ class TestComponent {
+
+ public testee = viewChild.required>('testee');
+ public emissions$ = new Subject();
+
+ protected onDimensionChange(dimension: SciDimension): void {
+ this.emissions$.next({...dimension, insideAngular: NgZone.isInAngularZone()});
+ }
+ }
+
+ const fixture = TestBed.createComponent(TestComponent);
+ const captor = new ObserveCaptor();
+ fixture.componentInstance.emissions$.subscribe(captor);
+
+ // Expect initial size emission.
+ await captor.waitUntilEmitCount(1);
+ expect(captor.getLastValue()).toEqual({
+ clientWidth: 300,
+ offsetWidth: 302,
+ clientHeight: 150,
+ offsetHeight: 152,
+ element: fixture.componentInstance.testee().nativeElement,
+ insideAngular: true,
+ });
+
+ // Change size.
+ setSize(fixture.componentInstance.testee(), {width: 200, height: 100});
+
+ // Expect emission because size has changed.
+ await captor.waitUntilEmitCount(2);
+ expect(captor.getLastValue()).toEqual({
+ clientWidth: 200,
+ offsetWidth: 202,
+ clientHeight: 100,
+ offsetHeight: 102,
+ element: fixture.componentInstance.testee().nativeElement,
+ insideAngular: true,
+ });
+ });
+
+ it('should detect size change (outside Angular)', 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;
+ }
+ `,
+ imports: [
+ SciDimensionDirective,
+ ],
+ standalone: true,
+ })
+ class TestComponent {
+
+ public testee = viewChild.required>('testee');
+ public emissions$ = new Subject();
+
+ protected onDimensionChange(dimension: SciDimension): void {
+ this.emissions$.next({...dimension, insideAngular: NgZone.isInAngularZone()});
+ }
+ }
+
+ const fixture = TestBed.createComponent(TestComponent);
+ const captor = new ObserveCaptor();
+ fixture.componentInstance.emissions$.subscribe(captor);
+
+ // Expect initial size emission.
+ await captor.waitUntilEmitCount(1);
+ expect(captor.getLastValue()).toEqual({
+ clientWidth: 300,
+ offsetWidth: 302,
+ clientHeight: 150,
+ offsetHeight: 152,
+ element: fixture.componentInstance.testee().nativeElement,
+ insideAngular: false,
+ });
+
+ // Change size.
+ setSize(fixture.componentInstance.testee(), {width: 200, height: 100});
+
+ // Expect emission because size has changed.
+ await captor.waitUntilEmitCount(2);
+ expect(captor.getLastValue()).toEqual({
+ clientWidth: 200,
+ offsetWidth: 202,
+ clientHeight: 100,
+ offsetHeight: 102,
+ element: fixture.componentInstance.testee().nativeElement,
+ insideAngular: false,
+ });
+ });
+});
+
+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.spec.ts b/projects/scion/components/dimension/src/dimension.signal.spec.ts
similarity index 75%
rename from projects/scion/components/dimension/src/dimension.spec.ts
rename to projects/scion/components/dimension/src/dimension.signal.spec.ts
index 6480bf3f..7f40991e 100644
--- a/projects/scion/components/dimension/src/dimension.spec.ts
+++ b/projects/scion/components/dimension/src/dimension.signal.spec.ts
@@ -9,150 +9,13 @@
*/
import {ComponentFixtureAutoDetect, TestBed} from '@angular/core/testing';
-import {Component, computed, createEnvironmentInjector, effect, ElementRef, EnvironmentInjector, Injector, NgZone, runInInjectionContext, signal, Signal, viewChild} from '@angular/core';
-import {SciDimensionDirective} from './dimension.directive';
+import {Component, computed, createEnvironmentInjector, effect, ElementRef, EnvironmentInjector, Injector, runInInjectionContext, signal, Signal, viewChild} from '@angular/core';
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 {firstValueFrom, mergeMap, NEVER, race, throwError, timer} from 'rxjs';
import {toObservable} from '@angular/core/rxjs-interop';
import {first} from 'rxjs/operators';
-describe('Dimension Directive', () => {
-
- 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;
- }
- `,
- imports: [
- SciDimensionDirective,
- ],
- standalone: true,
- })
- class TestComponent {
-
- public testee = viewChild.required>('testee');
- public emissions$ = new Subject();
-
- protected onDimensionChange(dimension: SciDimension): void {
- this.emissions$.next({...dimension, insideAngular: NgZone.isInAngularZone()});
- }
- }
-
- const fixture = TestBed.createComponent(TestComponent);
- const captor = new ObserveCaptor();
- fixture.componentInstance.emissions$.subscribe(captor);
-
- // Expect initial size emission.
- await captor.waitUntilEmitCount(1);
- expect(captor.getLastValue()).toEqual({
- clientWidth: 300,
- offsetWidth: 302,
- clientHeight: 150,
- offsetHeight: 152,
- element: fixture.componentInstance.testee().nativeElement,
- insideAngular: true,
- });
-
- // Change size.
- setSize(fixture.componentInstance.testee(), {width: 200, height: 100});
-
- // Expect emission because size has changed.
- await captor.waitUntilEmitCount(2);
- expect(captor.getLastValue()).toEqual({
- clientWidth: 200,
- offsetWidth: 202,
- clientHeight: 100,
- offsetHeight: 102,
- element: fixture.componentInstance.testee().nativeElement,
- insideAngular: true,
- });
- });
-
- it('should detect size change (outside Angular)', 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;
- }
- `,
- imports: [
- SciDimensionDirective,
- ],
- standalone: true,
- })
- class TestComponent {
-
- public testee = viewChild.required>('testee');
- public emissions$ = new Subject();
-
- protected onDimensionChange(dimension: SciDimension): void {
- this.emissions$.next({...dimension, insideAngular: NgZone.isInAngularZone()});
- }
- }
-
- const fixture = TestBed.createComponent(TestComponent);
- const captor = new ObserveCaptor();
- fixture.componentInstance.emissions$.subscribe(captor);
-
- // Expect initial size emission.
- await captor.waitUntilEmitCount(1);
- expect(captor.getLastValue()).toEqual({
- clientWidth: 300,
- offsetWidth: 302,
- clientHeight: 150,
- offsetHeight: 152,
- element: fixture.componentInstance.testee().nativeElement,
- insideAngular: false,
- });
-
- // Change size.
- setSize(fixture.componentInstance.testee(), {width: 200, height: 100});
-
- // Expect emission because size has changed.
- await captor.waitUntilEmitCount(2);
- expect(captor.getLastValue()).toEqual({
- clientWidth: 200,
- offsetWidth: 202,
- clientHeight: 100,
- offsetHeight: 102,
- element: fixture.componentInstance.testee().nativeElement,
- insideAngular: false,
- });
- });
-});
-
describe('Dimension Signal', () => {
it('should detect size change', async () => {
@@ -185,7 +48,7 @@ describe('Dimension Signal', () => {
const fixture = TestBed.createComponent(TestComponent);
const size = TestBed.runInInjectionContext(() => dimension(fixture.componentInstance.testee()));
- // Expect initial size emission.
+ // Expect initial size.
expect(size()).toEqual({
clientWidth: 300,
offsetWidth: 302,
@@ -485,17 +348,25 @@ describe('Dimension Signal', () => {
const fixture = TestBed.createComponent(TestComponent);
const size = dimension(fixture.componentInstance.testee(), {injector});
- // Expect initial size emission.
+ // Expect initial size.
expect(size()).toEqual(jasmine.objectContaining({
clientWidth: 300,
clientHeight: 150,
}));
+ // Change size.
+ setSize(fixture.componentInstance.testee(), {width: 301, height: 151});
+ await waitForSignalChange(size);
+ expect(size()).toEqual(jasmine.objectContaining({
+ clientWidth: 301,
+ clientHeight: 151,
+ }));
+
// Destroy injector.
injector.destroy();
// Change size.
- setSize(fixture.componentInstance.testee(), {width: 200, height: 100});
+ setSize(fixture.componentInstance.testee(), {width: 302, height: 152});
// Expect signal to be disconnected.
await expectAsync(waitForSignalChange(size, {timeout: 500})).toBeRejected();
@@ -532,17 +403,25 @@ describe('Dimension Signal', () => {
const fixture = TestBed.createComponent(TestComponent);
const size = runInInjectionContext(injector, () => dimension(fixture.componentInstance.testee()));
- // Expect initial size emission.
+ // Expect initial size.
expect(size()).toEqual(jasmine.objectContaining({
clientWidth: 300,
clientHeight: 150,
}));
+ // Change size.
+ setSize(fixture.componentInstance.testee(), {width: 301, height: 151});
+ await waitForSignalChange(size);
+ expect(size()).toEqual(jasmine.objectContaining({
+ clientWidth: 301,
+ clientHeight: 151,
+ }));
+
// Destroy injector.
injector.destroy();
// Change size.
- setSize(fixture.componentInstance.testee(), {width: 200, height: 100});
+ setSize(fixture.componentInstance.testee(), {width: 302, height: 152});
// Expect signal to be disconnected.
await expectAsync(waitForSignalChange(size, {timeout: 500})).toBeRejected();
@@ -600,11 +479,11 @@ describe('Dimension Signal', () => {
}));
// Resize testee 1.
- setSize(fixture.componentInstance.testee1(), {width: 350, height: 250});
+ setSize(fixture.componentInstance.testee1(), {width: 301, height: 151});
await waitForSignalChange(size);
expect(size()).toEqual(jasmine.objectContaining({
- clientWidth: 350,
- clientHeight: 250,
+ clientWidth: 301,
+ clientHeight: 151,
element: fixture.componentInstance.testee1().nativeElement,
}));
@@ -619,16 +498,16 @@ describe('Dimension Signal', () => {
}));
// Resize testee 2.
- setSize(fixture.componentInstance.testee2(), {width: 275, height: 75});
+ setSize(fixture.componentInstance.testee2(), {width: 201, height: 51});
await waitForSignalChange(size);
expect(size()).toEqual(jasmine.objectContaining({
- clientWidth: 275,
- clientHeight: 75,
+ clientWidth: 201,
+ clientHeight: 51,
element: fixture.componentInstance.testee2().nativeElement,
}));
// Resize testee 1.
- setSize(fixture.componentInstance.testee1(), {width: 350, height: 250});
+ setSize(fixture.componentInstance.testee1(), {width: 999, height: 999});
// Expect testee 1 to be disconnected.
await expectAsync(waitForSignalChange(size, {timeout: 500})).toBeRejected();
});
diff --git a/projects/scion/components/dimension/src/dimension.signal.ts b/projects/scion/components/dimension/src/dimension.signal.ts
index 86d0dab7..720e79cb 100644
--- a/projects/scion/components/dimension/src/dimension.signal.ts
+++ b/projects/scion/components/dimension/src/dimension.signal.ts
@@ -8,21 +8,23 @@
* SPDX-License-Identifier: EPL-2.0
*/
-import {assertInInjectionContext, assertNotInReactiveContext, computed, DestroyRef, effect, ElementRef, inject, Injector, isSignal, signal, Signal} from '@angular/core';
+import {assertInInjectionContext, assertNotInReactiveContext, computed, effect, ElementRef, inject, Injector, isSignal, signal, Signal, untracked} from '@angular/core';
import {SciDimension} from './dimension';
import {coerceElement} from '@angular/cdk/coercion';
import {Objects} from '@scion/toolkit/util';
+import {fromResize$} from '@scion/toolkit/observable';
+import {animationFrameScheduler, observeOn} from 'rxjs';
/**
- * Creates a signal to observe the size of an element.
+ * Creates a signal observing 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 element can be passed as a signal, enabling observation of view children in the component constructor.
*
- * The signal uses to the native {@link ResizeObserver} to monitor element size changes. Destroying the injection context will unsubscribe from {@link ResizeObserver}.
+ * The signal subscribes 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.
+ * - Must be called within an injection context or an injector provided. Destroying the injector will unsubscribe the signal.
+ * - 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;
@@ -33,26 +35,24 @@ export function dimension(elementLike: HTMLElement | ElementRef | S
}
const injector = options?.injector ?? inject(Injector);
- const element = computed(() => coerceElement(isSignal(elementLike) ? elementLike() : elementLike));
+ const elementAsSignal = 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));
- }
+ const element = elementAsSignal();
+ untracked(() => {
+ if (element) {
+ const subscription = fromResize$(element)
+ .pipe(observeOn(animationFrameScheduler))
+ .subscribe(() => onResize.set());
+ onCleanup(() => subscription.unsubscribe());
+ }
+ })
}, {injector});
return computed(() => {
- const el = element();
- if (!el) {
+ const element = elementAsSignal();
+ if (!element) {
return undefined;
}
@@ -60,11 +60,11 @@ export function dimension(elementLike: HTMLElement | ElementRef | S
onResize();
return {
- clientWidth: el.clientWidth,
- offsetWidth: el.offsetWidth,
- clientHeight: el.clientHeight,
- offsetHeight: el.offsetHeight,
- element: el,
+ clientWidth: element.clientWidth,
+ offsetWidth: element.offsetWidth,
+ clientHeight: element.clientHeight,
+ offsetHeight: element.offsetHeight,
+ element: element,
};
}, {equal: Objects.isEqual});
}
diff --git a/projects/scion/components/dimension/src/public_api.ts b/projects/scion/components/dimension/src/public_api.ts
index 61733681..f5141584 100644
--- a/projects/scion/components/dimension/src/public_api.ts
+++ b/projects/scion/components/dimension/src/public_api.ts
@@ -16,4 +16,5 @@
export {SciDimensionModule} from './dimension.module';
export {SciDimensionDirective} from './dimension.directive';
export {dimension} from './dimension.signal';
+export {boundingClientRect} from './bounding-client-rect.signal';
export {SciDimension} from './dimension';
diff --git a/projects/scion/toolkit/observable/src/bounding-client-rect.observable.ts b/projects/scion/toolkit/observable/src/bounding-client-rect.observable.ts
index e620c4fa..33a1da2a 100644
--- a/projects/scion/toolkit/observable/src/bounding-client-rect.observable.ts
+++ b/projects/scion/toolkit/observable/src/bounding-client-rect.observable.ts
@@ -20,11 +20,11 @@ import {fromResize$} from './resize.observable';
*
* Upon subscription, emits the current bounding box, and then continuously when the bounding box changes. The Observable never completes.
*
- * The target element and the document root (``) must be positioned `relative` or `absolute`.
- * If not, a warning is logged and positioning is changed to `relative`.
+ * The element and the document root (``) must be positioned `relative` or `absolute`.
+ * If not, a warning is logged, and positioning changed to `relative`.
* Note:
- * As of 2024, there is no native browser API to observe the position of an element. This implementation uses {@link IntersectionObserver} and
+ * There is no native browser API to observe the position of an element. The observable uses {@link IntersectionObserver} and
* {@link ResizeObserver} to detect position changes. For tracking only size changes, use {@link fromResize$} instead.
*
* @param element - The element to observe.
@@ -105,7 +105,7 @@ class BoundingClientRectObserver {
private installChangeEmitter(onChange: (clientRect: DOMRect) => void): void {
this._clientRect$
.pipe(
- distinctUntilChanged((a, b) => a.top === b.top && a.right === b.right && a.bottom === b.bottom && a.left === b.left),
+ distinctUntilChanged(isEqualDomRect),
takeUntil(this._destroy$),
)
.subscribe(boundingBox => {
@@ -244,3 +244,7 @@ function setStyle(element: HTMLElement, styles: {[style: string]: string | null}
}
});
}
+
+function isEqualDomRect(a: DOMRect, b: DOMRect): boolean {
+ return a.top === b.top && a.right === b.right && a.bottom === b.bottom && a.left === b.left;
+}