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; +}