feat(VirtualList): add auto-pagination and correct scrollbar height

- Add 'total' prop to VirtualList for accurate scrollbar height in pagination scenarios
- Add 'onNearBottom' callback to trigger auto-loading when user scrolls near end
- Update FontVirtualList to forward the new props
- Implement auto-pagination in SuggestedFonts component (remove manual Load More button)
- Display loading indicator when fetching next batch
- Show accurate font count (e.g., "Showing 150 of 1920 fonts")

Key changes:
- VirtualList now uses total count for height calculation instead of items.length
- Auto-fetches next page when user scrolls within 5 items of the end
- Only fetches if hasMore is true and not already fetching
- Backward compatible: total defaults to items.length when not provided
This commit is contained in:
Ilia Mashkov
2026-01-30 19:21:47 +03:00
parent ef48d9815c
commit 3add50a190
3 changed files with 115 additions and 5 deletions

View File

@@ -19,6 +19,20 @@ interface Props {
* @template T - The type of items in the list
*/
items: T[];
/**
* Total number of items (including not-yet-loaded items for pagination).
* If not provided, defaults to items.length.
*
* Use this when implementing pagination to ensure the scrollbar
* reflects the total count of items, not just the loaded ones.
*
* @example
* ```ts
* // Pagination scenario: 1920 total fonts, but only 50 loaded
* <VirtualList items={loadedFonts} total={1920}>
* ```
*/
total?: number;
/**
* Height for each item, either as a fixed number
* or a function that returns height per index.
@@ -40,6 +54,24 @@ interface Props {
* @param items - Loaded items
*/
onVisibleItemsChange?: (items: T[]) => void;
/**
* An optional callback that will be called when user scrolls near the end of the list.
* Useful for triggering auto-pagination.
*
* The callback receives the index of the last visible item. You can use this
* to determine if you should load more data.
*
* @example
* ```ts
* onNearBottom={(lastVisibleIndex) => {
* const itemsRemaining = total - lastVisibleIndex;
* if (itemsRemaining < 5 && hasMore && !isFetching) {
* loadMore();
* }
* }}
* ```
*/
onNearBottom?: (lastVisibleIndex: number) => void;
/**
* Snippet for rendering individual list items.
*
@@ -55,10 +87,19 @@ interface Props {
children: Snippet<[{ item: T; index: number; isVisible: boolean; proximity: number }]>;
}
let { items, itemHeight = 80, overscan = 5, class: className, onVisibleItemsChange, children }: Props = $props();
let {
items,
total = items.length,
itemHeight = 80,
overscan = 5,
class: className,
onVisibleItemsChange,
onNearBottom,
children,
}: Props = $props();
const virtualizer = createVirtualizer(() => ({
count: items.length,
count: total,
data: items,
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
overscan,
@@ -67,6 +108,16 @@ const virtualizer = createVirtualizer(() => ({
$effect(() => {
const visibleItems = virtualizer.items.map(item => items[item.index]);
onVisibleItemsChange?.(visibleItems);
// Trigger onNearBottom when user scrolls near the end (within 5 items)
if (virtualizer.items.length > 0 && onNearBottom) {
const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1];
const itemsRemaining = total - lastVisibleItem.index;
if (itemsRemaining <= 5) {
onNearBottom(lastVisibleItem.index);
}
}
});
</script>
@@ -96,12 +147,14 @@ $effect(() => {
class="absolute top-0 left-0 w-full"
style:transform="translateY({item.start}px)"
>
{@render children({
{#if item.index < items.length}
{@render children({
item: items[item.index],
index: item.index,
isVisible: item.isVisible,
proximity: item.proximity,
})}
{/if}
</div>
{/each}
</div>