WIP infinite loader component

This commit is contained in:
Pat Hartl 2026-01-03 15:53:17 -06:00
parent 76aa51dc7a
commit 40871e24aa
9 changed files with 634 additions and 382 deletions

View file

@ -0,0 +1,8 @@
namespace LANCommander.UI.Components.InfiniteLoader;
public class InfiniteLoadResponse<T>
{
public T? Next { get; set; }
public IEnumerable<T>? Items { get; set; }
public bool HasMore { get; set; }
}

View file

@ -0,0 +1,12 @@
@typeparam T
<div class="infinite-scroll" @ref="_scrollHost">
<div class="sentinel" @ref="_sentinel"></div>
@foreach (var i in _items)
{
<div @key="@_keySelector?.Invoke(i)" data-index="@_keySelector?.Invoke(i)">
@ChildContent?.Invoke(i)
</div>
}
</div>

View file

@ -0,0 +1,122 @@
using System.Linq.Expressions;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace LANCommander.UI.Components.InfiniteLoader;
public partial class InfiniteLoader<T> : ComponentBase
{
[Parameter]
public Func<T, int, Task<InfiniteLoadResponse<T>>>? Loader { get; set; }
[Parameter]
public int PageSize { get; set; } = 10;
[Parameter]
public Expression<Func<T, string>>? KeySelector { get; set; }
[Parameter]
public RenderFragment<T>? ChildContent { get; set; }
[Inject]
private IJSRuntime JS { get; set; }
private readonly List<T> _items = new();
private T? _next;
private bool _isLoadingMore;
private bool _hasMore = true;
private Func<T, string>? _keySelector;
private ElementReference _scrollHost;
private ElementReference _sentinel;
private InfiniteScrollInterop? _scrollInterop;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
await LoadInitialAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
return;
_scrollInterop = new InfiniteScrollInterop(JS);
await _scrollInterop.InitializeAsync(_scrollHost, _sentinel);
}
protected override void OnParametersSet()
{
if (KeySelector is null)
throw new ArgumentNullException(nameof(KeySelector));
_keySelector = KeySelector.Compile();
}
private async Task LoadInitialAsync()
{
if (Loader == null)
throw new ArgumentNullException($"{nameof(Loader)} is null");
if (_next is null)
return;
var response = await Loader(_next, PageSize);
_items.Clear();
if (response.Items is not null)
_items.AddRange(response.Items);
_hasMore = response.HasMore;
_next = response.Next;
}
private async Task LoadMoreAsync(object? anchor)
{
if (_isLoadingMore || !_hasMore || Loader is null || _next is null)
return;
_isLoadingMore = true;
try
{
var page = await Loader.Invoke(_next, PageSize);
if (page.Items is not null)
_items.InsertRange(0, page.Items);
_hasMore = page.HasMore;
_next = page.Next;
await InvokeAsync(StateHasChanged);
await _scrollInterop!.RestoreAfterPrependAsync(_scrollHost, anchor);
}
finally
{
_isLoadingMore = false;
}
}
[JSInvokable]
public async Task OnSentinelVisible()
{
if (_scrollInterop is null)
return;
var anchor = await _scrollInterop.CaptureAnchorAsync(_scrollHost);
await LoadMoreAsync(anchor);
}
public async ValueTask DisposeAsync()
{
if (_scrollInterop is not null)
await _scrollInterop.DisposeAsync();
}
}

View file

@ -0,0 +1,50 @@
import {InfiniteScrollAnchor} from "./InfiniteScrollAnchor";
declare const DotNet: typeof import("@microsoft/dotnet-js-interop").DotNet;
export function ObserveSentinel(scrollHost: Element, sentinel: Element) {
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
DotNet.invokeMethodAsync("LANCommander.UI", "OnSentinelVisible");
}
}
}, {
root: scrollHost,
threshold: 0.01
});
observer.observe(sentinel);
}
export function CaptureAnchor(scrollHost: Element): InfiniteScrollAnchor {
const first = scrollHost.querySelector("[data-id]");
if (!first)
return null;
const rect = first.getBoundingClientRect();
const hostRect = scrollHost.getBoundingClientRect();
const anchor = new InfiniteScrollAnchor();
anchor.Id = first.getAttribute("data-id");
anchor.OffsetTop = rect.top - hostRect.top;
return anchor;
}
export function RestoreAfterPrepend(scrollHost: Element, anchor: InfiniteScrollAnchor) {
if (!anchor)
return;
const el = scrollHost.querySelector(`[data-id="${anchor.Id}"]`);
if (!el)
return;
const rect = el.getBoundingClientRect();
const hostRect = scrollHost.getBoundingClientRect();
const newOffset = rect.top - hostRect.top;
scrollHost.scrollTop += (newOffset - anchor.OffsetTop);
}

View file

@ -0,0 +1,4 @@
export class InfiniteScrollAnchor {
public Id: string;
public OffsetTop: number;
}

View file

@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace LANCommander.UI.Components.InfiniteLoader;
public sealed class InfiniteScrollInterop(IJSRuntime js) : IAsyncDisposable
{
private IJSObjectReference? _module;
private IJSObjectReference? _observerHandle;
public async Task InitializeAsync(ElementReference scrollHost, ElementReference sentinel)
{
_module ??= await js.InvokeAsync<IJSObjectReference>("import", "./js/infiniteScroll.js");
_observerHandle = await _module.InvokeAsync<IJSObjectReference>("observeSentinel", scrollHost, sentinel);
}
public async Task<object?> CaptureAnchorAsync(ElementReference scrollHost)
{
_module ??= await js.InvokeAsync<IJSObjectReference>("import", "./js/infiniteScroll.js");
return await _module.InvokeAsync<IJSObjectReference>("CaptureAnchor", scrollHost);
}
public async Task RestoreAfterPrependAsync(ElementReference scrollHost, object? anchor)
{
if (anchor is null)
return;
_module ??= await js.InvokeAsync<IJSObjectReference>("import", "./js/infiniteScroll.js");
await _module.InvokeVoidAsync("RestoreAfterPrepend", scrollHost, anchor);
}
public async ValueTask DisposeAsync()
{
if (_observerHandle is not null)
await _observerHandle.InvokeVoidAsync("dispose");
if (_module is not null)
await _module.DisposeAsync();
}
}

View file

@ -1,3 +1,4 @@
export { CaptureAnchor, ObserveSentinel, RestoreAfterPrepend } from "./Components/InfiniteLoader/InfiniteScroll";
export { CreateUploader } from "./Components/ChunkUploader/UploaderFactory";
export { CreateSplitPane } from "./Components/SplitPane/SplitPaneFactory";
export { CreateTimeProvider } from "./Components/LocalTime/TimeProviderFactory";

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,7 @@
},
"homepage": "https://github.com/LANCommander/LANCommander#readme",
"devDependencies": {
"@microsoft/dotnet-js-interop": "^10.0.0",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^7.1.2",
"mini-css-extract-plugin": "^2.9.0",