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