From 7ca7cf3d3158398c49a2f3d6fa067775c1816fbe Mon Sep 17 00:00:00 2001
From: Denis Hilt
Date: Mon, 12 Oct 2026 13:21:58 +0200
Subject: [PATCH 1/3] Fix content offset calculation with positioned ancestors
Use measured edges and scroll position to keep offsets consistent for element and window viewports. Update the getOffset documentation.
---
docs/routines.md | 2 +-
src/classes/domRoutines.ts | 8 +++++---
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/docs/routines.md b/docs/routines.md
index 2e96b72f..a1f89f8f 100644
--- a/docs/routines.md
+++ b/docs/routines.md
@@ -127,7 +127,7 @@ Sizes and coordinates are expressed in CSS pixels. The active dimension is heigh
| `setSizeStyle(element: HTMLElement, value: number): void` | Rounds the size, clamps it to zero or greater, and writes inline `height` or `width` in pixels. Used for padding elements. |
| `getEdge(element: HTMLElement, direction: Direction): number` | Returns the corresponding edge from `getElementParams(element)`. |
| `getViewportEdge(direction: Direction): number` | Returns the corresponding edge of the window rectangle in window mode; otherwise, `getEdge(viewport, direction)`. |
-| `getOffset(): number` | Returns the content element's `offsetTop` or `offsetLeft`, minus the viewport's corresponding offset. No subtraction is performed in window mode. |
+| `getOffset(): number` | Returns the content element's top/left offset from the viewport at scroll position zero, using measured edges and current scroll position. In window mode, returns its document-relative position. |
### Visibility and scrolling
diff --git a/src/classes/domRoutines.ts b/src/classes/domRoutines.ts
index c8bf9a9f..ece3e205 100644
--- a/src/classes/domRoutines.ts
+++ b/src/classes/domRoutines.ts
@@ -164,9 +164,11 @@ export class Routines implements IRoutines {
}
getOffset(): number {
- const get = (element: HTMLElement) =>
- (this.settings.horizontal ? element.offsetLeft : element.offsetTop) || 0;
- return get(this.element) - (!this.settings.window ? get(this.viewport) : 0);
+ return (
+ this.getEdge(this.element, Direction.backward) -
+ this.getViewportEdge(Direction.backward) +
+ this.getScrollPosition()
+ );
}
scrollTo(element: HTMLElement, argument?: boolean | ScrollIntoViewOptions): void {
From 835f43aa92a897617dfe8aad0680c90ae490702a Mon Sep 17 00:00:00 2001
From: Denis Hilt
Date: Mon, 12 Oct 2026 13:22:48 +0200
Subject: [PATCH 2/3] Add e2e regressions for positioned viewport layouts
Cover parent and external viewports on both axes, plus window mode.
Verify starting positions and stable offsets across scrolling and reload.
---
tests/e2e/specs/viewport.spec.ts | 117 +++++++++++++++++++++++++++++++
1 file changed, 117 insertions(+)
diff --git a/tests/e2e/specs/viewport.spec.ts b/tests/e2e/specs/viewport.spec.ts
index 544aed5d..8b654ecf 100644
--- a/tests/e2e/specs/viewport.spec.ts
+++ b/tests/e2e/specs/viewport.spec.ts
@@ -2,6 +2,7 @@ import {
getDatasource,
makeTest,
Misc,
+ MutableDatasource,
TestConfig
} from '../scaffolding';
@@ -56,6 +57,80 @@ const testWindowViewport =
expect(misc.adapter.firstVisible.$index).toEqual(index);
};
+const offsetConfigs: TestConfig[] = [false, true].map(horizontal => ({
+ datasource: () => new MutableDatasource(1, 100),
+ datasourceSettings: {
+ startIndex: 1,
+ minIndex: 1,
+ maxIndex: 100,
+ itemSize: 20,
+ horizontal
+ },
+ templateSettings: {
+ viewportHeight: 120,
+ viewportWidth: 120,
+ itemHeight: 20,
+ itemWidth: 20,
+ horizontal
+ }
+}));
+
+const externalOffsetConfigs: TestConfig[] = offsetConfigs.map(config => ({
+ ...config,
+ datasourceSettings: {
+ ...config.datasourceSettings,
+ viewportElement: () =>
+ document.querySelector('[data-test-viewport]')
+ }
+}));
+
+const prepareOffsetLayout = (misc: Misc, external: boolean): void => {
+ const { root, viewportElement, contentElement, horizontal } = misc;
+ root.style[horizontal ? 'paddingLeft' : 'paddingTop'] = '100px';
+ const header = document.createElement('div');
+ header.style[horizontal ? 'width' : 'height'] = '40px';
+ if (horizontal) {
+ header.style.display = 'inline-block';
+ }
+ viewportElement.prepend(header);
+
+ if (external) {
+ const wrapper = document.createElement('div');
+ wrapper.style.position = 'relative';
+ if (horizontal) {
+ wrapper.style.display = 'inline-block';
+ }
+ viewportElement.append(wrapper);
+ wrapper.append(contentElement);
+ } else {
+ viewportElement.style.position = 'relative';
+ viewportElement.style.border = '3px solid transparent';
+ }
+};
+
+const testOffset = (external: boolean) => (misc: Misc) => async () => {
+ misc.trackVisibleItems();
+ await misc.relaxNext();
+
+ const offset = external ? 40 : 43;
+ misc.expect.startVisible(1);
+ expect(misc.getScrollPosition()).toBe(0);
+ expect(misc.scroller.viewport.offset).toBe(offset);
+ expect(misc.adapter.itemsCount).toBeLessThan(100);
+ expect(misc.source().requests).toHaveLength(1);
+
+ await misc.scrollToRelax(offset + 200);
+ misc.expect.startVisible(11);
+ expect(misc.scroller.routines.getOffset()).toBe(offset);
+
+ await misc.adapter.reload(51);
+ misc.expect.startVisible(51);
+ expect(misc.scroller.viewport.offset).toBe(offset);
+
+ await misc.scrollMinRelax();
+ misc.expect.startVisible(1);
+};
+
describe('Viewport Spec', () => {
describe('Entire Window with Header', () => {
configs.forEach(config =>
@@ -70,4 +145,46 @@ describe('Viewport Spec', () => {
})
);
});
+
+ describe('Positioned layouts', () => {
+ [false, true].forEach(external =>
+ (external ? externalOffsetConfigs : offsetConfigs).forEach(config =>
+ makeTest({
+ config,
+ meta: external ? 'external viewport' : 'parent viewport',
+ title:
+ 'should preserve the starting index across scrolling and reload',
+ before: misc => prepareOffsetLayout(misc, external),
+ it: testOffset(external)
+ })
+ )
+ );
+ });
+
+ makeTest({
+ config: {
+ ...offsetConfigs[0],
+ datasourceSettings: {
+ ...offsetConfigs[0].datasourceSettings,
+ windowViewport: true,
+ startIndex: 21
+ }
+ },
+ title: 'should preserve the starting index in a positioned window wrapper',
+ before: misc => prepareOffsetLayout(misc, true),
+ it: misc => async () => {
+ misc.trackVisibleItems();
+ await misc.relaxNext();
+
+ misc.expect.startVisible(21);
+ expect(misc.scroller.viewport.offset).toBe(140);
+
+ await misc.scrollToRelax(600);
+ expect(misc.scroller.routines.getOffset()).toBe(140);
+
+ await misc.adapter.reload(21);
+ expect(misc.scroller.viewport.offset).toBe(140);
+ misc.expect.startVisible(21);
+ }
+ });
});
From 508ae14e93f6718ac629927cc20b201ed4c28952 Mon Sep 17 00:00:00 2001
From: Denis Hilt
Date: Mon, 12 Oct 2026 14:17:15 +0200
Subject: [PATCH 3/3] Replace obsolete table demo with a sticky-toolbar example
Update the Custom Routines guide and demo links.
Add visible-range tracking, scroll and reload controls.
---
demo/index.html | 5 +-
demo/table-demo.html | 153 -----------------------------------------
demo/toolbar-demo.html | 128 ++++++++++++++++++++++++++++++++++
docs/routines.md | 26 +++++--
4 files changed, 152 insertions(+), 160 deletions(-)
delete mode 100644 demo/table-demo.html
create mode 100644 demo/toolbar-demo.html
diff --git a/demo/index.html b/demo/index.html
index 7f80baba..0e30d131 100644
--- a/demo/index.html
+++ b/demo/index.html
@@ -39,7 +39,8 @@
- Another minimal demo is for table layout: table-demo.html.
+ A Custom Routines demo shows how a sticky toolbar
+ can be excluded from the visible area.
@@ -53,4 +54,4 @@