diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index cc6ed8873a41..ac67adcfa5d1 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -8,6 +8,7 @@ export const Virtualize = { dispose, scrollToBottom, refreshObservers, + setAnchorMode, }; const dispatcherObserversByDotNetIdPropname = Symbol(); @@ -42,7 +43,7 @@ function getScaleFactor(spacerBefore: HTMLElement, spacerAfter: HTMLElement): nu return (Number.isFinite(scale) && scale > 0) ? scale : 1; } -function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spacerAfter: HTMLElement, rootMargin = 50): void { +function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spacerAfter: HTMLElement, rootMargin = 50, anchorMode = 1): void { // If the component was disposed before the JS interop call completed, the element references may be null // or the elements may have been disconnected from the DOM. Return early to avoid errors. if (!spacerBefore || !spacerAfter || !spacerBefore.isConnected || !spacerAfter.isConnected) { @@ -50,7 +51,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } const scrollContainer = findClosestScrollContainer(spacerBefore); - const scrollElement = scrollContainer || document.documentElement; + const scrollElement = (scrollContainer || document.scrollingElement || document.documentElement) as HTMLElement; const isTable = isValidTableElement(spacerAfter.parentElement); const supportsAnchor = CSS.supports('overflow-anchor', 'auto'); const useNativeAnchoring = !isTable && supportsAnchor; @@ -85,6 +86,31 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac const anchoredItems: Map = new Map(); let scrollTriggeredRender = false; + // None-mode top-prepend compensation is applied explicitly after render using the + // current item-size estimate. Ignore the next stale spacerBefore IO callback, and + // allow one follow-up measured correction if spacerBefore's actual height differs. + let skipNextSpacerBeforeCallback = false; + let pendingSpacerBeforeCompensationHeight: number | null = null; + + function reobserveSpacerBefore(): void { + if (spacerBefore.isConnected) { + intersectionObserver.unobserve(spacerBefore); + intersectionObserver.observe(spacerBefore); + } + } + + function applyPrependCompensation(expectedHeight: number): void { + if (expectedHeight <= 0) { + return; + } + + scrollElement.scrollTop += expectedHeight; + pendingSpacerBeforeCompensationHeight = expectedHeight; + skipNextSpacerBeforeCallback = true; + + queueMicrotask(reobserveSpacerBefore); + } + function getObservedHeight(entry: ResizeObserverEntry): number { return entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; } @@ -125,6 +151,17 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac // 2. For convergence (sticky-top/bottom) - observes elements for geometry changes, drives the scroll position. // 3. Manual scroll compensation (tables/Safari) — adjusts scrollTop when above-viewport items resize. const resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]): void => { + if (pendingSpacerBeforeCompensationHeight !== null) { + const remainingHeightDifference = spacerBefore.offsetHeight - pendingSpacerBeforeCompensationHeight; + pendingSpacerBeforeCompensationHeight = null; + + if (remainingHeightDifference !== 0) { + scrollElement.scrollTop += remainingHeightDifference; + skipNextSpacerBeforeCallback = true; + queueMicrotask(reobserveSpacerBefore); + } + } + for (const entry of entries) { if (entry.target === spacerBefore || entry.target === spacerAfter) { const spacer = entry.target as HTMLElement; @@ -157,7 +194,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac resizeObserver.observe(spacerBefore); resizeObserver.observe(spacerAfter); - function refreshObservedElements(): void { + function refreshObservedElements(prependCompensation = 0): void { // C# style updates overwrite the entire style attribute. Re-apply what we need. if (isTable) { spacerBefore.style.display = 'table-row'; @@ -173,6 +210,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac resizeObserver.observe(spacerBefore); resizeObserver.observe(spacerAfter); + if (prependCompensation > 0) { + applyPrependCompensation(prependCompensation); + } + // During convergence, keep the observed element set in sync with the DOM. if (convergingElements) { const currentItems: Set = new Set(); @@ -276,6 +317,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac scrollElement, startConvergenceObserving, setConvergingToBottom: () => { convergingToBottom = true; }, + setAnchorMode: (mode: number) => { anchorMode = mode; }, onDispose: () => { stopConvergenceObserving(); anchoredItems.clear(); @@ -319,15 +361,23 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } if (convergingToBottom) return; - const atBottom = scrollElement.scrollTop + scrollElement.clientHeight >= scrollElement.scrollHeight - 1; - if (!atBottom && !pendingJumpToEnd) return; - - convergingToBottom = true; - startConvergenceObserving(); + // pendingJumpToEnd is user-initiated (End key) — always honor it. + // Data-driven convergence only fires when End anchoring is enabled. if (pendingJumpToEnd) { + convergingToBottom = true; + startConvergenceObserving(); scrollElement.scrollTop = scrollElement.scrollHeight; pendingJumpToEnd = false; + return; } + + if (!(anchorMode & 2)) return; + + const atBottom = scrollElement.scrollTop + scrollElement.clientHeight >= scrollElement.scrollHeight - 1; + if (!atBottom) return; + + convergingToBottom = true; + startConvergenceObserving(); } function onSpacerBeforeVisible(): void { @@ -340,15 +390,23 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } if (convergingToTop) return; - const atTop = scrollElement.scrollTop < 1; - if (!atTop && !pendingJumpToStart) return; - - convergingToTop = true; - startConvergenceObserving(); + // pendingJumpToStart is user-initiated (Home key) — always honor it. + // Data-driven convergence only fires when Beginning anchoring is enabled. if (pendingJumpToStart) { + convergingToTop = true; + startConvergenceObserving(); scrollElement.scrollTop = 0; pendingJumpToStart = false; + return; } + + if (!(anchorMode & 1)) return; + + const atTop = scrollElement.scrollTop < 1; + if (!atTop) return; + + convergingToTop = true; + startConvergenceObserving(); } function processIntersectionEntries(entries: IntersectionObserverEntry[]): void { @@ -358,6 +416,14 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } const intersectingEntries = entries.filter(entry => { + // During None-mode prepend compensation, skip the stale spacerBefore + // callback that was computed before scrollTop was explicitly adjusted. + if (skipNextSpacerBeforeCallback && entry.target === spacerBefore) { + skipNextSpacerBeforeCallback = false; + queueMicrotask(reobserveSpacerBefore); + return false; + } + if (entry.isIntersecting) { if (entry.target === spacerAfter) { onSpacerAfterVisible(); @@ -423,10 +489,16 @@ function scrollToBottom(dotNetHelper: DotNet.DotNetObject): void { } } -function refreshObservers(dotNetHelper: DotNet.DotNetObject): void { +function refreshObservers(dotNetHelper: DotNet.DotNetObject, prependCompensation = 0): void { + const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); + const entry = observersByDotNetObjectId[id]; + entry?.refreshObservedElements?.(prependCompensation); +} + +function setAnchorMode(dotNetHelper: DotNet.DotNetObject, mode: number): void { const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); const entry = observersByDotNetObjectId[id]; - entry?.refreshObservedElements?.(); + entry?.setAnchorMode?.(mode); } function getObserversMapEntry(dotNetHelper: DotNet.DotNetObject): { observersByDotNetObjectId: {[id: number]: any }, id: number } { diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index b4c6d1f43c3a..3cf591571ee2 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -80,6 +80,12 @@ Microsoft.AspNetCore.Components.Web.Media.MediaSource.MediaSource(byte[]! data, Microsoft.AspNetCore.Components.Web.Media.MediaSource.MediaSource(System.IO.Stream! stream, string! mimeType, string! cacheKey) -> void Microsoft.AspNetCore.Components.Web.Media.MediaSource.MimeType.get -> string! Microsoft.AspNetCore.Components.Web.Media.MediaSource.Stream.get -> System.IO.Stream! +Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.AnchorMode.get -> Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode +Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.AnchorMode.set -> void +Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode +Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode.Beginning = 1 -> Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode +Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode.End = 2 -> Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode +Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode.None = 0 -> Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode virtual Microsoft.AspNetCore.Components.Forms.InputFile.Dispose(bool disposing) -> void Microsoft.AspNetCore.Components.Forms.DisplayName Microsoft.AspNetCore.Components.Forms.DisplayName.DisplayName() -> void diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index a90372571ca3..152918b216af 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -72,6 +72,10 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I internal bool _pendingScrollToBottom; + private float _pendingPrependCompensationPx; + + private VirtualizeAnchorMode _lastRenderedAnchorMode; + [Inject] private IJSRuntime JSRuntime { get; set; } = default!; @@ -150,6 +154,14 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I [Parameter] public int MaxItemCount { get; set; } = 100; + /// + /// Gets or sets the anchor mode that controls how the viewport behaves at the edges + /// of the list when new items arrive. The default is , + /// which preserves backward-compatible behavior. + /// + [Parameter] + public VirtualizeAnchorMode AnchorMode { get; set; } = VirtualizeAnchorMode.Beginning; + /// /// Instructs the component to re-request data from its . /// This is useful if external data may have changed. There is no need to call this @@ -230,7 +242,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (firstRender) { _jsInterop = new VirtualizeJsInterop(this, JSRuntime); - await _jsInterop.InitializeAsync(_spacerBefore, _spacerAfter); + await _jsInterop.InitializeAsync(_spacerBefore, _spacerAfter, (int)AnchorMode); + _lastRenderedAnchorMode = AnchorMode; } if (_pendingScrollToBottom && _jsInterop is not null) @@ -242,7 +255,15 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // After render the set of items could change. Tell JS to refresh ResizeObserver. if (!firstRender && _jsInterop is not null) { - await _jsInterop.RefreshObserversAsync(); + if (_lastRenderedAnchorMode != AnchorMode) + { + _lastRenderedAnchorMode = AnchorMode; + await _jsInterop.SetAnchorModeAsync((int)AnchorMode); + } + + var pendingPrependCompensationPx = _pendingPrependCompensationPx; + _pendingPrependCompensationPx = 0; + await _jsInterop.RefreshObserversAsync(pendingPrependCompensationPx); } } @@ -260,7 +281,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.OpenElement(0, SpacerElement); builder.AddAttribute(1, "style", GetSpacerStyle(_itemsBefore)); builder.AddAttribute(2, "aria-hidden", "true"); - builder.AddElementReferenceCapture(3, elementReference => _spacerBefore = elementReference); + builder.AddElementReferenceCapture(4, elementReference => _spacerBefore = elementReference); builder.CloseElement(); var lastItemIndex = Math.Min(_itemsBefore + _visibleItemCapacity, _itemCount); @@ -393,7 +414,8 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerS // When we're at the very bottom and new measurements arrived, // scroll to bottom so the viewport stays pinned while items converge. - if (itemsAfter == 0 && hadNewMeasurements) + // Only activate when AnchorMode includes the End flag. + if (itemsAfter == 0 && hadNewMeasurements && (AnchorMode & VirtualizeAnchorMode.End) != 0) { _pendingScrollToBottom = true; } @@ -522,13 +544,24 @@ private async ValueTask RefreshDataCoreAsync(bool renderOnSuccess) var countDelta = result.TotalItemCount - previousItemCount; // Detect if items were prepended above the current viewport position. - if (countDelta > 0 && _itemsBefore > 0 && _previousFirstLoadedItem != null + if (countDelta > 0 && _previousFirstLoadedItem != null && _itemsProvider == DefaultItemsProvider) { var newFirstItem = Items!.ElementAtOrDefault(_itemsBefore); if (newFirstItem != null && !ReferenceEquals(_previousFirstLoadedItem, newFirstItem)) { - _itemsBefore = Math.Min(_itemsBefore + countDelta, Math.Max(0, result.TotalItemCount - _visibleItemCapacity)); + if (_itemsBefore > 0) + { + // Mid-list: adjust itemsBefore to keep the same items visible. + _itemsBefore = Math.Min(_itemsBefore + countDelta, Math.Max(0, result.TotalItemCount - _visibleItemCapacity)); + } + else if (AnchorMode == VirtualizeAnchorMode.None) + { + // At the top edge in None mode, apply an explicit post-render + // scroll compensation using the current item-size estimate. + _itemsBefore = Math.Min(countDelta, Math.Max(0, result.TotalItemCount - _visibleItemCapacity)); + _pendingPrependCompensationPx = countDelta * _itemSize; + } var adjustedRequest = new ItemsProviderRequest(_itemsBefore, _visibleItemCapacity, cancellationToken); result = await _itemsProvider(adjustedRequest); diff --git a/src/Components/Web/src/Virtualization/VirtualizeAnchorMode.cs b/src/Components/Web/src/Virtualization/VirtualizeAnchorMode.cs new file mode 100644 index 000000000000..298ffd84798a --- /dev/null +++ b/src/Components/Web/src/Virtualization/VirtualizeAnchorMode.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Web.Virtualization; + +/// +/// Controls how the viewport behaves at the edges of the list when +/// new items arrive. Flags can be combined to pin both edges. +/// +[Flags] +public enum VirtualizeAnchorMode +{ + /// + /// No edge pinning. The viewport stays at its current scroll position + /// regardless of item changes. + /// + None = 0, + + /// + /// Pins the viewport to the beginning of the list. When the user is + /// at or near the top and new items arrive at the beginning, the viewport + /// stays at the top showing the newest items — matching standard news + /// feed / notification list UX. + /// + Beginning = 1, + + /// + /// Pins the viewport to the end of the list. When the user is at or near + /// the bottom and new items arrive at the end, the viewport auto-scrolls + /// to show them. If the user has scrolled away, auto-scroll disengages + /// until they return to the bottom — matching standard chat / log UX. + /// + End = 2, +} diff --git a/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs b/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs index 363557cb7688..4f0c76766f6e 100644 --- a/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs +++ b/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs @@ -24,10 +24,10 @@ public VirtualizeJsInterop(IVirtualizeJsCallbacks owner, IJSRuntime jsRuntime) _jsRuntime = jsRuntime; } - public async ValueTask InitializeAsync(ElementReference spacerBefore, ElementReference spacerAfter) + public async ValueTask InitializeAsync(ElementReference spacerBefore, ElementReference spacerAfter, int anchorMode) { _selfReference = DotNetObjectReference.Create(this); - await _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.init", _selfReference, spacerBefore, spacerAfter); + await _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.init", _selfReference, spacerBefore, spacerAfter, /* rootMargin */ 50, anchorMode); } [JSInvokable] @@ -47,9 +47,14 @@ public ValueTask ScrollToBottomAsync() return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.scrollToBottom", _selfReference); } - public ValueTask RefreshObserversAsync() + public ValueTask RefreshObserversAsync(float prependCompensationPx = 0) { - return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.refreshObservers", _selfReference); + return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.refreshObservers", _selfReference, prependCompensationPx); + } + + public ValueTask SetAnchorModeAsync(int anchorMode) + { + return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.setAnchorMode", _selfReference, anchorMode); } public async ValueTask DisposeAsync() diff --git a/src/Components/Web/test/Virtualization/VirtualizeTest.cs b/src/Components/Web/test/Virtualization/VirtualizeTest.cs index f0435bdbb2c1..a35b96398c21 100644 --- a/src/Components/Web/test/Virtualization/VirtualizeTest.cs +++ b/src/Components/Web/test/Virtualization/VirtualizeTest.cs @@ -456,6 +456,8 @@ public async Task Virtualize_ScrollToBottom_SetWhenAtEndWithNewMeasurements() await testRenderer.RenderRootComponentAsync(componentId); Assert.NotNull(renderedVirtualize); + renderedVirtualize.AnchorMode = VirtualizeAnchorMode.End; + // First callback triggers items to render await testRenderer.Dispatcher.InvokeAsync(() => ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible( @@ -475,6 +477,24 @@ i.Arguments[0] is string id && "scrollToBottom should either be called via JS interop or be pending"); } + [Fact] + public async Task Virtualize_ScrollToBottom_NotSetForBeginningMode() + { + var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 100); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + // Default AnchorMode is Beginning, which does NOT include the End flag. + // At the bottom with new measurements, _pendingScrollToBottom should NOT be set. + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 500f, 500f)); + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 500f, 500f)); + + Assert.False(virtualize._pendingScrollToBottom, + "Beginning mode should not trigger scrollToBottom"); + } + [Fact] public async Task Virtualize_ScrollToBottom_NotSetWhenNotAtEnd() { @@ -508,6 +528,8 @@ public async Task Virtualize_ScrollToBottom_NotSetWhenMeasurementsNotApplied() await testRenderer.RenderRootComponentAsync(componentId); Assert.NotNull(renderedVirtualize); + renderedVirtualize.AnchorMode = VirtualizeAnchorMode.End; + var callbacks = (IVirtualizeJsCallbacks)renderedVirtualize; // First call: real measurements at the bottom — should set pending diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 1083fb8ce87a..bd5452f69502 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -1028,9 +1028,8 @@ public void DynamicContent_ExpandingOffScreenItemDoesNotAffectVisibleItems() var sameItem = container.FindElement(By.CssSelector($"[data-index='{firstVisibleIndex}']")); var firstVisibleTopAfter = sameItem.Location.Y; - // The visible items should stay in place (or very close, allowing for minor reflow) - Assert.True(Math.Abs(firstVisibleTopAfter - firstVisibleTopBefore) < 5, - $"Visible item should not have moved when off-screen item expanded. Before: {firstVisibleTopBefore}, After: {firstVisibleTopAfter}"); + // The visible items should stay in place + Assert.Equal(firstVisibleTopBefore, firstVisibleTopAfter); } [Fact] @@ -1083,9 +1082,7 @@ public void DynamicContent_PrependItemsWhileScrolledToMiddle_VisibleItemsStayInP var sameItem = container.FindElement(By.CssSelector($"[data-index='{firstVisibleIndex}']")); var firstVisibleTopAfter = sameItem.Location.Y; - Assert.True(Math.Abs(firstVisibleTopAfter - firstVisibleTopBefore) < 5, - $"Visible item {firstVisibleIndex} should not move when items are prepended above. " + - $"Before Y: {firstVisibleTopBefore}, After Y: {firstVisibleTopAfter}"); + Assert.Equal(firstVisibleTopBefore, firstVisibleTopAfter); Browser.True(() => container.FindElements(By.CssSelector(".item")).Count > 0); @@ -1135,12 +1132,63 @@ public void DynamicContent_AppendItemsWhileScrolledToMiddle_VisibleItemsStayInPl var firstVisibleTopAfter = sameItem.Location.Y; var scrollTopAfter = (long)js.ExecuteScript("return arguments[0].scrollTop", container); - Assert.True(Math.Abs(firstVisibleTopAfter - firstVisibleTopBefore) < 5, - $"Visible item {firstVisibleIndex} should not move when items are appended below. " + - $"Before: {firstVisibleTopBefore}, After: {firstVisibleTopAfter}"); - Assert.True(Math.Abs(scrollTopAfter - scrollTopBefore) < 5, - $"scrollTop should not change when appending below viewport. " + - $"Before: {scrollTopBefore}, After: {scrollTopAfter}"); + Assert.Equal(firstVisibleTopBefore, firstVisibleTopAfter); + Assert.Equal(scrollTopBefore, scrollTopAfter); + } + + [Fact] + public void DynamicContent_PrependItemsWhileAtTop_NewItemsAppearAtTop() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".item") > 0); + + // Verify we're at the top. + Assert.Equal(0, (long)js.ExecuteScript("return arguments[0].scrollTop", container)); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + // At scrollTop=0, the natural floor prevents native anchoring from compensating — new items + // appear at the top and old items shift down. This is the default AnchorMode.Beginning behavior. + // In contrast, AnchorMode.None compensates scrollTop so old items stay in view. + var scrollTopAfter = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + Assert.True(scrollTopAfter < 50, + $"scrollTop should stay near 0 at the natural floor, but was {scrollTopAfter}"); + + // The prepended items should become visible after the IO callback triggers re-render. + Browser.True(() => container.FindElements(By.CssSelector("[data-index='-1']")).Count > 0); + } + + [Fact] + public void DynamicContent_AppendItemsWhileAtBottom_DefaultBehavior() + { + // The default AnchorMode is Beginning, so bottom-edge convergence is not active. + // For small appends, the Virtualize's normal item-loading cycle may naturally + // end up at the bottom (IO-driven window slide). This test documents that behavior. + // For explicit bottom-pinning, use AnchorMode.End (tested in AnchorMode_End_* tests). + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".item") > 0); + + js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollHeight", container); + Browser.True(() => + { + var scrollTop = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var scrollHeight = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var clientHeight = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + return scrollHeight - scrollTop - clientHeight < 2; + }); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + // Verify items were appended and the component is functional. + Browser.True(() => GetElementCount(container, ".item") > 0); } [Fact] @@ -1803,7 +1851,7 @@ public void ViewportAnchoring_ExpandAboveViewport_VisibleItemStaysInPlace() var (_, relTopAfter, scrollTopAfter) = GetItemPositionInContainer(js, container, ".item", indexBefore); - Assert.True(Math.Abs(relTopAfter - relTopBefore) < 5, + Assert.True(Math.Abs(relTopAfter - relTopBefore) == 0, $"Visible item '{indexBefore}' should not have moved when off-screen item expanded. " + $"RelTop Before: {relTopBefore:F1}, After: {relTopAfter:F1}, Delta: {relTopAfter - relTopBefore:F1}px. " + $"scrollTop: {scrollTopBefore}->{scrollTopAfter}."); @@ -1834,7 +1882,7 @@ public void ViewportAnchoring_CollapseAboveViewport_VisibleItemStaysInPlace() var (_, relTopAfter, _) = GetItemPositionInContainer(js, container, ".item", indexBefore); - Assert.True(Math.Abs(relTopAfter - relTopBefore) < 5, + Assert.True(Math.Abs(relTopAfter - relTopBefore) == 0, $"Visible item '{indexBefore}' should not have moved when off-screen item collapsed. " + $"RelTop Before: {relTopBefore:F1}, After: {relTopAfter:F1}, Delta: {relTopAfter - relTopBefore:F1}px"); } @@ -1872,6 +1920,496 @@ public void Table_VariableHeight_IncrementalScrollDoesNotCauseWildJumps() $"Large jumps indicate CSS scroll anchoring is miscalculating on elements."); } + private void MountAnchorModeComponent(string anchorMode, bool variableHeight = false) + { + Browser.MountTestComponent(); + var container = Browser.Exists(By.Id("scroll-container")); + Browser.True(() => GetElementCount(container, ".item") > 0); + + if (variableHeight) + { + Browser.Exists(By.Id("toggle-height")).Click(); + Browser.True(() => GetElementCount(container, ".item") > 0); + } + + var select = Browser.Exists(By.Id("anchor-mode-select")); + var selectElement = new SelectElement(select); + selectElement.SelectByValue(anchorMode); + + Browser.True(() => Browser.Exists(By.Id("current-mode")).Text == anchorMode); + Browser.True(() => GetElementCount(container, ".item") > 0); + } + + private void ScrollToBottomAndWait(IWebElement container, IJavaScriptExecutor js) + { + // With variable-height items, scrollHeight may shift as Virtualize re-measures. + // Retry the scroll-to-bottom until the viewport is truly at the bottom. + Browser.True(() => + { + js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollHeight", container); + var st = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var sh = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var ch = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + return sh - st - ch < 2; + }, TimeSpan.FromSeconds(10)); + } + + private void ScrollMidListAndWaitForRender(IWebElement container, IJavaScriptExecutor js) + { + js.ExecuteScript("arguments[0].scrollTop = 5000", container); + Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) > 4000); + // Wait for Virtualize to render items at the new scroll position. + Browser.True(() => + { + var result = js.ExecuteScript(@" + var container = arguments[0]; + var containerRect = container.getBoundingClientRect(); + var items = container.querySelectorAll('.item[data-index]'); + + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var idx = parseInt(item.getAttribute('data-index'), 10); + var itemRect = item.getBoundingClientRect(); + + if (!Number.isNaN(idx) + && idx > 50 + && itemRect.bottom > containerRect.top + 1 + && itemRect.top < containerRect.bottom - 1) { + return true; + } + } + + return false; + ", container); + + return result is bool isVisible && isVisible; + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_None_PrependAtTop_ViewportStaysStable(bool variableHeight) + { + MountAnchorModeComponent("0", variableHeight); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + Assert.Equal(0, (long)js.ExecuteScript("return arguments[0].scrollTop", container)); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + // Wait for compensation to stabilize + Browser.True(() => + { + var scrollTop = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + return scrollTop > 50; + }, TimeSpan.FromSeconds(5)); + + var (indexAfter, relTopAfter, _) = GetItemPositionInContainer(js, container, ".item"); + + var idxBefore = int.Parse(indexBefore, System.Globalization.CultureInfo.InvariantCulture); + var idxAfter = int.Parse(indexAfter, System.Globalization.CultureInfo.InvariantCulture); + Assert.True(Math.Abs(idxAfter - idxBefore) <= 1, + $"None mode: viewport shifted from item {indexBefore} to {indexAfter} after prepend at top"); + Assert.True(Math.Abs(relTopAfter - relTopBefore) == 0, + $"None mode: item position shifted by {Math.Abs(relTopAfter - relTopBefore)}px after prepend"); + + // Scroll up and verify prepended items are actually reachable. + js.ExecuteScript("arguments[0].scrollTop = 0", container); + Browser.True(() => + { + return container.FindElements(By.CssSelector("[data-index='-1']")).Count > 0; + }, TimeSpan.FromSeconds(5), "None mode: prepended items should be reachable after scrolling up"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_None_LargeAppendAtBottom_DoesNotFollowToBottom(bool variableHeight) + { + MountAnchorModeComponent("0", variableHeight); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollToBottomAndWait(container, js); + + Browser.Exists(By.Id("append-many-items")).Click(); + Browser.Contains("Appended 100 items", () => Browser.Exists(By.Id("status")).Text); + // The viewport should NOT end up at the very bottom of 1500 items. + var st2 = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var sh2 = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var ch2 = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + var gap = sh2 - st2 - ch2; + Assert.True(gap > 5000, + $"None mode: should not converge to bottom after large append. " + + $"scrollTop: {st2}, scrollHeight: {sh2}, gap: {gap}"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_None_MidList_ViewportStable(bool variableHeight) + { + MountAnchorModeComponent("0", variableHeight); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollMidListAndWaitForRender(container, js); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + // Native anchoring adjusts scrollTop to compensate, so check visual position instead. + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + "None mode mid-list: viewport should stay visually stable after prepend", + compareWholePixels: true); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_Beginning_PrependAtTop_NewItemsVisible(bool variableHeight) + { + MountAnchorModeComponent("1", variableHeight); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + Assert.Equal(0, (long)js.ExecuteScript("return arguments[0].scrollTop", container)); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + var scrollTopAfter = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + Assert.True(scrollTopAfter < 50, + $"Beginning mode: should stay near top after prepend, but scrollTop was {scrollTopAfter}"); + + Browser.True(() => container.FindElements(By.CssSelector("[data-index='-1']")).Count > 0); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_Beginning_LargeAppendAtBottom_DoesNotFollowToBottom(bool variableHeight) + { + MountAnchorModeComponent("1", variableHeight); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollToBottomAndWait(container, js); + + Browser.Exists(By.Id("append-many-items")).Click(); + Browser.Contains("Appended 100 items", () => Browser.Exists(By.Id("status")).Text); + // Beginning mode: no convergence to chase the new bottom. + var st2 = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var sh2 = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var ch2 = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + var gap = sh2 - st2 - ch2; + Assert.True(gap > 5000, + $"Beginning mode: should not converge to bottom after large append. " + + $"scrollTop: {st2}, scrollHeight: {sh2}, gap: {gap}"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_Beginning_MidList_ViewportStable(bool variableHeight) + { + MountAnchorModeComponent("1", variableHeight); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollMidListAndWaitForRender(container, js); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + var driftTolerance = 0; + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + "Beginning mode mid-list: viewport should stay stable after prepend", + driftTolerance); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_End_PrependAtTop_ViewportStaysStable(bool variableHeight) + { + MountAnchorModeComponent("2", variableHeight); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + Assert.Equal(0, (long)js.ExecuteScript("return arguments[0].scrollTop", container)); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + var (_, relTopAfter, scrollTopAfter) = GetItemPositionInContainer(js, container, ".item", indexBefore); + Assert.True(scrollTopAfter < 50, + $"End mode at top: scrollTop should stay near 0 (floor constraint), but was {scrollTopAfter}"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_End_AppendAtBottom_ViewportFollows(bool variableHeight) + { + MountAnchorModeComponent("2", variableHeight); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollToBottomAndWait(container, js); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + Browser.True(() => + { + var st = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var sh = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var ch = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + return sh - st - ch < 2; + }, "End mode: viewport should follow new content to the bottom after append"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_End_MidList_ViewportStable(bool variableHeight) + { + MountAnchorModeComponent("2", variableHeight); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollMidListAndWaitForRender(container, js); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + var driftTolerance = 0; + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + "End mode mid-list: viewport should stay stable after prepend", + driftTolerance); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_End_LargeAppendAtBottom_StillFollows(bool variableHeight) + { + MountAnchorModeComponent("2", variableHeight); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollToBottomAndWait(container, js); + + Browser.Exists(By.Id("append-many-items")).Click(); + Browser.Contains("Appended 100 items", () => Browser.Exists(By.Id("status")).Text); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_Beginning_LargePrependAtTop_StillShowsNewItems(bool variableHeight) + { + MountAnchorModeComponent("1", variableHeight); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + Assert.Equal(0, (long)js.ExecuteScript("return arguments[0].scrollTop", container)); + + Browser.Exists(By.Id("prepend-many-items")).Click(); + Browser.Contains("Prepended 100 items", () => Browser.Exists(By.Id("status")).Text); + + var scrollTopAfter = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + Assert.True(scrollTopAfter < 50, + $"Beginning mode: large prepend should still pin to top, but scrollTop was {scrollTopAfter}"); + + Browser.True(() => container.FindElements(By.CssSelector("[data-index='-1']")).Count > 0); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_End_AppendAtMidList_DoesNotAutoScroll(bool variableHeight) + { + MountAnchorModeComponent("2", variableHeight); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollMidListAndWaitForRender(container, js); + + var (indexBefore, relTopBefore, scrollTopBefore) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + var (_, relTopAfter, scrollTopAfter) = GetItemPositionInContainer(js, container, ".item", indexBefore); + Assert.Equal(scrollTopBefore, scrollTopAfter); + var driftTolerance = 0; + Assert.True(Math.Abs(relTopAfter - relTopBefore) <= driftTolerance, + $"End mode mid-list: visible item should not shift on append. " + + $"relTop before: {relTopBefore}, after: {relTopAfter}"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_End_AppendAfterLeavingBottom_DoesNotReengage(bool variableHeight) + { + MountAnchorModeComponent("2", variableHeight); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollToBottomAndWait(container, js); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + Browser.True(() => + { + var st = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var sh = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var ch = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + return sh - st - ch < 2; + }, "End mode: first append should follow to bottom"); + + js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollTop - 500", container); + + var scrollTopBeforeSecondAppend = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + var scrollTopAfterSecondAppend = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + Assert.Equal(scrollTopBeforeSecondAppend, scrollTopAfterSecondAppend); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_Beginning_PrependAfterLeavingTop_DoesNotReengage(bool variableHeight) + { + MountAnchorModeComponent("1", variableHeight); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + Assert.Equal(0, (long)js.ExecuteScript("return arguments[0].scrollTop", container)); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + Browser.True(() => container.FindElements(By.CssSelector("[data-index='-1']")).Count > 0); + + js.ExecuteScript("arguments[0].scrollTop = 3000", container); + Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) > 2000); + + var scrollTopBefore = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + var scrollTopAfter = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + + // Native anchoring adjusts scrollTop to compensate for inserted items. + // The key check is that the viewport did NOT jump back to 0. + Assert.True(scrollTopAfter > 2000, + $"Beginning mode: should not pull user back to top after leaving. " + + $"scrollTop before: {scrollTopBefore}, after: {scrollTopAfter} (expected >2000, not near 0)"); + } + + private void MountWindowScrollAnchorModeComponent(string anchorMode) + { + Browser.MountTestComponent(); + var root = Browser.Exists(By.Id("virtualize-root")); + Browser.True(() => GetElementCount(root, ".item") > 0); + + var select = Browser.Exists(By.Id("anchor-mode-select")); + var selectElement = new SelectElement(select); + selectElement.SelectByValue(anchorMode); + + Browser.True(() => Browser.Exists(By.Id("current-mode")).Text == anchorMode); + Browser.True(() => GetElementCount(root, ".item") > 0); + } + + [Fact] + public void AnchorMode_WindowScroll_None_PrependAtTop_ViewportStaysStable() + { + MountWindowScrollAnchorModeComponent("0"); + + var js = (IJavaScriptExecutor)Browser; + var root = Browser.Exists(By.Id("virtualize-root")); + + // Start at the top of the page — buttons are above the Virtualize content + // so clicking them won't change scroll position. + js.ExecuteScript("window.scrollTo(0, 0)"); + Browser.True(() => (long)js.ExecuteScript("return window.scrollY") == 0); + + Browser.True(() => root.FindElements(By.CssSelector(".item[data-index]")).Count > 0); + var (firstIndexBefore, firstTopBefore) = GetItemPositionInViewport(js, root, ".item[data-index]"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + // Compensation should increase scrollY by the height of the prepended items. + Browser.True(() => + { + var scrollY = (long)js.ExecuteScript("return window.scrollY"); + return scrollY > 100; + }, TimeSpan.FromSeconds(5)); + + // Window scroll can render newly prepended rows above the viewport as overscan, + // so compare the same visible item's viewport position instead of the minimum rendered index. + Browser.True(() => + { + var (_, currentTop) = GetItemPositionInViewport(js, root, ".item[data-index]", firstIndexBefore); + return Math.Abs(currentTop - firstTopBefore) <= 2; + }, TimeSpan.FromSeconds(5)); + + var (_, firstTopAfter) = GetItemPositionInViewport(js, root, ".item[data-index]", firstIndexBefore); + + Assert.True(Math.Abs(firstTopAfter - firstTopBefore) <= 2, + $"Window-scroll None mode: item {firstIndexBefore} moved from {firstTopBefore} to {firstTopAfter} after prepend at top"); + } + private static (string index, double relTop, long scrollTop) GetItemPositionInContainer( IJavaScriptExecutor js, IWebElement container, string itemSelector, string dataIndex = null) { @@ -1888,7 +2426,7 @@ private static (string index, double relTop, long scrollTop) GetItemPositionInCo if (item.getAttribute('data-index') === targetIndex) { return { index: targetIndex, relTop: itemRect.top - containerRect.top, scrollTop: container.scrollTop }; } - } else if (itemRect.top >= containerRect.top - 1 && itemRect.top < containerRect.bottom) { + } else if (itemRect.bottom > containerRect.top + 1 && itemRect.top < containerRect.bottom - 1) { return { index: item.getAttribute('data-index'), relTop: itemRect.top - containerRect.top, scrollTop: container.scrollTop }; } } @@ -1902,6 +2440,65 @@ private static (string index, double relTop, long scrollTop) GetItemPositionInCo Convert.ToInt64(result["scrollTop"], CultureInfo.InvariantCulture)); } + private void AssertViewportStaysStable( + IJavaScriptExecutor js, + By containerSelector, + string itemSelector, + string indexBefore, + double relTopBefore, + string message, + double driftTolerance = 0, + bool compareWholePixels = false) + { + (string index, double relTop, long scrollTop) lastPos = default; + + Browser.True(() => + { + try + { + var container = Browser.Exists(containerSelector); + lastPos = GetItemPositionInContainer(js, container, itemSelector, indexBefore); + var drift = compareWholePixels + ? Math.Abs((int)Math.Round(lastPos.relTop) - (int)Math.Round(relTopBefore)) + : Math.Abs(lastPos.relTop - relTopBefore); + + return drift <= driftTolerance; + } + catch + { + return false; + } + }, $"{message} (index before: {indexBefore}, last index: {lastPos.index}, relTop before: {relTopBefore}, last relTop: {lastPos.relTop}, last scrollTop: {lastPos.scrollTop}, tolerance: {driftTolerance})"); + } + + private static (string index, double top) GetItemPositionInViewport( + IJavaScriptExecutor js, IWebElement root, string itemSelector, string dataIndex = null) + { + var result = js.ExecuteScript(@" + var root = arguments[0]; + var selector = arguments[1]; + var targetIndex = arguments[2]; + var items = root.querySelectorAll(selector); + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var itemRect = item.getBoundingClientRect(); + if (targetIndex != null) { + if (item.getAttribute('data-index') === targetIndex) { + return { index: targetIndex, top: itemRect.top }; + } + } else if (itemRect.bottom > 1 && itemRect.top < window.innerHeight - 1) { + return { index: item.getAttribute('data-index'), top: itemRect.top }; + } + } + return null; + ", root, itemSelector, dataIndex) as Dictionary; + + Assert.NotNull(result); + return ( + result["index"].ToString(), + Convert.ToDouble(result["top"], CultureInfo.InvariantCulture)); + } + /// /// Scrolls through all items detecting visual flashing (backward index jumps). /// Scrolls in 100px increments up to 300 iterations, tracking the top visible item index. diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index 6749e15a575d..b9d17ff8f333 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -134,6 +134,8 @@ + + diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor new file mode 100644 index 000000000000..79a60ec61d50 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor @@ -0,0 +1,125 @@ +@using Microsoft.AspNetCore.Components.Web.Virtualization + +

Virtualization Anchor Mode

+ +
+ +
+ +
+ +
+
Item @item.Index
+
+
+
+ +
+ + + + + +
+ +

@statusMessage

+

@((int)anchorMode)

+ +@code { + private List items = new(); + private string statusMessage = "Ready"; + private VirtualizeAnchorMode anchorMode = VirtualizeAnchorMode.Beginning; + private bool useVariableHeight = false; + + protected override void OnInitialized() + { + items = Enumerable.Range(0, 500) + .Select(i => new DynamicItem { Index = i, Height = GetHeight(i) }) + .ToList(); + } + + private int GetHeight(int index) + { + return useVariableHeight ? 25 + (Math.Abs(index * 7 + 13) % 76) : 50; + } + + private void ToggleVariableHeight() + { + useVariableHeight = !useVariableHeight; + foreach (var item in items) + { + item.Height = GetHeight(item.Index); + } + statusMessage = useVariableHeight ? "Switched to variable heights" : "Switched to fixed heights"; + } + + private void OnAnchorModeChanged(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out var value)) + { + anchorMode = (VirtualizeAnchorMode)value; + statusMessage = $"AnchorMode changed to {anchorMode}"; + } + } + + private int nextPrependIndex = -1; + private int nextAppendIndex = 500; + + private void PrependItems() + { + var newItems = Enumerable.Range(0, 10) + .Select(i => new DynamicItem { Index = nextPrependIndex - i, Height = GetHeight(nextPrependIndex - i) }) + .ToList(); + items.InsertRange(0, newItems); + nextPrependIndex -= 10; + statusMessage = $"Prepended 10 items (indices {newItems.Last().Index}..{newItems.First().Index})"; + } + + private void PrependManyItems() + { + var newItems = Enumerable.Range(0, 100) + .Select(i => new DynamicItem { Index = nextPrependIndex - i, Height = GetHeight(nextPrependIndex - i) }) + .ToList(); + items.InsertRange(0, newItems); + nextPrependIndex -= 100; + statusMessage = $"Prepended 100 items (indices {newItems.Last().Index}..{newItems.First().Index})"; + } + + private void AppendItems() + { + var startIndex = nextAppendIndex; + var newItems = Enumerable.Range(startIndex, 10) + .Select(i => new DynamicItem { Index = i, Height = GetHeight(i) }) + .ToList(); + items.AddRange(newItems); + nextAppendIndex += 10; + statusMessage = $"Appended 10 items (indices {startIndex}..{startIndex + 9})"; + } + + private void AppendManyItems() + { + var startIndex = nextAppendIndex; + var newItems = Enumerable.Range(startIndex, 100) + .Select(i => new DynamicItem { Index = i, Height = GetHeight(i) }) + .ToList(); + items.AddRange(newItems); + nextAppendIndex += 100; + statusMessage = $"Appended 100 items (indices {startIndex}..{startIndex + 99})"; + } + + private class DynamicItem + { + public int Index { get; set; } + public int Height { get; set; } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorModeWindowScroll.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorModeWindowScroll.razor new file mode 100644 index 000000000000..bbc21156ae6d --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorModeWindowScroll.razor @@ -0,0 +1,92 @@ +@using Microsoft.AspNetCore.Components.Web.Virtualization + + + +

Virtualization Anchor Mode (Window Scroll)

+ +
+ +
+ +@* Buttons placed ABOVE content so clicking doesn't scroll the page *@ +
+ + +
+ +

@statusMessage

+

@((int)anchorMode)

+ +
+ @* No scroll container — uses window/document as the scroll root *@ + +
+
Item @item.Index
+
+
+
+ +@code { + private List items = new(); + private string statusMessage = "Ready"; + private VirtualizeAnchorMode anchorMode = VirtualizeAnchorMode.Beginning; + + protected override void OnInitialized() + { + items = Enumerable.Range(0, 500) + .Select(i => new DynamicItem { Index = i, Height = 50 }) + .ToList(); + } + + private void OnAnchorModeChanged(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out var value)) + { + anchorMode = (VirtualizeAnchorMode)value; + statusMessage = $"AnchorMode changed to {anchorMode}"; + } + } + + private int nextPrependIndex = -1; + private int nextAppendIndex = 500; + + private void PrependItems() + { + var newItems = Enumerable.Range(0, 10) + .Select(i => new DynamicItem { Index = nextPrependIndex - i, Height = 50 }) + .ToList(); + items.InsertRange(0, newItems); + nextPrependIndex -= 10; + statusMessage = $"Prepended 10 items (indices {newItems.Last().Index}..{newItems.First().Index})"; + } + + private void AppendItems() + { + var startIndex = nextAppendIndex; + var newItems = Enumerable.Range(startIndex, 10) + .Select(i => new DynamicItem { Index = i, Height = 50 }) + .ToList(); + items.AddRange(newItems); + nextAppendIndex += 10; + statusMessage = $"Appended 10 items (indices {startIndex}..{startIndex + 9})"; + } + + private class DynamicItem + { + public int Index { get; set; } + public int Height { get; set; } + } +}