diff --git a/apps/web/src/lib/components/VirtualList.svelte b/apps/web/src/lib/components/VirtualList.svelte index c7f3d02..9f11f20 100644 --- a/apps/web/src/lib/components/VirtualList.svelte +++ b/apps/web/src/lib/components/VirtualList.svelte @@ -4,52 +4,84 @@ /** * Fixed-row virtual window for long scroll lists (Svelte 5). * Prefer with server pagination — only mounts visible rows + overscan. - * Use table-layout/fixed column widths in the row markup to avoid scroll jitter. + * Short lists render naturally (no scrollport / spacer) so a single row + * does not leave a scrollable empty area. */ let { items, estimateSize = 80, overscan = 8, + maxHeight = 640, class: className = "", getKey, + onNearEnd, + loadingMore = false, children }: { items: T[]; estimateSize?: number; overscan?: number; + /** Cap for the scrollport; below this, list is not scrollable. */ + maxHeight?: number; class?: string; getKey?: (item: T, index: number) => string | number; + /** Fired when the user scrolls near the bottom (infinite scroll). */ + onNearEnd?: () => void; + loadingMore?: boolean; children: Snippet<[T, number]>; } = $props(); let scrollEl = $state(null); let scrollTop = $state(0); let viewportHeight = $state(0); + let nearEndArmed = $state(true); const rowHeight = $derived(Math.max(1, Math.floor(estimateSize) || 80)); - const totalHeight = $derived(items.length * rowHeight); + const loaderHeight = $derived(loadingMore ? 40 : 0); + const totalHeight = $derived(items.length * rowHeight + loaderHeight); + const cap = $derived(Math.max(rowHeight, Math.floor(maxHeight) || 640)); + const scrollable = $derived(totalHeight > cap); const startIndex = $derived( - Math.max(0, Math.floor(scrollTop / rowHeight) - Math.max(0, overscan)) + scrollable + ? Math.max(0, Math.floor(scrollTop / rowHeight) - Math.max(0, overscan)) + : 0 ); const endIndex = $derived( - Math.min( - items.length, - Math.ceil((scrollTop + Math.max(viewportHeight, rowHeight)) / rowHeight) + - Math.max(0, overscan) - ) + scrollable + ? Math.min( + items.length, + Math.ceil((scrollTop + Math.max(viewportHeight, rowHeight)) / rowHeight) + + Math.max(0, overscan) + ) + : items.length ); const visible = $derived(items.slice(startIndex, endIndex)); const offsetY = $derived(startIndex * rowHeight); + function maybeNearEnd() { + if (!onNearEnd || !nearEndArmed || loadingMore || !scrollable || items.length === 0) return; + const threshold = rowHeight * 4; + if (scrollTop + viewportHeight >= totalHeight - threshold) { + nearEndArmed = false; + onNearEnd(); + } + } + function onScroll() { - if (scrollEl) scrollTop = scrollEl.scrollTop; + if (!scrollable || !scrollEl) return; + scrollTop = scrollEl.scrollTop; + maybeNearEnd(); } $effect(() => { const el = scrollEl; - if (!el) return; + if (!el || !scrollable) { + viewportHeight = 0; + return; + } const update = () => { viewportHeight = el.clientHeight; + maybeNearEnd(); }; update(); const ro = new ResizeObserver(update); @@ -57,40 +89,73 @@ return () => ro.disconnect(); }); + // Reset scroll only when the list head changes (new filter/page), not on append. + let prevHead: T | undefined = undefined; $effect(() => { - // New page / filter result — restart at the top of the window. const first = items[0]; - const headKey = - first !== undefined && getKey ? String(getKey(first, 0)) : String(items.length); - void headKey; - if (scrollEl) { - scrollEl.scrollTop = 0; - scrollTop = 0; + if (first !== prevHead) { + prevHead = first; + if (scrollEl && scrollable) { + scrollEl.scrollTop = 0; + scrollTop = 0; + } + nearEndArmed = true; } }); $effect(() => { - // Reset scroll window when the item set shrinks below the prior offset. void items.length; - if (scrollEl && scrollEl.scrollTop > totalHeight) { + void loadingMore; + void scrollable; + nearEndArmed = true; + if (scrollable && scrollEl && totalHeight > 0 && scrollEl.scrollTop > totalHeight) { scrollEl.scrollTop = Math.max(0, totalHeight - viewportHeight); scrollTop = scrollEl.scrollTop; } + if (!scrollable) scrollTop = 0; + queueMicrotask(() => maybeNearEnd()); }); -
-
-
- {#each visible as item, i (getKey ? getKey(item, startIndex + i) : startIndex + i)} - {@render children(item, startIndex + i)} - {/each} +{#if scrollable} +
+
+
+ {#each visible as item, i (getKey ? getKey(item, startIndex + i) : startIndex + i)} + {@render children(item, startIndex + i)} + {/each} +
+ {#if loadingMore} +
+ +
+ {/if}
-
+{:else} +
+ {#each items as item, i (getKey ? getKey(item, i) : i)} + {@render children(item, i)} + {/each} + {#if loadingMore} +
+ +
+ {/if} +
+{/if} diff --git a/apps/web/src/lib/components/products/ProductPagination.svelte b/apps/web/src/lib/components/products/ProductPagination.svelte index eb3c666..29fbdef 100644 --- a/apps/web/src/lib/components/products/ProductPagination.svelte +++ b/apps/web/src/lib/components/products/ProductPagination.svelte @@ -39,6 +39,11 @@ }); }); + const canGoNext = $derived( + currentPage < totalPages && + (!sequentialOnly || Boolean(sequentialNextQuery(nextCursor, nextAfterId))) + ); + const pages = $derived.by(() => { const out: (number | "…")[] = []; if (totalPages <= 7) { @@ -106,7 +111,7 @@