diff --git a/apps/components-testing-app/src/app/app.routes.ts b/apps/components-testing-app/src/app/app.routes.ts index 3a95db30..5f1174a7 100644 --- a/apps/components-testing-app/src/app/app.routes.ts +++ b/apps/components-testing-app/src/app/app.routes.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018-2023 Swiss Federal Railways + * 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 @@ -12,7 +12,11 @@ import {Routes} from '@angular/router'; export const routes: Routes = [ { - path: 'sci-viewport', - loadChildren: () => import('./sci-viewport/routes'), + path: 'components', + loadChildren: () => import('./components/routes'), + }, + { + path: 'toolkit', + loadChildren: () => import('./toolkit/routes'), }, ]; diff --git a/apps/components-testing-app/src/app/components/routes.ts b/apps/components-testing-app/src/app/components/routes.ts new file mode 100644 index 00000000..a9e62398 --- /dev/null +++ b/apps/components-testing-app/src/app/components/routes.ts @@ -0,0 +1,18 @@ +/* + * 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 {Routes} from '@angular/router'; + +export default [ + { + path: 'sci-viewport', + loadChildren: () => import('./sci-viewport/routes'), + }, +] satisfies Routes; diff --git a/apps/components-testing-app/src/app/sci-viewport/focus/viewport-focus-page.component.html b/apps/components-testing-app/src/app/components/sci-viewport/focus/viewport-focus-page.component.html similarity index 100% rename from apps/components-testing-app/src/app/sci-viewport/focus/viewport-focus-page.component.html rename to apps/components-testing-app/src/app/components/sci-viewport/focus/viewport-focus-page.component.html diff --git a/apps/components-testing-app/src/app/sci-viewport/focus/viewport-focus-page.component.ts b/apps/components-testing-app/src/app/components/sci-viewport/focus/viewport-focus-page.component.ts similarity index 100% rename from apps/components-testing-app/src/app/sci-viewport/focus/viewport-focus-page.component.ts rename to apps/components-testing-app/src/app/components/sci-viewport/focus/viewport-focus-page.component.ts diff --git a/apps/components-testing-app/src/app/sci-viewport/hover/viewport-hover-page.component.html b/apps/components-testing-app/src/app/components/sci-viewport/hover/viewport-hover-page.component.html similarity index 100% rename from apps/components-testing-app/src/app/sci-viewport/hover/viewport-hover-page.component.html rename to apps/components-testing-app/src/app/components/sci-viewport/hover/viewport-hover-page.component.html diff --git a/apps/components-testing-app/src/app/sci-viewport/hover/viewport-hover-page.component.scss b/apps/components-testing-app/src/app/components/sci-viewport/hover/viewport-hover-page.component.scss similarity index 100% rename from apps/components-testing-app/src/app/sci-viewport/hover/viewport-hover-page.component.scss rename to apps/components-testing-app/src/app/components/sci-viewport/hover/viewport-hover-page.component.scss diff --git a/apps/components-testing-app/src/app/sci-viewport/hover/viewport-hover-page.component.ts b/apps/components-testing-app/src/app/components/sci-viewport/hover/viewport-hover-page.component.ts similarity index 100% rename from apps/components-testing-app/src/app/sci-viewport/hover/viewport-hover-page.component.ts rename to apps/components-testing-app/src/app/components/sci-viewport/hover/viewport-hover-page.component.ts diff --git a/apps/components-testing-app/src/app/sci-viewport/overlap/viewport-overlap-page.component.html b/apps/components-testing-app/src/app/components/sci-viewport/overlap/viewport-overlap-page.component.html similarity index 100% rename from apps/components-testing-app/src/app/sci-viewport/overlap/viewport-overlap-page.component.html rename to apps/components-testing-app/src/app/components/sci-viewport/overlap/viewport-overlap-page.component.html diff --git a/apps/components-testing-app/src/app/sci-viewport/overlap/viewport-overlap-page.component.scss b/apps/components-testing-app/src/app/components/sci-viewport/overlap/viewport-overlap-page.component.scss similarity index 100% rename from apps/components-testing-app/src/app/sci-viewport/overlap/viewport-overlap-page.component.scss rename to apps/components-testing-app/src/app/components/sci-viewport/overlap/viewport-overlap-page.component.scss diff --git a/apps/components-testing-app/src/app/sci-viewport/overlap/viewport-overlap-page.component.ts b/apps/components-testing-app/src/app/components/sci-viewport/overlap/viewport-overlap-page.component.ts similarity index 100% rename from apps/components-testing-app/src/app/sci-viewport/overlap/viewport-overlap-page.component.ts rename to apps/components-testing-app/src/app/components/sci-viewport/overlap/viewport-overlap-page.component.ts diff --git a/apps/components-testing-app/src/app/sci-viewport/routes.ts b/apps/components-testing-app/src/app/components/sci-viewport/routes.ts similarity index 100% rename from apps/components-testing-app/src/app/sci-viewport/routes.ts rename to apps/components-testing-app/src/app/components/sci-viewport/routes.ts diff --git a/apps/components-testing-app/src/app/toolkit/observable/bounding-client-rect/bounding-client-rect-page.component.html b/apps/components-testing-app/src/app/toolkit/observable/bounding-client-rect/bounding-client-rect-page.component.html new file mode 100644 index 00000000..fa556458 --- /dev/null +++ b/apps/components-testing-app/src/app/toolkit/observable/bounding-client-rect/bounding-client-rect-page.component.html @@ -0,0 +1,20 @@ +
+ + diff --git a/apps/components-testing-app/src/app/toolkit/observable/bounding-client-rect/bounding-client-rect-page.component.scss b/apps/components-testing-app/src/app/toolkit/observable/bounding-client-rect/bounding-client-rect-page.component.scss new file mode 100644 index 00000000..edee87a0 --- /dev/null +++ b/apps/components-testing-app/src/app/toolkit/observable/bounding-client-rect/bounding-client-rect-page.component.scss @@ -0,0 +1,30 @@ +:host { + > div.testee { + position: absolute; + background-color: blue; + } + + > aside { + position: fixed; + top: 0; + right: 0; + display: flex; + flex-direction: column; + gap: 2em; + padding: 1em; + + > section { + display: grid; + grid-template-columns: auto 1fr; + gap: .25em 1em; + + > header { + font-weight: bold; + } + + > header, > button.apply { + grid-column: 1/-1; + } + } + } +} diff --git a/apps/components-testing-app/src/app/toolkit/observable/bounding-client-rect/bounding-client-rect-page.component.ts b/apps/components-testing-app/src/app/toolkit/observable/bounding-client-rect/bounding-client-rect-page.component.ts new file mode 100644 index 00000000..4b652a47 --- /dev/null +++ b/apps/components-testing-app/src/app/toolkit/observable/bounding-client-rect/bounding-client-rect-page.component.ts @@ -0,0 +1,63 @@ +/* + * 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 {Component, DestroyRef, ElementRef, inject, OnInit, signal, viewChild} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {fromBoundingClientRect$} from '@scion/toolkit/observable'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; + +@Component({ + selector: 'e2e-bounding-client-rect-page', + templateUrl: './bounding-client-rect-page.component.html', + styleUrl: './bounding-client-rect-page.component.scss', + standalone: true, + imports: [ + FormsModule, + ], +}) +export class BoundingClientRectPageComponent implements OnInit { + + private _testee = viewChild.required>('testee'); + private _destroyRef = inject(DestroyRef); + + protected properties = { + x: signal('0px'), + y: signal('0px'), + width: signal('100px'), + height: signal('100px'), + }; + + protected testeeBoundingBox = { + x: signal(undefined), + y: signal(undefined), + width: signal(undefined), + height: signal(undefined), + }; + + public ngOnInit(): void { + this.applyProperties(); + + fromBoundingClientRect$(this._testee().nativeElement) + .pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe(domRect => { + this.testeeBoundingBox.x.set(domRect.x); + this.testeeBoundingBox.y.set(domRect.y); + this.testeeBoundingBox.width.set(domRect.width); + this.testeeBoundingBox.height.set(domRect.height); + }); + } + + public applyProperties(): void { + this._testee().nativeElement.style.left = this.properties.x(); + this._testee().nativeElement.style.top = this.properties.y(); + this._testee().nativeElement.style.width = this.properties.width(); + this._testee().nativeElement.style.height = this.properties.height(); + } +} diff --git a/apps/components-testing-app/src/app/toolkit/observable/routes.ts b/apps/components-testing-app/src/app/toolkit/observable/routes.ts new file mode 100644 index 00000000..db8e24e6 --- /dev/null +++ b/apps/components-testing-app/src/app/toolkit/observable/routes.ts @@ -0,0 +1,16 @@ +/* + * 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 {Routes} from '@angular/router'; +import {BoundingClientRectPageComponent} from './bounding-client-rect/bounding-client-rect-page.component'; + +export default [ + {path: 'bounding-client-rect', component: BoundingClientRectPageComponent}, +] satisfies Routes; diff --git a/apps/components-testing-app/src/app/toolkit/routes.ts b/apps/components-testing-app/src/app/toolkit/routes.ts new file mode 100644 index 00000000..e1513ade --- /dev/null +++ b/apps/components-testing-app/src/app/toolkit/routes.ts @@ -0,0 +1,17 @@ +/* + * 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 {Routes} from '@angular/router'; + +export default [ + { + path: 'observable', loadChildren: () => import('./observable/routes'), + }, +] satisfies Routes; diff --git a/docs/site/scion-toolkit.md b/docs/site/scion-toolkit.md index 276eedf6..5c185da4 100644 --- a/docs/site/scion-toolkit.md +++ b/docs/site/scion-toolkit.md @@ -14,7 +14,7 @@ This module provides framework-agnostic utilities in TypeScript. It only has a d Provides cryptographic functions. - [**Observable**][link-tool-observable]\ - Provides RxJS Observables for observing the size or DOM mutations of an HTML element. + Provides RxJS observables to observe various aspects of an HTML element, such as size, position, and mutations. - [**Operators**][link-tool-operators]\ Provides a set of useful RxJS operators. diff --git a/docs/site/tools/observable.md b/docs/site/tools/observable.md index 9348f3c1..45a5f06c 100644 --- a/docs/site/tools/observable.md +++ b/docs/site/tools/observable.md @@ -5,7 +5,7 @@ ## [SCION Toolkit][menu-home] > [@scion/toolkit][link-scion-toolkit] > Observable -The NPM sub-module `@scion/toolkit/observable` provides a set of useful RxJS observables. +The NPM sub-module `@scion/toolkit/observable` provides RxJS observables to observe various aspects of an HTML element, such as size, position, and mutations. To use the observables, install the NPM module `@scion/toolkit` as following: @@ -14,68 +14,84 @@ npm install @scion/toolkit ```
- fromDimension$ - -Allows observing the dimension of an element. Upon subscription, it emits the element's dimension, and then continuously emits when the dimension of the element changes. It never completes. + fromResize$ -```typescript -import {Dimension, fromDimension$} from '@scion/toolkit/observable'; +Wraps the native [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) in an RxJS observable to observe resizing of an element. + +Upon subscription, emits the current size, and then continuously when the size changes. The observable never completes. + +```ts +import {fromResize$} from '@scion/toolkit/observable'; const element: HTMLElement = ...; -fromDimension$(element).subscribe((dimension: Dimension) => { - console.log(dimension); +fromResize$(element).subscribe((entries: ResizeObserverEntry[]) => { + }); ``` -The Observable uses the native [`ResizeObserver`](https://wicg.github.io/ResizeObserver) to detect size changes of the passed element. -
fromMutation$ -Allows watching for changes being made to the DOM tree of an HTML element. It never completes. - -The Observable wraps a [`MutationObserver`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) to watch for changes being made to the DOM tree. - -```typescript +Wraps the native [`MutationObserver`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) in an RxJS observable to observe mutations of an element. + +```ts import {fromMutation$} from '@scion/toolkit/observable'; const element: HTMLElement = ...; fromMutation$(element).subscribe((mutations: MutationRecord[]) => { - console.log(mutations); + }); ``` -When constructing the Observable, you can pass a `MutationObserverInit` options object to control which attributes or events to observe. See https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit for more information. +
+ +
+ fromIntersection$ + +Wraps the native [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) in an RxJS observable to observe intersection of an element. + +Upon subscription, emits the current intersection state, and then continuously when the intersection state changes. The observable never completes. + +```ts +import {fromIntersection$} from '@scion/toolkit/observable'; + +const element: HTMLElement = ...; +fromIntersection$(element, {threshold: 1}).subscribe((entries: IntersectionObserverEntry[]) => { + +}); +```
fromBoundingClientRect$ - -Allows observing an element's bounding box, providing information about the element's size and position relative to the browser viewport. Refer to https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect for more information. -Upon subscription, the Observable emits the element's current bounding box, and then continuously emits when its bounding box changes, e.g., due to a change in the layout. The Observable never completes. +Observes changes to the bounding box of an element. -```typescript +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. + +Upon subscription, emits the current bounding box, and then continuously when the bounding box changes. The observable never completes. + + +```ts import {fromBoundingClientRect$} from '@scion/toolkit/observable'; const element: HTMLElement = ...; -fromBoundingClientRect$(element).subscribe((boundingBox: Readonly) => { - console.log(boundingBox); +fromBoundingClientRect$(element).subscribe((clientRect: DOMRect) => { + }); ``` *** -If you are only interested in element size changes and not position changes, consider using the `fromDimension$` Observable as it is more efficient because natively supported by the browser. +The target element and the document root (``) must be positioned (`relative`, `absolute`, or `fixed`). If not, positioning is changed to `relative`. *** -*Note on the detection of position changes:*\ - -There is, unfortunately, no native browser API to detect position changes of an element in a performant and reliable way. Our approach to detecting position changes of an element is based on the premise that it usually involves a parent or a parent's direct child changing in size. Repositioning can further occur when the user scrolls a parent container or when elements are added to or removed from the DOM. This covers most cases, but not all. - -We are aware that this approach can be quite expensive, mainly because potentially a large number of elements need to be monitored for resizing/scrolling. Therefore, use this Observable only if you need to be informed about position changes. For pure dimension changes use the `fromDimension$` Observable instead. +*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.
[menu-home]: /README.md diff --git a/projects/scion/components.e2e/src/sci-viewport/focus/viewport-focus-page.po.ts b/projects/scion/components.e2e/src/components/sci-viewport/focus/viewport-focus-page.po.ts similarity index 90% rename from projects/scion/components.e2e/src/sci-viewport/focus/viewport-focus-page.po.ts rename to projects/scion/components.e2e/src/components/sci-viewport/focus/viewport-focus-page.po.ts index 2f39071c..04ba7af2 100644 --- a/projects/scion/components.e2e/src/sci-viewport/focus/viewport-focus-page.po.ts +++ b/projects/scion/components.e2e/src/components/sci-viewport/focus/viewport-focus-page.po.ts @@ -9,9 +9,9 @@ */ import {Locator, Page} from '@playwright/test'; -import {isActiveElement} from '../../helper/testing.utils'; +import {isActiveElement} from '../../../helper/testing.utils'; -const PATH = '/#/sci-viewport/focus'; +const PATH = '/#/components/sci-viewport/focus'; export class ViewportFocusPagePO { diff --git a/projects/scion/components.e2e/src/sci-viewport/focus/viewport-focus.e2e-spec.ts b/projects/scion/components.e2e/src/components/sci-viewport/focus/viewport-focus.e2e-spec.ts similarity index 97% rename from projects/scion/components.e2e/src/sci-viewport/focus/viewport-focus.e2e-spec.ts rename to projects/scion/components.e2e/src/components/sci-viewport/focus/viewport-focus.e2e-spec.ts index 7863af72..ebc08e2b 100644 --- a/projects/scion/components.e2e/src/sci-viewport/focus/viewport-focus.e2e-spec.ts +++ b/projects/scion/components.e2e/src/components/sci-viewport/focus/viewport-focus.e2e-spec.ts @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {test} from '../../fixtures'; +import {test} from '../../../fixtures'; import {expect} from '@playwright/test'; import {ViewportFocusPagePO} from './viewport-focus-page.po'; diff --git a/projects/scion/components.e2e/src/sci-viewport/hover/viewport-hover-page.po.ts b/projects/scion/components.e2e/src/components/sci-viewport/hover/viewport-hover-page.po.ts similarity index 97% rename from projects/scion/components.e2e/src/sci-viewport/hover/viewport-hover-page.po.ts rename to projects/scion/components.e2e/src/components/sci-viewport/hover/viewport-hover-page.po.ts index a9fc9e19..16c4624d 100644 --- a/projects/scion/components.e2e/src/sci-viewport/hover/viewport-hover-page.po.ts +++ b/projects/scion/components.e2e/src/components/sci-viewport/hover/viewport-hover-page.po.ts @@ -10,7 +10,7 @@ import {Locator, Page} from '@playwright/test'; -const PATH = '/#/sci-viewport/hover'; +const PATH = '/#/components/sci-viewport/hover'; export class ViewportHoverPagePO { diff --git a/projects/scion/components.e2e/src/sci-viewport/hover/viewport-hover.e2e-spec.ts b/projects/scion/components.e2e/src/components/sci-viewport/hover/viewport-hover.e2e-spec.ts similarity index 98% rename from projects/scion/components.e2e/src/sci-viewport/hover/viewport-hover.e2e-spec.ts rename to projects/scion/components.e2e/src/components/sci-viewport/hover/viewport-hover.e2e-spec.ts index e8715d78..24b7541a 100644 --- a/projects/scion/components.e2e/src/sci-viewport/hover/viewport-hover.e2e-spec.ts +++ b/projects/scion/components.e2e/src/components/sci-viewport/hover/viewport-hover.e2e-spec.ts @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {test} from '../../fixtures'; +import {test} from '../../../fixtures'; import {expect} from '@playwright/test'; import {ViewportHoverPagePO} from './viewport-hover-page.po'; diff --git a/projects/scion/components.e2e/src/sci-viewport/overlap/viewport-overlap-page.po.ts b/projects/scion/components.e2e/src/components/sci-viewport/overlap/viewport-overlap-page.po.ts similarity index 93% rename from projects/scion/components.e2e/src/sci-viewport/overlap/viewport-overlap-page.po.ts rename to projects/scion/components.e2e/src/components/sci-viewport/overlap/viewport-overlap-page.po.ts index 2957caa4..6ff19444 100644 --- a/projects/scion/components.e2e/src/sci-viewport/overlap/viewport-overlap-page.po.ts +++ b/projects/scion/components.e2e/src/components/sci-viewport/overlap/viewport-overlap-page.po.ts @@ -10,7 +10,7 @@ import {Locator, Page} from '@playwright/test'; -const PATH = '/#/sci-viewport/overlap'; +const PATH = '/#/components/sci-viewport/overlap'; export class ViewportOverlapPagePO { diff --git a/projects/scion/components.e2e/src/sci-viewport/overlap/viewport-overlap.e2e-spec.ts b/projects/scion/components.e2e/src/components/sci-viewport/overlap/viewport-overlap.e2e-spec.ts similarity index 94% rename from projects/scion/components.e2e/src/sci-viewport/overlap/viewport-overlap.e2e-spec.ts rename to projects/scion/components.e2e/src/components/sci-viewport/overlap/viewport-overlap.e2e-spec.ts index a1c2c1e5..f6466e01 100644 --- a/projects/scion/components.e2e/src/sci-viewport/overlap/viewport-overlap.e2e-spec.ts +++ b/projects/scion/components.e2e/src/components/sci-viewport/overlap/viewport-overlap.e2e-spec.ts @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {test} from '../../fixtures'; +import {test} from '../../../fixtures'; import {expect} from '@playwright/test'; import {ViewportOverlapPagePO} from './viewport-overlap-page.po'; diff --git a/projects/scion/components.e2e/src/toolkit/observable/bounding-client-rect/bounding-client-rect-page.e2e-spec.ts b/projects/scion/components.e2e/src/toolkit/observable/bounding-client-rect/bounding-client-rect-page.e2e-spec.ts new file mode 100644 index 00000000..5ab22ad9 --- /dev/null +++ b/projects/scion/components.e2e/src/toolkit/observable/bounding-client-rect/bounding-client-rect-page.e2e-spec.ts @@ -0,0 +1,35 @@ +/* + * 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 {test} from '../../../fixtures'; +import {expect} from '@playwright/test'; +import {BoundingClientRectPagePO} from './bounding-client-rect-page.po'; + +test.describe('@scion/toolkit/observable/fromBoundingClientRect$', () => { + + test('should detect changed bounding box when resizing the browser window', async ({page}) => { + const testPage = new BoundingClientRectPagePO(page); + await testPage.navigate(); + + const initialWindowWidth = page.viewportSize()!.width; + + // Set element size to 100x100 pixel. + await testPage.enterProperties({x: '0', y: '0', width: '100px', height: '100px'}); + await expect(testPage.testeeBoundingBox.width).toHaveText('100'); + + // Set element width to 100% and expect the bounding box to be updated. + await testPage.enterProperties({x: '0', y: '0', width: '100%', height: '100px'}); + await expect(testPage.testeeBoundingBox.width).toHaveText(`${initialWindowWidth}`); + + // Increase window width by 200px and expect the bounding box to be updated. + await testPage.resizeWindow({width: initialWindowWidth + 200}); + await expect(testPage.testeeBoundingBox.width).toHaveText(`${initialWindowWidth + 200}`); + }); +}); diff --git a/projects/scion/components.e2e/src/toolkit/observable/bounding-client-rect/bounding-client-rect-page.po.ts b/projects/scion/components.e2e/src/toolkit/observable/bounding-client-rect/bounding-client-rect-page.po.ts new file mode 100644 index 00000000..841b1ab1 --- /dev/null +++ b/projects/scion/components.e2e/src/toolkit/observable/bounding-client-rect/bounding-client-rect-page.po.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018-2022 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 {Locator, Page} from '@playwright/test'; + +const PATH = '/#/toolkit/observable/bounding-client-rect'; + +export class BoundingClientRectPagePO { + + private readonly _locator: Locator; + + public readonly testeeBoundingBox: { + x: Locator; + y: Locator; + width: Locator; + height: Locator; + }; + + constructor(private _page: Page) { + this._locator = _page.locator('e2e-bounding-client-rect-page'); + this.testeeBoundingBox = { + x: this._locator.locator('section.e2e-testee-bounding-box span.e2e-x'), + y: this._locator.locator('section.e2e-testee-bounding-box span.e2e-y'), + width: this._locator.locator('section.e2e-testee-bounding-box span.e2e-width'), + height: this._locator.locator('section.e2e-testee-bounding-box span.e2e-height'), + }; + } + + public async navigate(): Promise { + await this._page.goto(PATH); + } + + public async enterProperties(properties: {x: string; y: string; width: string; height: string}): Promise { + await this._locator.locator('section.e2e-properties input.e2e-x').fill(properties.x); + await this._locator.locator('section.e2e-properties input.e2e-y').fill(properties.y); + await this._locator.locator('section.e2e-properties input.e2e-width').fill(properties.width); + await this._locator.locator('section.e2e-properties input.e2e-height').fill(properties.height); + await this._locator.locator('section.e2e-properties button.e2e-apply').click(); + } + + public async resizeWindow(viewportSize: {width?: number; height?: number}): Promise { + await this._page.setViewportSize({ + width: viewportSize.width ?? this._page.viewportSize()!.width, + height: viewportSize.height ?? this._page.viewportSize()!.height, + }); + } +} diff --git a/projects/scion/components.internal/accordion/src/accordion.component.ts b/projects/scion/components.internal/accordion/src/accordion.component.ts index 9d02cd13..fdb8cd91 100644 --- a/projects/scion/components.internal/accordion/src/accordion.component.ts +++ b/projects/scion/components.internal/accordion/src/accordion.component.ts @@ -12,11 +12,11 @@ import {ChangeDetectorRef, Component, ContentChildren, ElementRef, HostBinding, import {animate, AnimationMetadata, style, transition, trigger} from '@angular/animations'; import {SciAccordionItemDirective} from './accordion-item.directive'; import {CdkAccordion, CdkAccordionItem, CdkAccordionModule} from '@angular/cdk/accordion'; -import {fromDimension$} from '@scion/toolkit/observable'; import {debounceTime, takeUntil} from 'rxjs/operators'; import {combineLatest, Subject} from 'rxjs'; import {NgClass, NgTemplateOutlet} from '@angular/common'; import {SciMaterialIconDirective} from '@scion/components.internal/material-icon'; +import {fromResize$} from '@scion/toolkit/observable'; /** * Component that shows items in an accordion. @@ -119,15 +119,15 @@ export class SciAccordionComponent implements OnInit, OnDestroy { */ private computeFilledStateOnDimensionChange(): void { combineLatest([ - fromDimension$(this._host.nativeElement), - fromDimension$(this._cdkAccordion.nativeElement), + fromResize$(this._host.nativeElement), + fromResize$(this._cdkAccordion.nativeElement), ]) .pipe( debounceTime(5), // debounce dimension changes because the animation for expanding/collapsing a panel continuously emits resize events. takeUntil(this._destroy$), ) - .subscribe(([hostDimension, accordionDimension]) => { - this.filled = hostDimension.clientHeight <= accordionDimension.offsetHeight; + .subscribe(() => { + this.filled = this._host.nativeElement.clientHeight <= this._cdkAccordion.nativeElement.offsetHeight; this._cdRef.detectChanges(); }); } diff --git a/projects/scion/components/dimension/src/dimension.directive.ts b/projects/scion/components/dimension/src/dimension.directive.ts index 4a97710f..e29e7ec5 100644 --- a/projects/scion/components/dimension/src/dimension.directive.ts +++ b/projects/scion/components/dimension/src/dimension.directive.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018-2019 Swiss Federal Railways + * 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 @@ -8,82 +8,70 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {Directive, ElementRef, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output} from '@angular/core'; -import {Subject} from 'rxjs'; -import {takeUntil} from 'rxjs/operators'; -import {captureElementDimension, Dimension, fromDimension$} from '@scion/toolkit/observable'; +import {Directive, ElementRef, inject, input, NgZone, output} from '@angular/core'; +import {fromResize$} from '@scion/toolkit/observable'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {observeInside, subscribeInside} from '@scion/toolkit/operators'; +import {animationFrameScheduler, observeOn} from 'rxjs'; /** - * Allows observing changes to host element's size. + * Observes changes to the size of the host element. * - * See {@link fromDimension$} Observable for more information. + * Upon subscription, emits the current size, and then continuously when the size changes. The Observable never completes. * * --- * Usage: * + * ```html *
+ * ``` */ @Directive({ selector: '[sciDimension]', - exportAs: 'sciDimension', standalone: true, }) -export class SciDimensionDirective implements OnInit, OnDestroy { - - private readonly _host: HTMLElement; - private _destroy$ = new Subject(); +export class SciDimensionDirective { /** - * Upon subscription, it emits the host element's dimension, and then continuously emits when the dimension of the host element changes. + * Controls if to emit outside the Angular zone. Defaults to `false`. */ - @Output('sciDimensionChange') // eslint-disable-line @angular-eslint/no-output-rename - public dimensionChange = new EventEmitter(); + public emitOutsideAngular = input(false); /** - * Controls if to emit a dimension change inside or outside of the Angular zone. - * If emitted outside of the Angular zone no change detection cycle is triggered. - * - * By default, if not specified, emits inside the Angular zone. + * Upon subscription, emits the current size, and then continuously when the size changes. The Observable never completes. */ - @Input() - public emitOutsideAngular = false; - - constructor(host: ElementRef, private _ngZone: NgZone) { - this._host = host.nativeElement; - } + public dimensionChange = output({alias: 'sciDimensionChange'}); - public ngOnInit(): void { - this.installDimensionListener(); - } + constructor() { + const host = inject(ElementRef).nativeElement; + const zone = inject(NgZone); - private installDimensionListener(): void { - this._ngZone.runOutsideAngular(() => { - fromDimension$(this._host) - .pipe(takeUntil(this._destroy$)) - .subscribe((dimension: SciDimension) => { - if (this.emitOutsideAngular) { - this.dimensionChange.emit(dimension); - } - else { - this._ngZone.run(() => this.dimensionChange.emit(dimension)); - } + fromResize$(host) + .pipe( + subscribeInside(continueFn => zone.runOutsideAngular(continueFn)), + observeInside(continueFn => this.emitOutsideAngular() ? continueFn() : zone.run(continueFn)), + observeOn(animationFrameScheduler), // to not block resize callback (ResizeObserver loop completed with undelivered notifications) + takeUntilDestroyed(), + ) + .subscribe(() => { + this.dimensionChange.emit({ + clientWidth: host.clientWidth, + offsetWidth: host.offsetWidth, + clientHeight: host.clientHeight, + offsetHeight: host.offsetHeight, + element: host, }); - }); - } - - /** - * Returns the current dimension of its host element. - */ - public get dimension(): SciDimension { - return captureElementDimension(this._host); - } - - public ngOnDestroy(): void { - this._destroy$.next(); + }); } } /** * Represents the dimension of an element. */ -export type SciDimension = Dimension; +export interface SciDimension { + offsetWidth: number; + offsetHeight: number; + clientWidth: number; + clientHeight: number; + element: HTMLElement; +} diff --git a/projects/scion/components/dimension/src/dimension.spec.ts b/projects/scion/components/dimension/src/dimension.spec.ts new file mode 100644 index 00000000..5c1fe5ea --- /dev/null +++ b/projects/scion/components/dimension/src/dimension.spec.ts @@ -0,0 +1,160 @@ +/* + * 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 {SciDimension, SciDimensionDirective} from './dimension.directive'; +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(); + + 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()}); + } + } + + 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. + fixture.componentInstance.setSize({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(); + + 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()}); + } + } + + 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. + fixture.componentInstance.setSize({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, + }); + }); +}); diff --git a/projects/scion/components/viewport/src/scrollbar/scrollbar.component.ts b/projects/scion/components/viewport/src/scrollbar/scrollbar.component.ts index c3674cb0..3a3c3681 100644 --- a/projects/scion/components/viewport/src/scrollbar/scrollbar.component.ts +++ b/projects/scion/components/viewport/src/scrollbar/scrollbar.component.ts @@ -12,7 +12,7 @@ import {DOCUMENT} from '@angular/common'; import {ChangeDetectionStrategy, Component, ElementRef, HostBinding, Inject, Input, NgZone, OnDestroy, ViewChild} from '@angular/core'; import {fromEvent, merge, Observable, of, Subject, timer} from 'rxjs'; import {debounceTime, first, map, startWith, switchMap, takeUntil, takeWhile, withLatestFrom} from 'rxjs/operators'; -import {fromDimension$, fromMutation$} from '@scion/toolkit/observable'; +import {fromMutation$, fromResize$} from '@scion/toolkit/observable'; import {filterArray, subscribeInside} from '@scion/toolkit/operators'; /** @@ -298,7 +298,7 @@ export class SciScrollbarComponent implements OnDestroy { * Emits on subscription, and then each time the size of the viewport changes. */ private viewportDimensionChange$(options: {debounceTime: number}): Observable { - return fromDimension$(this._viewport) + return fromResize$(this._viewport) .pipe( // Debouncing is particularly important in the context of Angular animations, since they continuously // trigger resize events. Debouncing prevents the scrollbar from flickering, for example, when the user @@ -315,7 +315,7 @@ export class SciScrollbarComponent implements OnDestroy { return this.children$(this._viewport) .pipe( switchMap(children => merge(...children.map(child => merge( - fromDimension$(child), + fromResize$(child), // Observe style mutations since some transformations change the scroll position without necessarily triggering a dimension change, // e.g., `scale` or `translate` used by some virtual scroll implementations fromMutation$(child, {subtree: false, childList: false, attributeFilter: ['style']})), diff --git a/projects/scion/components/viewport/src/viewport.component.spec.ts b/projects/scion/components/viewport/src/viewport.component.spec.ts index 7e17c0a2..6a98a981 100644 --- a/projects/scion/components/viewport/src/viewport.component.spec.ts +++ b/projects/scion/components/viewport/src/viewport.component.spec.ts @@ -13,10 +13,11 @@ import {Component, ElementRef, HostBinding, Input, Renderer2, ViewChild} from '@ import {By} from '@angular/platform-browser'; import {Dictionary} from '@scion/toolkit/util'; import {SciViewportComponent} from './viewport.component'; -import {Dimension, fromDimension$} from '@scion/toolkit/observable'; +import {fromResize$} from '@scion/toolkit/observable'; import {ObserveCaptor} from '@scion/toolkit/testing'; import {asyncScheduler} from 'rxjs'; import {SciScrollbarComponent} from './scrollbar/scrollbar.component'; +import {map} from 'rxjs/operators'; describe('Viewport', () => { @@ -924,8 +925,9 @@ describe('Viewport', () => { }); await flushChanges(fixture); - const viewportClientSizeCaptor = new ObserveCaptor(); - const fromDimensionSubscription = fromDimension$(component.viewport.viewportClientElement) + const viewportClientSizeCaptor = new ObserveCaptor(); + const fromDimensionSubscription = fromResize$(component.viewport.viewportClientElement) + .pipe(map(() => component.viewport.viewportClientElement.getBoundingClientRect())) .subscribe(viewportClientSizeCaptor); expect(getSize(fixture, 'sci-viewport')).toEqual(jasmine.objectContaining({height: 300})); @@ -934,7 +936,7 @@ describe('Viewport', () => { expect(isScrollbarVisible(fixture, 'horizontal')).toBeFalse(); await viewportClientSizeCaptor.waitUntilEmitCount(1); - expect(viewportClientSizeCaptor.getLastValue()).toEqual(jasmine.objectContaining({offsetHeight: 600})); + expect(viewportClientSizeCaptor.getLastValue()).toEqual(jasmine.objectContaining({height: 600})); expect(component.viewport.scrollHeight).toEqual(600); // unsubscribe to avoid `ResizeObserver loop limit exceeded` error diff --git a/projects/scion/toolkit/README.md b/projects/scion/toolkit/README.md index bb0f9a9c..eda80a5d 100644 --- a/projects/scion/toolkit/README.md +++ b/projects/scion/toolkit/README.md @@ -8,7 +8,7 @@ This library is written in plain TypeScript and has no dependency on any other l *** - [**Observable**][link-tool-observable]\ - Provides RxJS Observables for observing the size or DOM mutations of an HTML element. + Provides RxJS observables to observe various aspects of an HTML element, such as size, position, and mutations. - [**Operators**][link-tool-operators]\ Provides a set of useful RxJS operators. diff --git a/projects/scion/toolkit/observable/src/bounding-client-rect.observable.spec.ts b/projects/scion/toolkit/observable/src/bounding-client-rect.observable.spec.ts index b4461ac3..74d1a4eb 100644 --- a/projects/scion/toolkit/observable/src/bounding-client-rect.observable.spec.ts +++ b/projects/scion/toolkit/observable/src/bounding-client-rect.observable.spec.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018-2019 Swiss Federal Railways + * 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 @@ -11,312 +11,934 @@ import {fromBoundingClientRect$} from './bounding-client-rect.observable'; import {ObserveCaptor} from '@scion/toolkit/testing'; +const destroyAfterEach = true; +const disposables = new Array<() => void>(); + describe('fromBoundingClientRect$', () => { - // ** TESTS OPERATE ON THE FOLLOWING DOM ** - // - // +----------+ +-------------------------------+ +-----------+ - // | DIV#left | | DIV#middle | | DIV#right | - // | | | | | | - // | | | +---------------------------+ | | | - // | | | | DIV#middle_1 | | | | - // | | | +---------------------------+ | | | - // | | | | | | - // | | | +---------------------------+ | | | - // | | | | DIV#middle_2 | | | | - // | | | | | | | | - // | | | | +-----------------------+ | | | | - // | | | | | DIV#middle_3 | | | | | - // | | | | +-----------------------+ | | | | - // | | | | | | | | - // | | | | +--------------+ | | | | - // | | | | | DIV#testee | | | | | - // | | | | | | | | | | - // | | | | | 100x30 | | | | | - // | | | | | horizontally | | | | | - // | | | | | centered | | | | | - // | | | | +--------------+ | | | | - // | | | +---------------------------+ | | | - // +----------+ +-------------------------------+ +-----------+ - beforeEach(() => setupTestLayout()); - afterEach(() => $('div#root').remove()); - - it('should emit on layout change if affecting the observed element\'s position or size', async () => { - const clientRectCaptures = new ClientRects().capture(); - const emitCaptor = new ObserveCaptor>(); - - // TEST: Subscribe to fromBoundingClientRect$ - fromBoundingClientRect$($('div#testee')).subscribe(emitCaptor); - - // expect the initial DOMRect to be emitted - await emitCaptor.waitUntilEmitCount(1); - await expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining(clientRectCaptures.get('div#testee'))); - - // add 20px to the width of DIV#left; expect testee to be moved by 20px horizontally - addDelta('div#left', {width: 20}); - await emitCaptor.waitUntilEmitCount(2); - await expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({ - top: clientRectCaptures.get('div#testee').top, - left: clientRectCaptures.get('div#testee').left + 20, - width: 100, - height: 30, - })); - - // add 20px to the width of DIV#middle; expect testee to be moved by 10px horizontally, as centered horizontally - clientRectCaptures.capture(); - addDelta('div#middle', {minWidth: 20}); - await emitCaptor.waitUntilEmitCount(3); - await expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({ - top: clientRectCaptures.get('div#testee').top, - left: clientRectCaptures.get('div#testee').left + 10, - width: 100, - height: 30, - })); - - // add 20px to the width of DIV#middle_1; expect testee to be moved by 10px horizontally, as centered horizontally - clientRectCaptures.capture(); - addDelta('div#middle_1', {minWidth: 20}); - await emitCaptor.waitUntilEmitCount(4); - await expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({ - top: clientRectCaptures.get('div#testee').top, - left: clientRectCaptures.get('div#testee').left + 10, - width: 100, - height: 30, - })); - - // add 20px to the width of DIV#middle_2; expect testee to be moved by 10px horizontally, as centered horizontally - clientRectCaptures.capture(); - addDelta('div#middle_2', {minWidth: 20}); - await emitCaptor.waitUntilEmitCount(5); - await expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({ - top: clientRectCaptures.get('div#testee').top, - left: clientRectCaptures.get('div#testee').left + 10, - width: 100, - height: 30, - })); - - // add 20px to the width of DIV#middle_3; expect testee to be moved by 10px horizontally, as centered horizontally - clientRectCaptures.capture(); - addDelta('div#middle_3', {minWidth: 20}); - await emitCaptor.waitUntilEmitCount(6); - await expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({ - top: clientRectCaptures.get('div#testee').top, - left: clientRectCaptures.get('div#testee').left + 10, - width: 100, - height: 30, - })); - - // add 20px to the width of DIV#right; expect testee not to be moved - clientRectCaptures.capture(); - addDelta('div#right', {width: 20}); - await expectAsync(emitCaptor.waitUntilEmitCount(7, 250)).toBeRejected(); - - // add 20px to the height of DIV#left; expect testee not to be moved as vertically aligned on top - addDelta('div#left', {minHeight: 20}); - await expectAsync(emitCaptor.waitUntilEmitCount(7, 250)).toBeRejected(); - - // add 20px to the height of DIV#middle; expect testee not to be moved as vertically aligned on top - clientRectCaptures.capture(); - addDelta('div#middle', {minHeight: 20}); - await expectAsync(emitCaptor.waitUntilEmitCount(7, 250)).toBeRejected(); - - // add 20px to the height of DIV#middle_1; expect testee to be moved by 20px vertically - clientRectCaptures.capture(); - addDelta('div#middle_1', {minHeight: 20}); - await emitCaptor.waitUntilEmitCount(7); - await expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({ - top: clientRectCaptures.get('div#testee').top + 20, - left: clientRectCaptures.get('div#testee').left, - width: 100, - height: 30, - })); - - // add 20px to the height of DIV#middle_2; expect testee not to be moved as vertically aligned on top - clientRectCaptures.capture(); - addDelta('div#middle_2', {minHeight: 20}); - await expectAsync(emitCaptor.waitUntilEmitCount(8, 250)).toBeRejected(); - - // add 20px to the height of DIV#middle_3; expect testee to be moved by 20px vertically - clientRectCaptures.capture(); - addDelta('div#middle_3', {minHeight: 20}); - await emitCaptor.waitUntilEmitCount(8); - await expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({ - top: clientRectCaptures.get('div#testee').top + 20, - left: clientRectCaptures.get('div#testee').left, - width: 100, - height: 30, - })); - - // add 20px to the height of DIV#right; expect testee not to be moved - clientRectCaptures.capture(); - addDelta('div#right', {minHeight: 20}); - await expectAsync(emitCaptor.waitUntilEmitCount(9, 250)).toBeRejected(); - - // remove DIV#left; expect testee to be moved by minus the width of div#left (plus its margin of 5+5=10px) - clientRectCaptures.capture(); - - $('div#left').remove(); - await emitCaptor.waitUntilEmitCount(9); - await expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({ - top: clientRectCaptures.get('div#testee').top, - left: clientRectCaptures.get('div#testee').left - clientRectCaptures.get('div#left').width - 5 - 5, - width: 100, - height: 30, - })); - - // insert DIV#left again; expect testee to be moved by plus the width of div#left (plus its margin of 5+5=10px) - clientRectCaptures.capture(); - $('div#root').insertBefore(createDiv({id: 'left', style: {'background': '#00AAFF', 'width': '400px', 'margin': '5px'}}), $('DIV#middle')); - await emitCaptor.waitUntilEmitCount(10); - await expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({ - top: clientRectCaptures.get('div#testee').top, - left: clientRectCaptures.get('div#testee').left + 400 + 5 + 5, - width: 100, - height: 30, - })); + it('should detect position change', async () => { + const testee = createDiv({ + parent: document.body, + style: {position: 'absolute', top: '150px', left: '0', height: '100px', width: '100px', background: 'blue'}, + }); + + let emissionCount = 0; + const emitCaptor = new ObserveCaptor(domRect => domRect.toJSON()); + const subscription = fromBoundingClientRect$(testee).subscribe(emitCaptor); + onDestroy(() => subscription.unsubscribe()); + + const {x, y} = testee.getBoundingClientRect(); + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y})); + + // Move element to the right. + await waitUntilIdle(); + testee.style.transform = 'translate(1px, 0)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 1, y})); + + await waitUntilIdle(); + testee.style.transform = 'translate(2px, 0)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 2, y})); + + await waitUntilIdle(); + testee.style.transform = 'translate(3px, 0)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 3, y})); + + // Move element to the bottom. + await waitUntilIdle(); + testee.style.transform = 'translate(3px, 1px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 3, y: y + 1})); + + await waitUntilIdle(); + testee.style.transform = 'translate(3px, 2px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 3, y: y + 2})); + + await waitUntilIdle(); + testee.style.transform = 'translate(3px, 3px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 3, y: y + 3})); + + // Move element to the left. + await waitUntilIdle(); + testee.style.transform = 'translate(2px, 3px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 2, y: y + 3})); + + await waitUntilIdle(); + testee.style.transform = 'translate(1px, 3px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 1, y: y + 3})); + + await waitUntilIdle(); + testee.style.transform = 'translate(0, 3px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x, y: y + 3})); + + // Move element to the top. + await waitUntilIdle(); + testee.style.transform = 'translate(0, 2px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x, y: y + 2})); + + await waitUntilIdle(); + testee.style.transform = 'translate(0, 1px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x, y: y + 1})); + + await waitUntilIdle(); + testee.style.transform = 'translate(0, 0px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x, y: y})); }); - it('should emit when resizing the observed element', async () => { - // Capture current element dimensions and positions - const clientRectCaptures = new ClientRects().capture(); - // Init captor to capture Observable emissions - const emitCaptor = new ObserveCaptor>(); - - // TEST: Subscribe to fromBoundingClientRect$ - fromBoundingClientRect$($('div#testee')).subscribe(emitCaptor); - - // expect the initial DOMRect to be emitted - await emitCaptor.waitUntilEmitCount(1); - await expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining(clientRectCaptures.get('div#testee'))); - - // shrink testee horizontally by 50px - addDelta('div#testee', {width: -50}); - await emitCaptor.waitUntilEmitCount(2); - await expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({ - top: clientRectCaptures.get('div#testee').top, - left: clientRectCaptures.get('div#testee').left + 25, - width: 50, - height: 30, - })); + it('should detect position change (element has border)', async () => { + const testee = createDiv({ + parent: document.body, + style: {position: 'absolute', top: '150px', left: '0', height: '100px', width: '100px', border: '1px solid red', background: 'blue'}, + }); + + let emissionCount = 0; + const emitCaptor = new ObserveCaptor(domRect => domRect.toJSON()); + const subscription = fromBoundingClientRect$(testee).subscribe(emitCaptor); + onDestroy(() => subscription.unsubscribe()); + + const {x, y} = testee.getBoundingClientRect(); + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y})); + + // Move element to the right. + await waitUntilIdle(); + testee.style.transform = 'translate(1px, 0)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 1, y})); + + await waitUntilIdle(); + testee.style.transform = 'translate(2px, 0)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 2, y})); + + await waitUntilIdle(); + testee.style.transform = 'translate(3px, 0)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 3, y})); + + // Move element to the bottom. + await waitUntilIdle(); + testee.style.transform = 'translate(3px, 1px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 3, y: y + 1})); + + await waitUntilIdle(); + testee.style.transform = 'translate(3px, 2px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 3, y: y + 2})); + + await waitUntilIdle(); + testee.style.transform = 'translate(3px, 3px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 3, y: y + 3})); + + // Move element to the left. + await waitUntilIdle(); + testee.style.transform = 'translate(2px, 3px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 2, y: y + 3})); + + await waitUntilIdle(); + testee.style.transform = 'translate(1px, 3px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 1, y: y + 3})); + + await waitUntilIdle(); + testee.style.transform = 'translate(0, 3px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x, y: y + 3})); + + // Move element to the top. + await waitUntilIdle(); + testee.style.transform = 'translate(0, 2px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x, y: y + 2})); + + await waitUntilIdle(); + testee.style.transform = 'translate(0, 1px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x, y: y + 1})); + + await waitUntilIdle(); + testee.style.transform = 'translate(0, 0px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x, y: y})); }); -}); -/** - * +----------+ +-------------------------------+ +-----------+ - * | DIV#left | | DIV#middle | | DIV#right | - * | | | | | | - * | | | +---------------------------+ | | | - * | | | | DIV#middle_1 | | | | - * | | | +---------------------------+ | | | - * | | | | | | - * | | | +---------------------------+ | | | - * | | | | DIV#middle_2 | | | | - * | | | | | | | | - * | | | | +-----------------------+ | | | | - * | | | | | DIV#middle_3 | | | | | - * | | | | +-----------------------+ | | | | - * | | | | | | | | - * | | | | +--------------+ | | | | - * | | | | | DIV#testee | | | | | - * | | | | | | | | | | - * | | | | | centered | | | | | - * | | | | | horizontally | | | | | - * | | | | +--------------+ | | | | - * | | | +---------------------------+ | | | - * +----------+ +-------------------------------+ +-----------+ - */ -function setupTestLayout(): void { - createDiv({ - id: 'root', - parent: document.body, - style: {'display': 'flex', 'background-color': 'gray', 'color': 'white', 'width': '1000px'}, - children: [ - createDiv({id: 'left', style: {'background': '#00AAFF', 'min-width': '200px'}}), + it('should detect position change when layout changes', async () => { + // Layout: + // +---------------+ + // | top | + // +------+--------+ + // | left | testee | + // +------+--------+ + createDiv({ + parent: document.body, + style: {position: 'absolute', top: '150px', left: '0', width: '500px', background: 'gray'}, + children: [ + createDiv({id: 'top', style: {height: '50px', background: 'red'}}), + createDiv({ + style: {display: 'flex'}, + children: [ + createDiv({id: 'left', style: {flex: 'none', width: '50px', background: 'yellow'}}), + createDiv({id: 'testee', style: {flex: 'auto', height: '100px', background: 'blue'}}), + ], + }), + ], + }); + + let emissionCount = 0; + const emitCaptor = new ObserveCaptor(domRect => domRect.toJSON()); + const testee = queryElement('div#testee'); + const top = queryElement('div#top'); + const left = queryElement('div#left'); + const subscription = fromBoundingClientRect$(testee).subscribe(emitCaptor); + onDestroy(() => subscription.unsubscribe()); + + const {x, y} = testee.getBoundingClientRect(); + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y})); + + // Change height of 'div#top' from 50px to 75px. + await waitUntilIdle(); + top.style.height = '75px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 25})); + + // Change width of 'div#left' from 50px to 75px. + await waitUntilIdle(); + left.style.width = '75px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 25, y: y + 25})); + }); + + it('should detect position change when viewport overflows horizontally', async () => { + const container = createDiv({ + parent: document.body, + style: {position: 'absolute', top: '150px', left: '0', width: '500px', background: 'gray'}, + children: [ + createDiv({id: 'testee', style: {height: '50px', background: 'blue'}}), + ], + }); + + let emissionCount = 0; + const emitCaptor = new ObserveCaptor(domRect => domRect.toJSON()); + const testee = queryElement('div#testee'); + const subscription = fromBoundingClientRect$(testee).subscribe(emitCaptor); + onDestroy(() => subscription.unsubscribe()); + + const {x, y} = testee.getBoundingClientRect(); + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y})); + + // Expect no horizontal scrollbar. + expect(document.documentElement.scrollWidth).toEqual(document.documentElement.clientWidth); + + // Simulate horizontal scrollbar. + container.style.width = '200vw'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y})); + + // Expect horizontal scrollbar to display. + expect(document.documentElement.scrollWidth).toBeGreaterThan(document.documentElement.clientWidth); + + // Move element to the right. + await waitUntilIdle(); + testee.style.marginLeft = '25px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 25, y})); + + // Move element to the bottom. + await waitUntilIdle(); + testee.style.marginTop = '25px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 25, y: y + 25})); + }); + + it('should detect position change when viewport overflows vertically', async () => { + const container = createDiv({ + parent: document.body, + style: {position: 'absolute', top: '150px', left: '0', width: '500px', background: 'gray'}, + children: [ + createDiv({id: 'testee', style: {height: '50px', background: 'blue'}}), + ], + }); + + let emissionCount = 0; + const emitCaptor = new ObserveCaptor(domRect => domRect.toJSON()); + const testee = queryElement('div#testee'); + const subscription = fromBoundingClientRect$(testee).subscribe(emitCaptor); + onDestroy(() => subscription.unsubscribe()); + + const {x, y} = testee.getBoundingClientRect(); + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y})); + + // Expect no vertical scrollbar. + expect(document.documentElement.scrollHeight).toEqual(document.documentElement.clientHeight); + + // Simulate vertical scrollbar. + container.style.height = '200vh'; + + // Expect vertical scrollbar to display. + expect(document.documentElement.scrollHeight).toBeGreaterThan(document.documentElement.clientHeight); + + // Move element to the right. + await waitUntilIdle(); + testee.style.marginLeft = '25px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 25, y})); + + // Move element to the bottom. + await waitUntilIdle(); + testee.style.marginTop = '25px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 25, y: y + 25})); + await new Promise(resolve => setTimeout(() => resolve())); + }); + + it('should emit when resizing the element', async () => { + const testee = createDiv({ + parent: document.body, + style: {position: 'absolute', top: '150px', left: '0', height: '100px', width: '100px', background: 'blue'}, + }); + + let emissionCount = 0; + const emitCaptor = new ObserveCaptor(domRect => domRect.toJSON()); + const subscription = fromBoundingClientRect$(testee).subscribe(emitCaptor); + onDestroy(() => subscription.unsubscribe()); + + const {x, y} = testee.getBoundingClientRect(); + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y, width: 100, height: 100})); + + await waitUntilIdle(); + testee.style.width = '110px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y, width: 110, height: 100})); + + await waitUntilIdle(); + testee.style.width = '90px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y, width: 90, height: 100})); + + await waitUntilIdle(); + testee.style.width = '100px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y, width: 100, height: 100})); + + await waitUntilIdle(); + testee.style.height = '110px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y, width: 100, height: 110})); + + await waitUntilIdle(); + testee.style.height = '90px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y, width: 100, height: 90})); + + await waitUntilIdle(); + testee.style.height = '100px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y, width: 100, height: 100})); + }); + + it('should detect position change when scrolled the viewport', async () => { + createDiv({ + id: 'container', + parent: document.body, + style: {position: 'absolute', top: '150px', left: '0'}, + children: [ + createDiv({id: 'filler', style: {height: '0', background: 'gray'}}), + createDiv({id: 'testee', style: {width: '100px', height: '100px', background: 'blue'}}), + ], + }); + + const testee = queryElement('div#testee'); + const filler = queryElement('div#filler'); + + let emissionCount = 0; + const emitCaptor = new ObserveCaptor(domRect => domRect.toJSON()); + const subscription = fromBoundingClientRect$(testee).subscribe(emitCaptor); + onDestroy(() => subscription.unsubscribe()); + + await emitCaptor.waitUntilEmitCount(++emissionCount); + + // Add vertical overflow. + await waitUntilIdle(); + filler.style.height = '2000px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + + // Capture bounding box. + const {x: x1, y: y1} = testee.getBoundingClientRect(); + + // Expect emission when moving element to the right. + await waitUntilIdle(); + testee.style.transform = 'translateX(10px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x1 + 10, y: y1, width: 100, height: 100})); + + // Expect emission when moving element to the right. + await waitUntilIdle(); + testee.style.transform = 'translateX(20px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x1 + 20, y: y1, width: 100, height: 100})); + + // Expect emission when moving element to the left. + await waitUntilIdle(); + testee.style.transform = ''; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x1, y: y1, width: 100, height: 100})); + + // Expect emission when moving element to the top. + await waitUntilIdle(); + testee.style.transform = 'translateY(-10px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x1, y: y1 - 10, width: 100, height: 100})); + + // Expect emission when moving element to the top. + await waitUntilIdle(); + testee.style.transform = 'translateY(-20px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x1, y: y1 - 20, width: 100, height: 100})); + + // Expect emission when moving element to the bottom. + await waitUntilIdle(); + testee.style.transform = ''; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x1, y: y1, width: 100, height: 100})); + + // Move viewport to the bottom. + await waitUntilIdle(); + document.documentElement.scrollTop = 2500; + await emitCaptor.waitUntilEmitCount(++emissionCount); + + // Capture bounding box. + const {x: x2, y: y2} = testee.getBoundingClientRect(); + + // Expect emission when moving element to the right. + await waitUntilIdle(); + testee.style.transform = 'translateX(10px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x2 + 10, y: y2, width: 100, height: 100})); + + // Expect emission when moving element to the right. + await waitUntilIdle(); + testee.style.transform = 'translateX(20px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x2 + 20, y: y2, width: 100, height: 100})); + + // Expect emission when moving element to the top. + await waitUntilIdle(); + testee.style.transform = 'translateY(-10px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x2, y: y2 - 10, width: 100, height: 100})); + + // Expect emission when moving element to the top. + await waitUntilIdle(); + testee.style.transform = 'translateY(-20px)'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x2, y: y2 - 20, width: 100, height: 100})); + }); + + describe('Moving element out of the viewport', async () => { + + it('should emit until moved the element out of the viewport (moving element to the right)', async () => { + createDiv({ + id: 'container', + parent: document.body, + style: {position: 'absolute', top: '150px', left: '0', height: '100px', width: '100px', overflow: 'auto', background: 'gray'}, + children: [ + createDiv({id: 'testee', style: {position: 'absolute', top: '0', left: '0', height: '5px', width: '5px', background: 'blue'}}), + ], + }); + + let emissionCount = 0; + const emitCaptor = new ObserveCaptor(domRect => domRect.toJSON()); + const testee = queryElement('div#testee'); + const subscription = fromBoundingClientRect$(testee).subscribe(emitCaptor); + onDestroy(() => subscription.unsubscribe()); + + const {x, y} = testee.getBoundingClientRect(); + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y})); + + await waitUntilIdle(); + testee.style.left = '94px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 94, y})); + + await waitUntilIdle(); + testee.style.left = '95px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 95, y})); + + await waitUntilIdle(); + testee.style.left = '96px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 96, y})); + + await waitUntilIdle(); + testee.style.left = '97px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 97, y})); + + await waitUntilIdle(); + testee.style.left = '98px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 98, y})); + + await waitUntilIdle(); + testee.style.left = '99px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 99, y})); + + await waitUntilIdle(); + testee.style.left = '100px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 100, y})); + + await waitUntilIdle(); + testee.style.left = '101px'; // out of viewport + await expectAsync(emitCaptor.waitUntilEmitCount(emissionCount + 1, 250)).toBeRejected(); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 100, y})); + + await waitUntilIdle(); + testee.style.left = '102px'; // out of viewport + await expectAsync(emitCaptor.waitUntilEmitCount(emissionCount + 1, 250)).toBeRejected(); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 100, y})); + + await waitUntilIdle(); + testee.style.left = '101px'; // out of viewport + await expectAsync(emitCaptor.waitUntilEmitCount(emissionCount + 1, 250)).toBeRejected(); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 100, y})); + + await waitUntilIdle(); + testee.style.left = '100px'; // out of viewport + await expectAsync(emitCaptor.waitUntilEmitCount(emissionCount + 1, 250)).toBeRejected(); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 100, y})); + + await waitUntilIdle(); + testee.style.left = '99px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 99, y})); + + await waitUntilIdle(); + testee.style.left = '98px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 98, y})); + + await waitUntilIdle(); + testee.style.left = '97px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 97, y})); + + await waitUntilIdle(); + testee.style.left = '96px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 96, y})); + + await waitUntilIdle(); + testee.style.left = '95px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 95, y})); + + await waitUntilIdle(); + testee.style.left = '94px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 94, y})); + }); + + it('should emit until moved the element out of the viewport (moving element to the left)', async () => { + createDiv({ + id: 'container', + parent: document.body, + style: {position: 'absolute', top: '150px', left: '0', height: '100px', width: '100px', overflow: 'auto', background: 'gray'}, + children: [ + createDiv({id: 'testee', style: {position: 'absolute', top: '0', left: '0', height: '5px', width: '5px', background: 'blue'}}), + ], + }); + + let emissionCount = 0; + const emitCaptor = new ObserveCaptor(domRect => domRect.toJSON()); + const testee = queryElement('div#testee'); + const subscription = fromBoundingClientRect$(testee).subscribe(emitCaptor); + onDestroy(() => subscription.unsubscribe()); + + const {x, y} = testee.getBoundingClientRect(); + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y})); + + await waitUntilIdle(); + testee.style.left = '2px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 2, y})); + + await waitUntilIdle(); + testee.style.left = '1px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 1, y})); + + await waitUntilIdle(); + testee.style.left = '0'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x, y})); + + await waitUntilIdle(); + testee.style.left = '-1px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x - 1, y})); + + await waitUntilIdle(); + testee.style.left = '-2px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x - 2, y})); + + await waitUntilIdle(); + testee.style.left = '-3px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x - 3, y})); + + await waitUntilIdle(); + testee.style.left = '-4px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x - 4, y})); + + await waitUntilIdle(); + testee.style.left = '-5px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x - 5, y})); + + await waitUntilIdle(); + testee.style.left = '-6px'; // out of viewport + await expectAsync(emitCaptor.waitUntilEmitCount(emissionCount + 1, 250)).toBeRejected(); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x - 5, y})); + + await waitUntilIdle(); + testee.style.left = '-7px'; // out of viewport + await expectAsync(emitCaptor.waitUntilEmitCount(emissionCount + 1, 250)).toBeRejected(); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x - 5, y})); + + await waitUntilIdle(); + testee.style.left = '-6px'; // out of viewport + await expectAsync(emitCaptor.waitUntilEmitCount(emissionCount + 1, 250)).toBeRejected(); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x - 5, y})); + + await waitUntilIdle(); + testee.style.left = '-5px'; // out of viewport + await expectAsync(emitCaptor.waitUntilEmitCount(emissionCount + 1, 250)).toBeRejected(); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x - 5, y})); + + await waitUntilIdle(); + testee.style.left = '-4px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x - 4, y})); + + await waitUntilIdle(); + testee.style.left = '-3px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x - 3, y})); + + await waitUntilIdle(); + testee.style.left = '-2px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x - 2, y})); + + await waitUntilIdle(); + testee.style.left = '-1px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x - 1, y})); + + await waitUntilIdle(); + testee.style.left = '0px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x, y})); + + await waitUntilIdle(); + testee.style.left = '1px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 1, y})); + + await waitUntilIdle(); + testee.style.left = '2px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x: x + 2, y})); + }); + + it('should emit until moved the element out of the viewport (moving element to the bottom)', async () => { + createDiv({ + id: 'container', + parent: document.body, + style: {position: 'absolute', top: '150px', left: '0', height: '100px', width: '100px', overflow: 'auto', background: 'gray'}, + children: [ + createDiv({id: 'testee', style: {position: 'absolute', top: '0', left: '0', height: '5px', width: '5px', background: 'blue'}}), + ], + }); + + let emissionCount = 0; + const emitCaptor = new ObserveCaptor(domRect => domRect.toJSON()); + const testee = queryElement('div#testee'); + const subscription = fromBoundingClientRect$(testee).subscribe(emitCaptor); + onDestroy(() => subscription.unsubscribe()); + + const {x, y} = testee.getBoundingClientRect(); + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y})); + + await waitUntilIdle(); + testee.style.top = '94px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 94})); + + await waitUntilIdle(); + testee.style.top = '95px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 95})); + + await waitUntilIdle(); + testee.style.top = '96px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 96})); + + await waitUntilIdle(); + testee.style.top = '97px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 97})); + + await waitUntilIdle(); + testee.style.top = '98px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 98})); + + await waitUntilIdle(); + testee.style.top = '99px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 99})); + + await waitUntilIdle(); + testee.style.top = '100px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 100})); + + await waitUntilIdle(); + testee.style.top = '101px'; // out of viewport + await expectAsync(emitCaptor.waitUntilEmitCount(emissionCount + 1, 250)).toBeRejected(); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 100})); + + await waitUntilIdle(); + testee.style.top = '102px'; // out of viewport + await expectAsync(emitCaptor.waitUntilEmitCount(emissionCount + 1, 250)).toBeRejected(); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 100})); + + await waitUntilIdle(); + testee.style.top = '101px'; // out of viewport + await expectAsync(emitCaptor.waitUntilEmitCount(emissionCount + 1, 250)).toBeRejected(); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 100})); + + await waitUntilIdle(); + testee.style.top = '100px'; // out of viewport + await expectAsync(emitCaptor.waitUntilEmitCount(emissionCount + 1, 250)).toBeRejected(); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 100})); + + await waitUntilIdle(); + testee.style.top = '99px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 99})); + + await waitUntilIdle(); + testee.style.top = '98px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 98})); + + await waitUntilIdle(); + testee.style.top = '97px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 97})); + + await waitUntilIdle(); + testee.style.top = '96px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 96})); + + await waitUntilIdle(); + testee.style.top = '95px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 95})); + + await waitUntilIdle(); + testee.style.top = '94px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 94})); + }); + + it('should emit until moved the element out of the viewport (moving element to the top)', async () => { createDiv({ - id: 'middle', style: {'background': '#571845', 'min-width': '200px'}, + id: 'container', + parent: document.body, + style: {position: 'absolute', top: '150px', left: '0', height: '100px', width: '100px', overflow: 'auto', background: 'gray'}, children: [ - createDiv({id: 'middle_1', style: {'background': '#900C3E'}}), - createDiv({ - id: 'middle_2', style: {'background': '#C70039', 'display': 'flex', 'flex-direction': 'column'}, children: [ - createDiv({id: 'middle_3', style: {'background': '#FF5733'}}), - createDiv({id: 'testee', style: {'background': '#FFC300', 'align-self': 'center', 'height': '30px', 'width': '100px'}}), - ], - }), + createDiv({id: 'testee', style: {position: 'absolute', top: '0', left: '0', height: '5px', width: '5px', background: 'blue'}}), ], - }), - createDiv({id: 'right', style: {'background': '#0618D6', 'min-width': '200px'}}), - ], + }); + + let emissionCount = 0; + const emitCaptor = new ObserveCaptor(domRect => domRect.toJSON()); + const testee = queryElement('div#testee'); + const subscription = fromBoundingClientRect$(testee).subscribe(emitCaptor); + onDestroy(() => subscription.unsubscribe()); + + const {x, y} = testee.getBoundingClientRect(); + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y})); + + await waitUntilIdle(); + testee.style.top = '2px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 2})); + + await waitUntilIdle(); + testee.style.top = '1px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 1})); + + await waitUntilIdle(); + testee.style.top = '0'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y})); + + await waitUntilIdle(); + testee.style.top = '-1px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y - 1})); + + await waitUntilIdle(); + testee.style.top = '-2px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y - 2})); + + await waitUntilIdle(); + testee.style.top = '-3px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y - 3})); + + await waitUntilIdle(); + testee.style.top = '-4px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y - 4})); + + await waitUntilIdle(); + testee.style.top = '-5px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y - 5})); + + await waitUntilIdle(); + testee.style.top = '-6px'; // out of viewport + await expectAsync(emitCaptor.waitUntilEmitCount(emissionCount + 1, 250)).toBeRejected(); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y - 5})); + + await waitUntilIdle(); + testee.style.top = '-7px'; // out of viewport + await expectAsync(emitCaptor.waitUntilEmitCount(emissionCount + 1, 250)).toBeRejected(); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y - 5})); + + await waitUntilIdle(); + testee.style.top = '-6px'; // out of viewport + await expectAsync(emitCaptor.waitUntilEmitCount(emissionCount + 1, 250)).toBeRejected(); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y - 5})); + + await waitUntilIdle(); + testee.style.top = '-5px'; // out of viewport + await expectAsync(emitCaptor.waitUntilEmitCount(emissionCount + 1, 250)).toBeRejected(); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y - 5})); + + await waitUntilIdle(); + testee.style.top = '-4px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y - 4})); + + await waitUntilIdle(); + testee.style.top = '-3px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y - 3})); + + await waitUntilIdle(); + testee.style.top = '-2px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y - 2})); + + await waitUntilIdle(); + testee.style.top = '-1px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y - 1})); + + await waitUntilIdle(); + testee.style.top = '0px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y})); + + await waitUntilIdle(); + testee.style.top = '1px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 1})); + + await waitUntilIdle(); + testee.style.top = '2px'; + await emitCaptor.waitUntilEmitCount(++emissionCount); + expect(emitCaptor.getLastValue()).toEqual(jasmine.objectContaining({x, y: y + 2})); + }); }); -} - -function $(selector: string): HTMLElement { - return document.querySelector(selector) as HTMLElement; -} -function addDelta(selector: string, delta: {minWidth?: number; minHeight?: number; width?: number; height?: number}): void { - const element = $(selector); - delta.width && setStyle(element, {'width': `${element.getBoundingClientRect().width + delta.width}px`}); - delta.height && setStyle(element, {'height': `${element.getBoundingClientRect().height + delta.height}px`}); - delta.minWidth && setStyle(element, {'min-width': `${element.getBoundingClientRect().width + delta.minWidth}px`}); - delta.minHeight && setStyle(element, {'min-height': `${element.getBoundingClientRect().height + delta.minHeight}px`}); -} + function onDestroy(callback: () => void): void { + disposables.push(callback); + } -function createDiv(options: ElementCreateOptions): HTMLElement { - const div = document.createElement('div'); - if (options.id !== 'root') { - div.innerText = options.id; + function createDiv(options: ElementCreateOptions): HTMLElement { + const div = document.createElement('div'); + destroyAfterEach && onDestroy(() => div.remove()); + options.id && (div.id = options.id); + options.style && setStyle(div, options.style); + options.parent?.appendChild(div); + options.children?.forEach(child => div.appendChild(child)); + return div; } - setStyle(div, { - 'margin': '5px', - 'padding': '5px', - 'box-sizing': 'border-box', - 'text-align': 'center', - 'font-family': 'sans-serif', + + afterEach(() => { + disposables.forEach(callback => callback()); + disposables.length = 0; }); - div.id = options.id; - options.style && setStyle(div, options.style); - options.parent?.appendChild(div); - options.children?.forEach(child => div.appendChild(child)); - return div; -} +}); interface ElementCreateOptions { - id: string; + id?: string; parent?: Node; style?: {[style: string]: any}; children?: Node[]; } -function setStyle(element: HTMLElement, style: {[style: string]: any | null}): void { - Object.keys(style).forEach(key => element.style.setProperty(key, style[key])); +/** + * Waits for the browser to become idle to have "realistic" Intersection Observer behavior. + * + * Without throttling, the Intersection Observer may emit events even if the element is outside the viewport, a behavior only observed in unit tests. + */ +async function waitUntilIdle(): Promise { + await new Promise(resolve => requestIdleCallback(() => resolve())); } -type RectId = 'div#left' | 'div#middle' | 'div#right' | 'div#middle_1' | 'div#middle_2' | 'div#middle_3' | 'div#testee'; - -class ClientRects { - - public rects = new Map(); - - /** - * Captures current bounding boxes. - */ - public capture(): ClientRects { - this.rects.clear(); - this.rects - .set('div#left', $('div#left')?.getBoundingClientRect()) - .set('div#middle', $('div#middle')?.getBoundingClientRect()) - .set('div#right', $('div#right')?.getBoundingClientRect()) - .set('div#middle_1', $('div#middle_1')?.getBoundingClientRect()) - .set('div#middle_2', $('div#middle_2')?.getBoundingClientRect()) - .set('div#middle_3', $('div#middle_3')?.getBoundingClientRect()) - .set('div#testee', $('div#testee')?.getBoundingClientRect()); - return this; - } +function queryElement(selector: string): HTMLElement { + return document.querySelector(selector) as HTMLElement; +} - /** - * Returns the last captured bounding box for the element. - */ - public get(selector: RectId): Readonly { - return this.rects.get(selector)!; - } +function setStyle(element: HTMLElement, style: {[style: string]: any | null}): void { + Object.keys(style).forEach(key => element.style.setProperty(key, style[key])); } 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 5edd0004..c670d251 100644 --- a/projects/scion/toolkit/observable/src/bounding-client-rect.observable.ts +++ b/projects/scion/toolkit/observable/src/bounding-client-rect.observable.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018-2019 Swiss Federal Railways + * 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 @@ -8,74 +8,231 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {fromEvent, merge, Observable, OperatorFunction, pipe} from 'rxjs'; -import {auditTime, distinctUntilChanged, map, startWith, switchMap} from 'rxjs/operators'; -import {fromMutation$} from './mutation.observable'; -import {fromDimension$} from './dimension.observable'; +import {animationFrameScheduler, distinctUntilChanged, fromEvent, Observable, observeOn, Subject, switchMap, takeUntil} from 'rxjs'; +import {fromIntersection$} from './intersection.observable'; +import {fromResize$} from './resize.observable'; /** - * Allows observing an element's bounding box, providing information about the element's size and position relative to the - * browser viewport. Refer to https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect for more information. + * Observes changes to the bounding box of a specified element. * - * Upon subscription, the Observable emits the element's current bounding box, and then continuously emits when its - * bounding box changes, e.g., due to a change in the layout. The Observable never completes. + * 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. * - * *** - * If you are only interested in element size changes and not position changes, consider using the {@link fromDimension$} Observable - * as it is more efficient because natively supported by the browser. - * *** + * Upon subscription, emits the current bounding box, and then continuously when the bounding box changes. The Observable never completes. * - * ### Note on the detection of position changes: + * The target element and the document root (``) must be positioned (`relative`, `absolute`, or `fixed`). If not, positioning is changed to `relative`. * - * There is, unfortunately, no native browser API to detect position changes of an element in a performant and reliable way. - * Our approach to detecting position changes of an element is based on the premise that it usually involves a parent or a parent's - * direct child changing in size. Repositioning can further occur when the user scrolls a parent container or when elements are added - * to or removed from the DOM. This covers most cases, but not all. + * Note: + * As of 2024, there is no native browser API to observe the position of an element. This implementation uses {@link IntersectionObserver} and + * {@link ResizeObserver} to detect position changes. For tracking only size changes, use {@link fromResize$} instead. * - * We are aware that this approach can be quite expensive, mainly because potentially a large number of elements need to be monitored - * for resizing/scrolling. Therefore, use this Observable only if you need to be informed about position changes. For pure dimension - * changes use the {@link fromDimension$} Observable instead. - * - * @see fromDimension$ + * @param element - The element to observe. + * @returns {Observable} An observable that emits the bounding box of the element. */ -export function fromBoundingClientRect$(element: HTMLElement): Observable> { - return fromMutation$(document.body, {childList: true, subtree: true}) - .pipe( - startWith(undefined as void), - map(() => collectElements(element)), - detectLayoutChange(), - map(() => captureClientRect(element)), - distinctUntilChanged((a, b) => a.left === b.left && a.top === b.top && a.width === b.width && a.height === b.height), - ); +export function fromBoundingClientRect$(element: HTMLElement): Observable { + return new Observable(observer => { + const clientRectObserver = new BoundingClientRectObserver(element, clientRect => observer.next(clientRect)); + return () => clientRectObserver.destroy(); + }); } /** - * Collects elements that can affect the given element's size and position. + * Observes the position and size of an element using {@link IntersectionObserver} and {@link ResizeObserver}. + * + * Since there is no native browser API to observe element position, we create four vertices and set up + * an {@link IntersectionObserver} for each vertex. Configured with root margins aligned to the vertices' + * bounding boxes, we can detect when the element (specifically its vertices) moves. We then emit the changed + * bounding box of the element, recompute the root margins for each vertex, and restart observations. + * + * Observing vertices instead of the element itself enables position change detection even if the element is partially + * scrolled out of the viewport. */ -function collectElements(element: HTMLElement): HTMLElement[] { - const elements: HTMLElement[] = []; +class BoundingClientRectObserver { + + private readonly _destroy$ = new Subject(); + private readonly _clientRect$ = new Subject(); + private readonly _vertices: { + topLeft: Vertex; + topRight: Vertex; + bottomRight: Vertex; + bottomLeft: Vertex; + }; + + constructor(private _element: HTMLElement, onChange: (clientRect: DOMRect) => void) { + ensureElementPositioned(document.documentElement); + ensureElementPositioned(this._element); + + this._vertices = { + topLeft: new Vertex(this._element, {top: 0, left: 0}, () => this.emitClientRect()), + topRight: new Vertex(this._element, {top: 0, right: 0}, () => this.emitClientRect()), + bottomRight: new Vertex(this._element, {bottom: 0, right: 0}, () => this.emitClientRect()), + bottomLeft: new Vertex(this._element, {bottom: 0, left: 0}, () => this.emitClientRect()), + }; + + this.installElementResizeObserver(); + this.installDocumentResizeObserver(); + this.installChangeEmitter(onChange); + } + + /** + * Monitors changes to the element's size. + */ + private installElementResizeObserver(): void { + fromResize$(this._element, {box: 'border-box'}) + .pipe( + observeOn(animationFrameScheduler), // to not block resize callback (ResizeObserver loop completed with undelivered notifications) + takeUntil(this._destroy$), + ) + .subscribe(() => { + this.emitClientRect(); // emit for instant update + this.forEachVertex(vertex => vertex.computeRootMargin()); + }); + } - for (let el = element.parentElement; el !== null; el = el.parentElement) { - elements.push(...Array.from(el.children).filter(child => child instanceof HTMLElement) as HTMLElement[]); + /** + * Monitors changes to the document's size. + */ + private installDocumentResizeObserver(): void { + fromEvent(window, 'resize') + .pipe(takeUntil(this._destroy$)) + .subscribe(() => { + this.emitClientRect(); // emit for instant update + this.forEachVertex(vertex => vertex.computeRootMargin()); + }); + } + + 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), + takeUntil(this._destroy$), + ) + .subscribe(boundingBox => { + onChange(boundingBox); + }); + } + + private emitClientRect(): void { + this._clientRect$.next(this._element.getBoundingClientRect()); + } + + private forEachVertex(fn: (vertex: Vertex) => void): void { + Object.values(this._vertices).forEach(fn); + } + + public destroy(): void { + this.forEachVertex(vertex => vertex.destroy()); + this._destroy$.next(); + } +} + +class Vertex { + + private readonly _element: HTMLElement; + private readonly _rootMargin$ = new Subject(); + private readonly _destroy$ = new Subject(); + + constructor(parent: HTMLElement, + position: {top?: 0; right?: 0; bottom?: 0; left?: 0}, + private _onPositionChange: () => void) { + this._element = parent.appendChild(this.createVertexElement(position)); + this.installIntersectionObserver(); + this.computeRootMargin(); + } + + /** + * Computes the negative margins ("top right bottom left") to clip this vertex's bounding box. + */ + public computeRootMargin(): void { + const documentRoot = document.documentElement; + const documentClientRect = documentRoot.getBoundingClientRect(); + const elementClientRect = this._element.getBoundingClientRect(); + + const top = elementClientRect.top + documentRoot.scrollTop; + const left = elementClientRect.left + documentRoot.scrollLeft; + const right = documentClientRect.width - elementClientRect.right - documentRoot.scrollLeft; + const bottom = documentClientRect.height - elementClientRect.bottom - documentRoot.scrollTop; + this._rootMargin$.next(`${Math.ceil(-1 * top)}px ${Math.ceil(-1 * right)}px ${Math.ceil(-1 * bottom)}px ${Math.ceil(-1 * left)}px`); + } + + private installIntersectionObserver(): void { + this._rootMargin$ + .pipe( + distinctUntilChanged(), + switchMap(rootMargin => fromIntersection$(this._element, {rootMargin, threshold: 1, root: document.documentElement})), + takeUntil(this._destroy$), + ) + .subscribe((entries: IntersectionObserverEntry[]) => { + const isIntersecting = entries.at(-1)?.isIntersecting ?? false; + if (!isIntersecting) { + this._onPositionChange(); + this.computeRootMargin(); + } + }); + + // Recompute the root margin when the element re-enters the viewport. + fromIntersection$(this._element, {threshold: 1, root: document}) + .pipe(takeUntil(this._destroy$)) + .subscribe((entries: IntersectionObserverEntry[]) => { + const isIntersecting = entries.at(-1)?.isIntersecting ?? false; + if (isIntersecting) { + this._onPositionChange(); + this.computeRootMargin(); + } + }); + } + + private createVertexElement(position: {top?: 0; right?: 0; bottom?: 0; left?: 0}): HTMLElement { + const element = document.createElement<'div'>('div'); + setStyle(element, { + 'position': 'absolute', + 'top': position.top === 0 ? '0' : null, + 'right': position.right === 0 ? '0' : null, + 'bottom': position.bottom === 0 ? '0' : null, + 'left': position.left === 0 ? '0' : null, + 'width': '1px', + 'height': '1px', + 'visibility': 'hidden', + 'pointer-events': 'none', + }); + return element; + } + + public destroy(): void { + this._element.remove(); + this._destroy$.next(); } - return elements; } /** - * Emits whenever one of the source elements changes in size or scrolls. + * Ensures that the given HTML element is positioned, setting its position to `relative` if it is not already positioned. */ -function detectLayoutChange(): OperatorFunction { - return pipe( - switchMap(elements => merge(...elements.map(element => merge( - fromDimension$(element), - fromEvent(element, 'scroll', {passive: true})), - ))), - map(() => undefined), - // Debounce to a single emission as a layout change can cause multiple elements to change. - auditTime(25), - ); +function ensureElementPositioned(element: HTMLElement): void { + if (getComputedStyle(element).position !== 'static') { + return; + } + + // Position the HTML root using a constructable stylesheet to not clutter its element styles. + if (element === document.documentElement) { + const styleSheet = new CSSStyleSheet({}); + styleSheet.insertRule(`html { position: relative; }`); + document.adoptedStyleSheets.push(styleSheet); + } + else { + setStyle(element, {position: 'relative'}); + } } -function captureClientRect(element: HTMLElement): Readonly { - return element.getBoundingClientRect(); +/** + * Apples specified styles for given element. + */ +function setStyle(element: HTMLElement, styles: {[style: string]: string | null}): void { + Object.entries(styles).forEach(([name, value]) => { + if (value === null) { + element.style.removeProperty(name); + } + else { + element.style.setProperty(name, value); + } + }); } diff --git a/projects/scion/toolkit/observable/src/dimension.observable.ts b/projects/scion/toolkit/observable/src/dimension.observable.ts index 7faa25a4..0223ac23 100644 --- a/projects/scion/toolkit/observable/src/dimension.observable.ts +++ b/projects/scion/toolkit/observable/src/dimension.observable.ts @@ -18,6 +18,8 @@ import {Observable, Observer, TeardownLogic} from 'rxjs'; * * @param target - HTMLElement to observe its dimension. * @return Observable that emits dimension changes of the passed element. + * + * @deprecated since version 1.4.2; use {@link fromResize$} instead; API will be removed in version 2.0. */ export function fromDimension$(target: HTMLElement): Observable { return new Observable((observer: Observer): TeardownLogic => { @@ -32,6 +34,8 @@ export function fromDimension$(target: HTMLElement): Observable { /** * Captures the dimension of the given element. + * + * @deprecated since version 1.4.2; use {@link HTMLElement.getBoundingClientRect} instead; API will be removed in version 2.0. */ export function captureElementDimension(element: HTMLElement): Dimension { return { diff --git a/projects/scion/toolkit/observable/src/intersection.observable.ts b/projects/scion/toolkit/observable/src/intersection.observable.ts new file mode 100644 index 00000000..709a732b --- /dev/null +++ b/projects/scion/toolkit/observable/src/intersection.observable.ts @@ -0,0 +1,30 @@ +/* + * 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 {Observable} from 'rxjs'; + +/** + * Wraps the native {@link IntersectionObserver} in an RxJS Observable to observe intersection of an element. + * + * Upon subscription, emits the current intersection state, and then continuously when the intersection state changes. The Observable never completes. + * + * For more details, see https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API. + * + * @param element - Specifies the element to observe. + * @param options - Configures {@link IntersectionObserver}. + * For more details, see https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API. + */ +export function fromIntersection$(element: Element, options?: IntersectionObserverInit): Observable { + return new Observable(observer => { + const intersectionObserver = new IntersectionObserver(entries => observer.next(entries), options); + intersectionObserver.observe(element); + return () => intersectionObserver.disconnect(); + }); +} diff --git a/projects/scion/toolkit/observable/src/mutation.observable.ts b/projects/scion/toolkit/observable/src/mutation.observable.ts index b195eb76..91913416 100644 --- a/projects/scion/toolkit/observable/src/mutation.observable.ts +++ b/projects/scion/toolkit/observable/src/mutation.observable.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018-2019 Swiss Federal Railways + * 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 @@ -8,25 +8,21 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {Observable, Observer, TeardownLogic} from 'rxjs'; +import {Observable} from 'rxjs'; /** - * Allows watching for changes being made to the DOM tree of an HTML element. It never completes. + * Wraps the native {@link MutationObserver} in an RxJS Observable to observe mutations of an element. * - * Wraps a {MutationObserver} in an Observable to watch for changes being made to the DOM tree. - * See https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver for more information. + * For more details, see https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver. * - * @param target - HTMLElement to observe. - * @param options - describes the configuration of a mutation observer - * See https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit + * @param element - Specifies the element to observe. + * @param options - Configures {@link MutationObserver}. + * For more details, see https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit. */ -export function fromMutation$(target: Node, options?: MutationObserverInit): Observable { - return new Observable((observer: Observer): TeardownLogic => { +export function fromMutation$(element: Node, options?: MutationObserverInit): Observable { + return new Observable(observer => { const mutationObserver = new MutationObserver((mutations: MutationRecord[]): void => observer.next(mutations)); - mutationObserver.observe(target, options); - - return (): void => { - mutationObserver.disconnect(); - }; + mutationObserver.observe(element, options); + return () => mutationObserver.disconnect(); }); } diff --git a/projects/scion/toolkit/observable/src/public_api.ts b/projects/scion/toolkit/observable/src/public_api.ts index d59c8927..38575bcf 100644 --- a/projects/scion/toolkit/observable/src/public_api.ts +++ b/projects/scion/toolkit/observable/src/public_api.ts @@ -15,5 +15,7 @@ * @see https://github.com/ng-packagr/ng-packagr/blob/master/docs/secondary-entrypoints.md */ export {captureElementDimension, fromDimension$, Dimension} from './dimension.observable'; +export {fromResize$} from './resize.observable'; +export {fromIntersection$} from './intersection.observable'; export {fromMutation$} from './mutation.observable'; export {fromBoundingClientRect$} from './bounding-client-rect.observable'; diff --git a/projects/scion/toolkit/observable/src/resize.observable.ts b/projects/scion/toolkit/observable/src/resize.observable.ts new file mode 100644 index 00000000..70b4c4ef --- /dev/null +++ b/projects/scion/toolkit/observable/src/resize.observable.ts @@ -0,0 +1,30 @@ +/* + * 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 {Observable} from 'rxjs'; + +/** + * Wraps the native {@link ResizeObserver} in an RxJS Observable to observe resizing of an element. + * + * Upon subscription, emits the current size, and then continuously when the size changes. The Observable never completes. + * + * For more details, see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver. + * + * @param element - Specifies the element to observe. + * @param options - Configures {@link ResizeObserver}. + * For more details, see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver. + */ +export function fromResize$(element: Element, options?: ResizeObserverOptions): Observable { + return new Observable(observer => { + const resizeObserver = new ResizeObserver(entries => observer.next(entries)); + resizeObserver.observe(element, options); + return () => resizeObserver.disconnect(); + }); +}