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 @@

index-minimal.html.

- 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 @@

- \ No newline at end of file + diff --git a/demo/table-demo.html b/demo/table-demo.html deleted file mode 100644 index 2e956921..00000000 --- a/demo/table-demo.html +++ /dev/null @@ -1,153 +0,0 @@ - - - - - vscroll table demo - - - - - - -

VScroll Table Demo (CDN)

-
- - - - - - - - - - - - - - - - -
NumText
-
- - - - \ No newline at end of file diff --git a/demo/toolbar-demo.html b/demo/toolbar-demo.html new file mode 100644 index 00000000..bb5cf9d3 --- /dev/null +++ b/demo/toolbar-demo.html @@ -0,0 +1,128 @@ + + + + + vscroll custom Routines demo + + + + + + +

VScroll Custom Routines Demo (CDN)

+

+ Custom Routines exclude the sticky toolbar from the measured viewport. Only rows below it are + reported as visible. +

+

+ + +

+
+
Visible items:
+
+
+
+
+
+ + + + diff --git a/docs/routines.md b/docs/routines.md index 2e96b72f..7236ce5e 100644 --- a/docs/routines.md +++ b/docs/routines.md @@ -16,20 +16,36 @@ The exported `IRoutines` interface describes the class contract. The following i | `viewport` | `HTMLElement` | Viewport element determined by `getViewportElement()`. | | `settings` | `IRoutines['settings']` | Reduced settings: `viewport` is the explicitly configured viewport or `null`; `horizontal` selects horizontal mode; `window` holds the value of `windowViewport`. | -For example, the [table demo](../demo/table-demo.html) uses `tbody` as the content element and explicitly selects an outer scrollable container through `settings.viewportElement`. The `getOffset()` method determines the list's offset relative to the viewport. For this layout, the override sets it to the height of the preceding table header: +For example, a sticky toolbar can cover the top of a vertical element viewport. Default Routines measure the full viewport, so rows behind the toolbar can still be reported as visible. `scroll-padding-top` accounts for a 40px toolbar when the browser scrolls to an item: + +```css +#viewport { scroll-padding-top: 40px; } +``` + +`ToolbarRoutines` applies the same inset to the viewport's measured rectangle: ```js import { Routines, Workflow } from 'vscroll'; -class TableRoutines extends Routines { - getOffset() { - return this.viewport.querySelector('thead')?.offsetHeight || 0; +class ToolbarRoutines extends Routines { + getElementParams(element) { + const rect = super.getElementParams(element); + // Crop only the viewport; row and content measurements stay unchanged. + if (element === this.viewport) { + // The CSS inset matches the toolbar's height in pixels. + const inset = parseFloat(getComputedStyle(element).scrollPaddingTop) || 0; + rect.y += inset; // Move the top edge below the toolbar. + rect.height -= inset; // Keep the bottom edge unchanged. + } + return rect; } } -new Workflow({ Routines: TableRoutines, ... }); +new Workflow({ Routines: ToolbarRoutines, ... }); ``` +The adjusted top edge and height describe the uncovered area; row and content measurements remain unchanged. See the [working demo](https://dhilt.github.io/vscroll/toolbar-demo.html). + The inherited constructor initializes `element` and reduced `settings`, assigns `viewport` from `getViewportElement()`, then calls `onInit(settings)`. Both hooks run before subclass fields are initialized; `this.viewport` is not yet available inside `getViewportElement()`. A custom constructor must forward its arguments to `super`; overriding it is usually unnecessary. ## Hiding and revealing new rows @@ -127,7 +143,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 { 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); + } + }); });