diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ae676c7..e9698266d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Setting `min` or `max` with a value before the first render no longer throws a `TypeError`. - #### Color picker - The HSL saturation no longer becomes infinite for a very small saturation at full value. +- #### Virtual scroll + - `layoutComplete` now resolves after the rendered items are measured. Before, it could resolve first, so `scrollToIndex` stopped its correction early, and an item with a size other than the estimate landed up to tens of pixels from the requested edge. + - A list larger than the maximum scroll size of the browser now shows its last items at the end of the scroll range, and `scrollToIndex` puts the item at the requested edge. The items also move evenly during a scroll. Before, the last items could not be reached, the item landed tens of pixels off, and the items jumped by some pixels each time the rendered window changed. ## [7.4.1] - 2026-09-25 ### Added diff --git a/src/components/virtualization/engine.spec.ts b/src/components/virtualization/engine.spec.ts index ebe38692e..4be525a1f 100644 --- a/src/components/virtualization/engine.spec.ts +++ b/src/components/virtualization/engine.spec.ts @@ -551,7 +551,11 @@ describe('VirtualScrollEngine', () => { describe('Coordinate compression', () => { const MAX_SIZE = 10_000; - const ITEMS = 1000; // 50_000px total, a ratio of 5 + const ITEMS = 1000; // 50_000px total + const VIEWPORT = 300; + // The virtual scroll range over the DOM one: 49_700 / 9_700. + const RATIO = (50_000 - VIEWPORT) / (MAX_SIZE - VIEWPORT); + const EPSILON = 1e-6; it('clamps the DOM size to the maximum browser size', () => { const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS); @@ -570,11 +574,50 @@ describe('VirtualScrollEngine', () => { it('maps DOM scroll positions onto the virtual space', () => { const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS); - // Halfway down the DOM range is halfway down the virtual range. - expect(engine.getVisibleRange(MAX_SIZE / 2, 300, 0).startIndex).to.equal( - 500 + // Halfway down the DOM scroll range (4850px) is halfway down the + // virtual one (24_850px), where item 497 starts. One DOM px past it + // stays clear of the rounding at the item boundary. + expect(engine.getVisibleRange(4851, VIEWPORT, 0).startIndex).to.equal( + 497 + ); + expect(engine.getScrollOffsetForIndex(497, VIEWPORT)).to.be.closeTo( + 4850, + EPSILON + ); + }); + + it('shows the end of the list at the largest DOM scroll offset', () => { + const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS); + const maxOffset = MAX_SIZE - VIEWPORT; + + expect(engine.getVisibleRange(maxOffset, VIEWPORT, 0).endIndex).to.equal( + ITEMS - 1 + ); + expect( + engine.getAlignedScrollOffset(ITEMS - 1, VIEWPORT, 'end') + ).to.be.closeTo(maxOffset, EPSILON); + }); + + it('places the content so the viewport shows its virtual position', () => { + const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS); + + // At DOM offset 4850, the viewport starts at virtual offset 24_850, so + // item 497 is at the top edge, and the items before it are at their + // real size above it. + expect(engine.getContentOffset(497, 4850, VIEWPORT)).to.be.closeTo( + 4850, + EPSILON + ); + expect(engine.getContentOffset(495, 4850, VIEWPORT)).to.be.closeTo( + 4850 - 2 * ESTIMATE, + EPSILON + ); + + // Without compression, the offset is that of the item at any scroll. + const uncompressed = createEngineWithMaxSize(MAX_SIZE, 100); + expect(uncompressed.getContentOffset(10, 1234, VIEWPORT)).to.equal( + 10 * ESTIMATE ); - expect(engine.getScrollOffsetForIndex(500)).to.equal(MAX_SIZE / 2); }); it('sizes the rendered window by the viewport, not by the ratio', () => { @@ -588,26 +631,26 @@ describe('VirtualScrollEngine', () => { it('converts the alignment slack into DOM space', () => { const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS); - const start = engine.getAlignedScrollOffset(500, 300, 'start'); - const centered = engine.getAlignedScrollOffset(500, 300, 'center'); + const start = engine.getAlignedScrollOffset(500, VIEWPORT, 'start'); + const centered = engine.getAlignedScrollOffset(500, VIEWPORT, 'center'); - // The slack is 125 virtual px, which is 25 DOM px at a ratio of 5. - expect(start).to.equal(MAX_SIZE / 2); - expect(centered).to.equal(MAX_SIZE / 2 - 25); + // The slack is 125 virtual px, which is 125 / RATIO DOM px. + expect(start).to.be.closeTo(25_000 / RATIO, EPSILON); + expect(centered).to.be.closeTo((25_000 - 125) / RATIO, EPSILON); }); it('resolves nearest in DOM space', () => { const engine = createEngineWithMaxSize(MAX_SIZE, ITEMS); - // DOM offset 2000 is virtual offset 10_000. Item 300 spans - // 15_000-15_050, after the viewport. - expect(engine.resolveScrollOffset(300, 2000, 300, 'nearest')).to.equal( - (15_050 - 300) / 5 - ); + // DOM offset 2000 is virtual offset 2000 * RATIO, about 10_247. Item + // 300 spans 15_000-15_050, after the viewport. + expect( + engine.resolveScrollOffset(300, 2000, VIEWPORT, 'nearest') + ).to.be.closeTo((15_050 - VIEWPORT) / RATIO, EPSILON); // Item 100 spans 5000-5050, before the viewport. - expect(engine.resolveScrollOffset(100, 2000, 300, 'nearest')).to.equal( - 5000 / 5 - ); + expect( + engine.resolveScrollOffset(100, 2000, VIEWPORT, 'nearest') + ).to.be.closeTo(5000 / RATIO, EPSILON); }); it('probes a given document only once', () => { diff --git a/src/components/virtualization/engine.ts b/src/components/virtualization/engine.ts index 49af16f02..217122e36 100644 --- a/src/components/virtualization/engine.ts +++ b/src/components/virtualization/engine.ts @@ -257,21 +257,20 @@ class SizeTree { * Browsers limit how far an element can scroll. When the total item size is * larger than that limit, the engine compresses the *virtual* space * (`0…totalSize`) into the *DOM* space the browser can represent - * (`0…domSize`) by the factor `_virtualRatio`. Each offset that crosses that + * (`0…domSize`). The ratio maps the scroll ranges onto each other: + * `(totalSize - viewportSize) / (domSize - viewportSize)`, so the last DOM + * scroll position shows the end of the list. Each offset that crosses that * boundary is scaled: incoming scroll positions are multiplied by the ratio, - * and outgoing offsets are divided by it. Items render at their real pixel - * size, so item sizes are always virtual. + * and outgoing offsets are divided by it. + * + * Items render at their real pixel size, so item sizes are always virtual. + * The viewport shows the virtual pixels from `scrollPosition * ratio`, so the + * content moves by the ratio for each DOM pixel of scroll. See + * `getContentOffset`. */ export class VirtualScrollEngine { private _maxBrowserSize = Number.POSITIVE_INFINITY; - /** - * Maps a virtual scroll position to a DOM scroll position. The ratio - * `totalSize / maxBrowserSize` if `totalSize` is larger than the maximum DOM - * coordinate of the browser, and `1` in all other cases. - */ - private _virtualRatio = 1; - /** Binary Indexed Tree for O(log N) size queries and updates. */ private _tree: SizeTree | null = null; @@ -294,13 +293,17 @@ export class VirtualScrollEngine { /** Total size in DOM space, clamped to the maximum browser size. */ public get domSize(): number { - return this._virtualRatio !== 1 ? this._maxBrowserSize : this.totalSize; + return Math.min(this.totalSize, this._maxBrowserSize); + } + + /** Whether the virtual space is compressed into a smaller DOM space. */ + public get isCompressed(): boolean { + return this.totalSize > this._maxBrowserSize; } - /** Measures the maximum browser size for the document and rescales. */ + /** Measures the maximum browser size for the document. */ public initMaxBrowserSize(doc: Document): void { this._maxBrowserSize = getMaxBrowserSizeProbePx(doc); - this._updateVirtualRatio(); } /** @@ -330,7 +333,6 @@ export class VirtualScrollEngine { tree.estimate = estimatedSize; } this._tree = tree; - this._updateVirtualRatio(); this.onSizeChange?.(); } @@ -338,7 +340,6 @@ export class VirtualScrollEngine { public measureItem(index: number, size: number): void { if (!this._tree?.update(index, size)) return; - this._updateVirtualRatio(); this.onSizeChange?.(); } @@ -373,13 +374,39 @@ export class VirtualScrollEngine { /** * Returns the DOM scroll offset in px that puts the item at `index` at the - * leading edge of the viewport. + * leading edge of a `viewportSize` px viewport. Not clamped to the + * reachable scroll range, see `getAlignedScrollOffset`. */ - public getScrollOffsetForIndex(index: number): number { + public getScrollOffsetForIndex(index: number, viewportSize = 0): number { if (!this._tree || index <= 0) return 0; const clamped = Math.min(index, this._tree.length); - return this._tree.prefixSum(clamped) / this._virtualRatio; + return this._tree.prefixSum(clamped) / this._ratio(viewportSize); + } + + /** + * Returns the DOM offset of the content wrapper, whose first item is at + * `startIndex`, for the given scroll state. The items then show the virtual + * pixels from `scrollPosition * ratio` at the leading edge of the viewport, + * as `getVisibleRange` and the alignment math expect. + * + * Without compression, the result is the offset of the item and does not + * depend on `scrollPosition`. With it, the result changes on each scroll, so + * the content moves by the ratio for each DOM pixel. + */ + public getContentOffset( + startIndex: number, + scrollPosition: number, + viewportSize: number + ): number { + if (!this._tree || this._tree.length === 0) return 0; + + const start = this._tree.prefixSum( + clampIndex(startIndex, this._tree.length) + ); + return ( + start - Math.max(0, scrollPosition) * (this._ratio(viewportSize) - 1) + ); } /** @@ -397,9 +424,8 @@ export class VirtualScrollEngine { * reachable scroll range. * * The slack is computed in virtual space against the item's real size and - * converted to DOM space once, at the end. One DOM pixel equals - * `_virtualRatio` virtual pixels, so mixed coordinates would scale the - * slack. + * converted to DOM space once, at the end. One DOM pixel equals `_ratio` + * virtual pixels, so mixed coordinates would scale the slack. */ public getAlignedScrollOffset( index: number, @@ -419,7 +445,7 @@ export class VirtualScrollEngine { } return clamp( - offset / this._virtualRatio, + offset / this._ratio(viewportSize), 0, this._getMaxScrollOffset(viewportSize) ); @@ -466,7 +492,7 @@ export class VirtualScrollEngine { const clamped = clampIndex(index, this._tree.length); const itemStart = this._tree.prefixSum(clamped); const itemEnd = this._tree.prefixSum(clamped + 1); - const viewStart = Math.max(0, scrollPosition) * this._virtualRatio; + const viewStart = Math.max(0, scrollPosition) * this._ratio(viewportSize); const viewEnd = viewStart + viewportSize; const contained = itemStart >= viewStart && itemEnd <= viewEnd; @@ -491,7 +517,7 @@ export class VirtualScrollEngine { // The virtual ratio does not scale the viewport. Items render at their real // pixel size, so a `viewportSize` px viewport shows that many virtual pixels // of items at any compression of the scroll range. - const startOffset = Math.max(0, scrollPosition) * this._virtualRatio; + const startOffset = Math.max(0, scrollPosition) * this._ratio(viewportSize); const first = this._tree.findIndexAtOffset(startOffset); const last = this._tree.findIndexAtOffset(startOffset + viewportSize); @@ -502,9 +528,7 @@ export class VirtualScrollEngine { } /** - * Sum of the actual sizes of the items in [startIndex, endIndex]. The - * render pass uses it to clamp the content translate offset, so rendered - * items do not overflow past `domSize` under coordinate compression. + * Sum of the actual sizes of the items in [startIndex, endIndex]. */ public getPhysicalRangeSize(startIndex: number, endIndex: number): number { if (!this._tree) return 0; @@ -534,13 +558,19 @@ export class VirtualScrollEngine { tree.estimate = estimatedSize; if (tree.totalSize === total) return; - this._updateVirtualRatio(); this.onSizeChange?.(); } - private _updateVirtualRatio(): void { - const totalSize = this._tree?.totalSize ?? 0; - this._virtualRatio = - totalSize <= this._maxBrowserSize ? 1 : totalSize / this._maxBrowserSize; + /** + * The number of virtual pixels in one DOM pixel of scroll for a + * `viewportSize` px viewport: `1` without compression. With it, the ratio of + * the virtual scroll range to the DOM one, so the largest DOM scroll offset + * shows the end of the list. + */ + private _ratio(viewportSize: number): number { + const domRange = this._maxBrowserSize - viewportSize; + return this.isCompressed && domRange > 0 + ? (this.totalSize - viewportSize) / domRange + : 1; } } diff --git a/src/components/virtualization/spec.md b/src/components/virtualization/spec.md index c8297f5c3..23fb01b62 100644 --- a/src/components/virtualization/spec.md +++ b/src/components/virtualization/spec.md @@ -36,6 +36,7 @@ - [Public API](#public-api) - [Engine integration](#engine-integration) - [Item elements](#item-elements) + - [Coordinate compression tests](#coordinate-compression-tests) - [RTL tests](#rtl-tests) - [Engine unit tests](#engine-unit-tests) - [Recycle directive tests](#recycle-directive-tests) @@ -54,6 +55,8 @@ | 2 | 2026-09-23 | Recycled item elements and `keyFunction`, adapted size estimate, `nearest` edge alignment | | 3 | 2026-09-23 | Fewer element moves on large scrolls, focus kept on reorders, unbound DOM state guidance | | 4 | 2026-09-24 | Detached elements stay in the document of the list | +| 5 | 2026-09-29 | `layoutComplete` waits for the measurements, so `scrollToIndex` aligns items of any size | +| 6 | 2026-09-29 | Compression maps the scroll ranges, and the content follows each scroll | ## Overview @@ -227,9 +230,14 @@ scroll.addEventListener('igcDataRequest', async ({ detail }) => { #### Coordinate compression When the virtual size of the content passes the maximum scroll coordinate of the browser, the component maps the -virtual positions onto the DOM scroll positions through a ratio. The rendered window is still sized by the -viewport rather than by the ratio, and the alignment slack is converted into DOM space. Nothing has to be -accounted for by the application. +virtual positions onto the DOM scroll positions through a ratio: the virtual scroll range over the DOM one. So the +end of the DOM scroll range shows the last item. + +The items render at their real size, and the viewport shows the virtual pixels from the scroll position times the +ratio. So the content wrapper moves by the ratio for each DOM pixel of scroll, and the component moves it on each +scroll, also inside one window. Over-scanned items can then extend past the end of the track, which clips them, so +they do not grow the scroll size. The rendered window is still sized by the viewport rather than by the ratio, and +the alignment slack is converted into DOM space. Nothing has to be accounted for by the application. #### Waiting for the layout @@ -237,6 +245,9 @@ accounted for by the application. the current render, the measurements it triggers, and the renders those measurements schedule. It is the promise to await after a data change, a scroll or a viewport resize. +A frame runs its animation frame callbacks before its `ResizeObserver` callbacks. So `layoutComplete` waits for a +task after each frame, and it resolves only after a frame in which nothing rendered. + ### Localization None applicable. The component renders no text of its own. @@ -347,7 +358,8 @@ integrates into the document and into a shadow root alike. 10. It settles on the last index instead of waiting out the scroll timeout, and does nothing for `block: nearest` when the item is already in view, including an item that already fills the viewport. 11. It keeps the requested index aligned once the real item sizes differ from the estimate, including a far-away - index reached with a smooth scroll in a large list. + index reached with a smooth scroll in a large list, and it puts the requested item at the requested edge when + the item sizes vary. 12. `layoutComplete` settles even when no animation frames are served. 13. `scrollToIndex` with `nearest` scrolls by one item to reveal the item after the last visible one, aligns an item after the viewport to its end, and aligns an item before the viewport to its start. @@ -369,56 +381,66 @@ integrates into the document and into a shadow root alike. 21. With a `keyFunction`, an item that moves in `data` keeps its element; without one, an index keeps its element. An item template in `keyed` gives each entering item new DOM in a recycled element. +### Coordinate compression tests + +22. A list of 1,000,000 items of 50 px has a scroll size below its virtual size, `scrollToIndex` puts the item at + the requested edge, also while the last item is in the over-scan, the largest scroll offset shows the last item + without over-scan, and the items move by + the ratio for each scroll step, also across item boundaries. Positions are checked to 2 px, the precision of + the browser at such offsets. + ### RTL tests -22. `scrollToIndex` passes a negative left value to `scrollTo`, and a negative `scrollLeft` is normalized to a +23. `scrollToIndex` passes a negative left value to `scrollTo`, and a negative `scrollLeft` is normalized to a positive engine offset. -23. The content element gets a negative `translateX` when scrolled, `igcStateChange` carries valid indices, and +24. The content element gets a negative `translateX` when scrolled, `igcStateChange` carries valid indices, and the first data item is rendered as the right-most one. ### Engine unit tests -24. **Sizing**: new items take the estimate, an unsized engine reports zero, a measurement applies to the later +25. **Sizing**: new items take the estimate, an unsized engine reports zero, a measurement applies to the later offsets, out-of-range measurements are ignored, offsets are clamped to the item count, and a range sum is clamped the same way. -25. **Estimated size**: a new estimate applies only to unmeasured items, and a measurement equal to the current +26. **Estimated size**: a new estimate applies only to unmeasured items, and a measurement equal to the current size still counts as a measurement. -26. **Adapted estimate**: the average measured size replaces the estimate, the first average applies anywhere, a +27. **Adapted estimate**: the average measured size replaces the estimate, the first average applies anywhere, a later one waits until each item before the window is measured, the average survives a resize and a replacement, a new configured estimate replaces it, only the sizes measured since then count, and a change notifies. -27. **Resizing**: measured sizes survive an append and a removal, are discarded at and beyond the retained count +28. **Resizing**: measured sizes survive an append and a removal, are discarded at and beyond the retained count and marked unmeasured again, a changed estimate reaches every unmeasured item, and a matching length with everything retained is a no-op. -28. **Change notifications**: a resize, a measurement and an estimate change notify, and nothing notifies when +29. **Change notifications**: a resize, a measurement and an estimate change notify, and nothing notifies when nothing changes. -29. **Visible range**: an empty range without items or viewport, coverage of the viewport from the top, an offset +30. **Visible range**: an empty range without items or viewport, coverage of the viewport from the top, an offset exactly on an item boundary, the expansion by the over-scan clamped to the item count, and measured sizes. -30. **Alignment**: leading, centered and trailing alignment, never a negative offset, clamping to the largest +31. **Alignment**: leading, centered and trailing alignment, never a negative offset, clamping to the largest reachable offset, the in-view report including an item larger than the viewport, an out-of-range index, and an empty tree. -31. **Scroll offset resolution**: `start`, `center` and `end` match the alignment math, an unknown position is +32. **Scroll offset resolution**: `start`, `center` and `end` match the alignment math, an unknown position is `start`, and `nearest` keeps an item in view, aligns an item after or before the viewport to the closer edge, scrolls an item larger than the viewport until it covers it, and keeps the offset on an empty tree. -32. **Coordinate compression**: the DOM size clamped to the browser maximum and untouched below it, the mapping of - DOM scroll positions onto the virtual space, a rendered window sized by the viewport rather than by the ratio, +33. **Coordinate compression**: the DOM size clamped to the browser maximum and untouched below it, the mapping of + the DOM scroll range onto the virtual one, the end of the list at the largest DOM offset, the content offset + that puts the virtual position of the viewport at its leading edge, a rendered window sized by the viewport + rather than by the ratio, the alignment slack converted into DOM space, `nearest` resolved in DOM space, and a document probed only once, including from an already scrolled document. ### Recycle directive tests -33. Items render in order; the element of a key that stays is kept; a full replacement of the keys reuses every +34. Items render in order; the element of a key that stays is kept; a full replacement of the keys reuses every element without a DOM move; a shift moves only the recycled elements, unless the kept elements are fewer than half the recycled ones. -34. No element is created once the window has its full size, each item keeps exactly two markers, and no comment +35. No element is created once the window has its full size, each item keeps exactly two markers, and no comment node leaks. -35. Any change of keys, including reversals, shuffles, growth, shrinkage, duplicate keys and random changes, gives key +36. Any change of keys, including reversals, shuffles, growth, shrinkage, duplicate keys and random changes, gives key order. -36. A focused element in a kept item keeps the focus while the keys shift, reverse, or the other kept elements move. -37. Removed parts disconnect their async directives; the directive takes over from and gives way to other content. -38. **Pool**: a detached part is reused when the window grows, the pool holds at most as many parts as the window, +37. A focused element in a kept item keeps the focus while the keys shift, reverse, or the other kept elements move. +38. Removed parts disconnect their async directives; the directive takes over from and gives way to other content. +39. **Pool**: a detached part is reused when the window grows, the pool holds at most as many parts as the window, an empty window drops the pool, pooled parts disconnect their async directives and reconnect on reuse, and a detached part stays in the document of the list, also after the list moves to another document. -39. **Unbound DOM state** moves with a recycled element to the entering key, and stays with its key in a `keyed` +40. **Unbound DOM state** moves with a recycled element to the entering key, and stays with its key in a `keyed` template. ### Not covered by the suite @@ -446,6 +468,8 @@ integrates into the document and into a shadow root alike. - The adapted estimate is an average: when the first items differ in size from the rest, the scrollbar is less accurate until more items are measured. - Items are measured by their border box, so margins on an item accumulate as drift. +- Under coordinate compression, the browser keeps positions to about one pixel, and the content moves by the ratio + for each pixel of scroll. - The component does not manage focus inside the list. ## Accessibility diff --git a/src/components/virtualization/virtualization.spec.ts b/src/components/virtualization/virtualization.spec.ts index 3a30fd8e1..afa4adec4 100644 --- a/src/components/virtualization/virtualization.spec.ts +++ b/src/components/virtualization/virtualization.spec.ts @@ -28,6 +28,34 @@ describe('VirtualScroll', () => { >${ctx.value}`; + /** An item template of blocks that are `size` px tall. */ + function heightTemplate(size: number): VirtualScrollItemTemplate { + return (ctx) => + html`${ctx.value}`; + } + + type EdgeAlignment = 'start' | 'center' | 'end'; + + /** The distance in px from the item at `index` to the `block` alignment. */ + function edgeDistance( + el: IgcVirtualScrollComponent, + index: number, + block: EdgeAlignment + ): number { + const view = el.getBoundingClientRect(); + const item = el + .querySelector(`[data-vs-index="${index}"]`)! + .getBoundingClientRect(); + + return { + start: item.top - view.top, + center: (item.top + item.bottom - view.top - view.bottom) / 2, + end: item.bottom - view.bottom, + }[block]; + } + /** A 300px scroll of 1000 items of `FIXED_SIZE`, with an exact estimate. */ async function createFixedScroll(): Promise< IgcVirtualScrollComponent @@ -265,7 +293,7 @@ describe('VirtualScroll', () => { estimated-item-size="50" over-scan="0" .data=${createItems(500)} - .itemTemplate=${itemTemplate} + .itemTemplate=${heightTemplate(50)} >` ); @@ -449,6 +477,41 @@ describe('VirtualScroll', () => { ); }); + it('puts the requested item at the requested edge when the item sizes vary', async () => { + // 20 to 110px. The rendered items differ from the adapted average, so + // only a correction after their measurement aligns the item. + const sizeOf = (index: number) => 20 + ((index * 37) % 7) * 15; + const variedTemplate: VirtualScrollItemTemplate = (ctx) => + html`${ctx.value}`; + + const el = await fixture>( + html`` + ); + + await el.layoutComplete; + + const cases: [number, EdgeAlignment][] = [ + [1234, 'start'], + [700, 'center'], + [1600, 'end'], + ]; + + for (const [index, block] of cases) { + await el.scrollToIndex(index, { block }); + + expect( + edgeDistance(el, index, block), + `${block} of item ${index}` + ).to.be.closeTo(0, 1); + } + }); + it('scrollToIndex with nearest leaves an item that already fills the viewport alone', async () => { const tallTemplate: VirtualScrollItemTemplate = (ctx) => html`${ctx.value}`; @@ -817,6 +880,100 @@ describe('VirtualScroll', () => { }); }); + describe('Coordinate compression', () => { + // 1,000,000 items of 50px are 50,000,000px, more than a browser can + // scroll, so the component compresses the virtual space. At DOM offsets of + // millions of px, the browser keeps positions to about 1px, so the checks + // allow 2px. Without the compression fix, items were off by 20 to 35px. + const COUNT = 1_000_000; + const PRECISION = 2; + const ITEM_SIZE = 50; + // Built on first use and shared: `data` is compared by reference and never + // changed, and each copy is 1,000,000 strings. + let hugeData: string[] | undefined; + + async function createHugeScroll( + overScan = 2 + ): Promise> { + const el = await fixture>( + html`` + ); + await el.layoutComplete; + return el; + } + + it('compresses the scroll size to the browser maximum', async () => { + const el = await createHugeScroll(); + + expect(el.scrollHeight).to.be.below(COUNT * ITEM_SIZE); + }); + + it('puts the requested item at the requested edge', async () => { + const el = await createHugeScroll(); + // The last case keeps the last item in the over-scan, where the + // rendered items extend past the end of the track. + const cases: [number, EdgeAlignment][] = [ + [777_777, 'start'], + [300_000, 'center'], + [555_555, 'end'], + [COUNT - 2, 'end'], + ]; + + for (const [index, block] of cases) { + await el.scrollToIndex(index, { block }); + + expect( + edgeDistance(el, index, block), + `${block} of item ${index}` + ).to.be.closeTo(0, PRECISION); + } + }); + + it('shows the last item at the end of the scroll range', async () => { + // Without over-scan, only the viewport renders, so the extra items + // cannot hide a scroll range that stops short of the end. + const el = await createHugeScroll(0); + + await simulateScroll(el, { top: el.scrollHeight }); + await el.layoutComplete; + + expect(edgeDistance(el, COUNT - 1, 'end')).to.be.closeTo(0, PRECISION); + }); + + it('moves the items evenly while the scroll crosses item boundaries', async () => { + const el = await createHugeScroll(); + await el.scrollToIndex(500_000); + + const index = 500_001; + const steps: number[] = []; + let previous = edgeDistance(el, index, 'start'); + + // 10 steps of 10px cross two item boundaries in DOM space. + for (let i = 0; i < 10; i++) { + await simulateScroll(el, { top: el.scrollTop + 10 }); + + const top = edgeDistance(el, index, 'start'); + steps.push(previous - top); + previous = top; + } + + // One DOM px scrolls the virtual scroll range over the DOM one. + const ratio = + (COUNT * ITEM_SIZE - el.clientHeight) / + (el.scrollHeight - el.clientHeight); + + for (const step of steps) { + expect(step).to.be.closeTo(10 * ratio, PRECISION); + } + }); + }); + describe('RTL', () => { async function createRTLScroll( count = 1000 diff --git a/src/components/virtualization/virtualization.ts b/src/components/virtualization/virtualization.ts index 20434404f..cdd077006 100644 --- a/src/components/virtualization/virtualization.ts +++ b/src/components/virtualization/virtualization.ts @@ -116,6 +116,7 @@ export default class IgcVirtualScrollComponent< :where(igc-virtual-scroll) [part="virtualization-track"] { position: relative; + overflow: clip; width: 100%; min-height: 100%; } @@ -182,6 +183,9 @@ export default class IgcVirtualScrollComponent< private _layoutCompletePromise: Promise | null = null; private _scrollRequestId = 0; + /** The number of completed updates. See `_resolveLayoutComplete`. */ + private _updateCount = 0; + /** * The `startIndex` of the last `igcDataRequest`, which is also the item count * at that emit. See `_checkDataRequest`. @@ -360,6 +364,8 @@ export default class IgcVirtualScrollComponent< } protected override updated(_changed: PropertyValues): void { + this._updateCount++; + this._positionContent(); this._scheduleItemMeasurement(); this._checkDataRequest(); this._emitStateChange(); @@ -379,28 +385,6 @@ export default class IgcVirtualScrollComponent< ? { height: `${this._engine.domSize}px` } : { width: `${this._engine.domSize}px` }; - // The content wrapper is absolutely positioned at the origin of a track of - // `domSize` px. A translation to the scroll offset of the first rendered - // item puts that item at its virtual position. - let contentPosition = this._engine.getScrollOffsetForIndex( - range.startIndex - ); - const physicalRangeSize = this._engine.getPhysicalRangeSize( - range.startIndex, - range.endIndex - ); - contentPosition = clamp( - contentPosition, - 0, - this._engine.domSize - physicalRangeSize - ); - const isRTL = !isVertical && !isLTR(this); - const contentStyle = { - transform: isVertical - ? `translateY(${contentPosition}px)` - : `translateX(${isRTL ? -contentPosition : contentPosition}px)`, - }; - const visibleItems = range.endIndex >= range.startIndex ? items.slice(range.startIndex, range.endIndex + 1) @@ -416,7 +400,6 @@ export default class IgcVirtualScrollComponent< ${ref(this._contentRef)} part="virtualization-content" role="presentation" - style=${styleMap(contentStyle)} > ${recycle( visibleItems, @@ -609,7 +592,8 @@ export default class IgcVirtualScrollComponent< /** * Records the new scroll offset. Schedules a render only if the rendered - * window moves. + * window moves. Under coordinate compression, a scroll inside one window + * moves the content only. * * @remarks * `render` reads `_currentRange`, not the scroll offset. Without the guard, @@ -626,9 +610,36 @@ export default class IgcVirtualScrollComponent< endIndex !== this._currentRange.endIndex ) { this.requestUpdate(); + } else if (this._engine.isCompressed) { + this._positionContent(); } } + /** + * Translates the content wrapper, which is absolutely positioned at the + * origin of the track, so the rendered items are at their virtual positions. + * + * @remarks + * Applied outside `render`, because with coordinate compression the offset + * changes on each scroll, also inside one window. Over-scanned items can + * then extend past the end of the track. The track clips them, so they do + * not grow the scroll size. + */ + private _positionContent(): void { + const content = this._contentRef.value; + if (!content) return; + + const offset = this._engine.getContentOffset( + this._currentRange.startIndex, + this._scrollPosition, + this._viewportSize + ); + + content.style.transform = this._isVertical + ? `translateY(${offset}px)` + : `translateX(${isLTR(this) ? offset : -offset}px)`; + } + /** * The length of the prefix that a `data` change keeps: the first index at * which the old and the new items differ, or the length of the shorter array @@ -772,17 +783,22 @@ export default class IgcVirtualScrollComponent< } /** - * Resolves on the next animation frame, or after `LAYOUT_FRAME_TIMEOUT_MS` if - * no frame arrives. A hidden tab or a disconnected element gets no frames, - * and `layoutComplete` must still settle. Such a state has no layout to wait - * for, so an early resolve is safe. + * Resolves after the next frame has delivered its `ResizeObserver` + * callbacks, or after `LAYOUT_FRAME_TIMEOUT_MS` if no frame arrives. + * + * A frame runs its animation frame callbacks before its layout and its + * `ResizeObserver` callbacks, so a resolve in the `requestAnimationFrame` + * callback comes before the measurements. A task queued from that callback + * runs after them. */ - private _nextFrame(): Promise { + private _nextMeasurement(): Promise { return this._withDeadline( LAYOUT_FRAME_TIMEOUT_MS, (signal) => new Promise((resolve) => { - const id = requestAnimationFrame(() => resolve()); + const id = requestAnimationFrame(() => + resolve(this._timeout(0, signal)) + ); signal.addEventListener('abort', () => cancelAnimationFrame(id), { once: true, }); @@ -792,18 +808,20 @@ export default class IgcVirtualScrollComponent< /** * Waits for the current update, then lets the ResizeObserver measurements - * run. If those schedule one more render, for example when a measured size - * replaces an estimate, the wait repeats until nothing is pending, up to a - * safety cap. + * run. If those render again, for example when a measured size replaces an + * estimate, the wait repeats until a frame passes with no render, up to a + * safety cap. A render during the wait is complete before the check, so + * `isUpdatePending` alone misses it. */ private async _resolveLayoutComplete(): Promise { try { await this.updateComplete; for (let i = 0; i < MAX_LAYOUT_SETTLE_PASSES; i++) { - await this._nextFrame(); + const updates = this._updateCount; + await this._nextMeasurement(); - if (!this.isUpdatePending) { + if (!this.isUpdatePending && this._updateCount === updates) { break; }