Add VirtualList component backed by virtua

A reusable windowing wrapper that mounts virtua's Virtualizer against
the existing OverlayScrollbars viewport, so the app-wide custom
scrollbar is preserved.
This commit is contained in:
Rūdolfs Ošiņš
2026-07-03 11:27:33 +02:00
parent 8970fd6f68
commit 2bc474b9ce
7 changed files with 298 additions and 199 deletions
+31
View File
@@ -21,6 +21,7 @@
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-svelte": "^0.5.5",
"semver": "^7.8.0",
"virtua": "^0.49.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -4876,6 +4877,36 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/virtua": {
"version": "0.49.1",
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.49.1.tgz",
"integrity": "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.14.0",
"react-dom": ">=16.14.0",
"solid-js": ">=1.0",
"svelte": ">=5.0",
"vue": ">=3.2"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/vite": {
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
+1
View File
@@ -37,6 +37,7 @@
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-svelte": "^0.5.5",
"semver": "^7.8.0",
"virtua": "^0.49.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -1,44 +0,0 @@
<script lang="ts">
import { getScrollViewport } from "@app/components/ScrollArea.svelte";
interface Props {
onIntersect: () => void | Promise<void>;
disabled?: boolean;
rootMargin?: string;
}
const {
onIntersect,
disabled = false,
rootMargin = "500%",
}: Props = $props();
const getViewport = getScrollViewport();
let sentinel: HTMLElement | undefined = $state();
let firing = false;
$effect(() => {
if (disabled) return;
const viewport = getViewport();
if (!viewport || !sentinel) return;
if (!("IntersectionObserver" in window)) return;
const observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
if (!entry.isIntersecting || firing) continue;
firing = true;
void Promise.resolve(onIntersect()).finally(() => {
firing = false;
});
}
},
{ root: viewport, rootMargin },
);
observer.observe(sentinel);
return () => observer.disconnect();
});
</script>
<div bind:this={sentinel} aria-hidden="true" style="height: 1px;"></div>
+76
View File
@@ -0,0 +1,76 @@
<script lang="ts" generics="T">
import type { Snippet } from "svelte";
import { Virtualizer } from "virtua/svelte";
import { getScrollViewport } from "@app/components/ScrollArea.svelte";
interface Props {
items: readonly T[];
row: Snippet<[T, number]>;
hasMore?: boolean;
loadingMore?: boolean;
onLoadMore?: () => void;
getKey?: (item: T, index: number) => string | number;
// Estimated item size in pixels; reduces scrollbar jump before measurement.
estimatedItemSize?: number;
// Extra pixels rendered before/after the viewport.
bufferSize?: number;
// Height of any content rendered before this list inside the same scroller.
startMargin?: number;
// Distance from the bottom (in pixels) at which to request the next page.
prefetchPx?: number;
}
const {
items,
row,
hasMore = false,
loadingMore = false,
onLoadMore = undefined,
getKey = undefined,
estimatedItemSize = undefined,
bufferSize = undefined,
startMargin = undefined,
prefetchPx = 800,
}: Props = $props();
const getViewport = getScrollViewport();
// Resolves once ScrollArea publishes its viewport (set in OverlayScrollbars'
// `initialized`, which is deferred).
const viewport = $derived(getViewport());
function maybeLoadMore() {
if (!onLoadMore || !hasMore || loadingMore) return;
const el = viewport;
if (!el) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - prefetchPx) {
onLoadMore();
}
}
$effect(() => {
// After the dataset grows (or the viewport first appears), the list may not
// fill the viewport yet, so no scroll event would fire. Re-check on the next
// frame, once Virtualizer has sized the scroll container.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(items.length, hasMore, loadingMore, viewport);
const id = requestAnimationFrame(() => maybeLoadMore());
return () => cancelAnimationFrame(id);
});
</script>
{#if viewport}
<Virtualizer
data={items}
scrollRef={viewport}
{getKey}
itemSize={estimatedItemSize}
{bufferSize}
{startMargin}
onscroll={maybeLoadMore}>
{#snippet children(item: T, index: number)}
{@render row(item, index)}
{/snippet}
</Virtualizer>
{/if}
+48 -41
View File
@@ -25,10 +25,10 @@
import CobCacheWarning from "@app/components/CobCacheWarning.svelte";
import FuzzySearch from "@app/components/FuzzySearch.svelte";
import Icon from "@app/components/Icon.svelte";
import InfiniteScrollSentinel from "@app/components/InfiniteScrollSentinel.svelte";
import IssueTeaser from "@app/components/IssueTeaser.svelte";
import ScrollArea from "@app/components/ScrollArea.svelte";
import Topbar from "@app/components/Topbar.svelte";
import VirtualList from "@app/components/VirtualList.svelte";
import CreateIssueModal from "@app/modals/CreateIssue.svelte";
import Layout from "./Layout.svelte";
@@ -53,6 +53,9 @@
let debouncedSearch = $state("");
let showSearch = $state(false);
let cacheState: CacheEvent | undefined = $state();
// Height of the cache-warning banner above the list; fed to the virtualizer
// as a start offset since it shares the same scroll viewport.
let chromeHeight = $state(0);
const project = $derived(repo.payloads["xyz.radicle.project"]!);
@@ -226,11 +229,8 @@
.filter .global-counter-badge {
margin-left: 0.25rem;
}
.list {
display: flex;
flex-direction: column;
gap: 1px;
min-height: 100%;
.row {
border-bottom: 1px solid var(--color-border-subtle);
}
</style>
@@ -316,44 +316,51 @@
</Topbar>
<ScrollArea style="height: 100%; min-width: 0;">
{#if issueCountMismatch(status)}
<CobCacheWarning
noun="issues"
{cacheState}
onRebuild={rebuildIssueCache} />
{/if}
<div class="list">
{#each searchResults as result}
<IssueTeaser
focussed={searchResults.length === 1 && searchInput !== ""}
issue={result.obj.issue}
rid={repo.rid}
{status} />
{/each}
{#if searchResults.length === 0}
<div
class="global-flex"
style:flex="1"
style:justify-content="center"
style:align-items="center">
<div
class="txt-missing txt-body-m-regular global-flex"
style:gap="0.25rem">
{#if items.length > 0 && searchResults.length === 0}
No matching issues
{:else}
No {status === "all" ? "" : status} issues
{/if}
</div>
</div>
<div bind:clientHeight={chromeHeight}>
{#if issueCountMismatch(status)}
<CobCacheWarning
noun="issues"
{cacheState}
onRebuild={rebuildIssueCache} />
{/if}
</div>
<InfiniteScrollSentinel
onIntersect={loadMoreContent}
disabled={!more || loadingMore} />
{#if searchResults.length === 0}
<div
class="global-flex"
style:flex="1"
style:justify-content="center"
style:align-items="center">
<div
class="txt-missing txt-body-m-regular global-flex"
style:gap="0.25rem">
{#if items.length > 0}
No matching issues
{:else}
No {status === "all" ? "" : status} issues
{/if}
</div>
</div>
{:else}
<VirtualList
items={searchResults}
hasMore={more}
{loadingMore}
onLoadMore={() => loadMoreContent()}
startMargin={chromeHeight}
estimatedItemSize={80}
getKey={result => result.obj.issue.id}>
{#snippet row(result)}
<div class="row">
<IssueTeaser
focussed={searchResults.length === 1 && searchInput !== ""}
issue={result.obj.issue}
rid={repo.rid}
{status} />
</div>
{/snippet}
</VirtualList>
{/if}
</ScrollArea>
</div>
</Layout>
+48 -41
View File
@@ -23,11 +23,11 @@
import CobCacheWarning from "@app/components/CobCacheWarning.svelte";
import FuzzySearch from "@app/components/FuzzySearch.svelte";
import Icon from "@app/components/Icon.svelte";
import InfiniteScrollSentinel from "@app/components/InfiniteScrollSentinel.svelte";
import NewPatchButton from "@app/components/NewPatchButton.svelte";
import PatchTeaser from "@app/components/PatchTeaser.svelte";
import ScrollArea from "@app/components/ScrollArea.svelte";
import Topbar from "@app/components/Topbar.svelte";
import VirtualList from "@app/components/VirtualList.svelte";
import Layout from "./Layout.svelte";
@@ -51,6 +51,9 @@
let debouncedSearch = $state("");
let showSearch = $state(false);
let cacheState: CacheEvent | undefined = $state();
// Height of the cache-warning banner above the list; fed to the virtualizer
// as a start offset since it shares the same scroll viewport.
let chromeHeight = $state(0);
const project = $derived(repo.payloads["xyz.radicle.project"]!);
@@ -228,11 +231,8 @@
display: inline;
}
}
.list {
display: flex;
flex-direction: column;
gap: 1px;
min-height: 100%;
.row {
border-bottom: 1px solid var(--color-border-subtle);
}
</style>
@@ -344,44 +344,51 @@
</Topbar>
<ScrollArea style="height: 100%; min-width: 0;">
{#if patchCountMismatch(status)}
<CobCacheWarning
noun="patches"
{cacheState}
onRebuild={rebuildPatchCache} />
{/if}
<div class="list">
{#each searchResults as result}
<PatchTeaser
focussed={searchResults.length === 1 && searchInput !== ""}
patch={result.obj.patch}
rid={repo.rid}
{status} />
{/each}
{#if searchResults.length === 0}
<div
class="global-flex"
style:flex="1"
style:justify-content="center"
style:align-items="center">
<div
class="txt-missing txt-body-m-regular global-flex"
style:gap="0.25rem">
{#if items.length > 0 && searchResults.length === 0}
No matching patches
{:else}
No {status === undefined ? "" : status} patches
{/if}
</div>
</div>
<div bind:clientHeight={chromeHeight}>
{#if patchCountMismatch(status)}
<CobCacheWarning
noun="patches"
{cacheState}
onRebuild={rebuildPatchCache} />
{/if}
</div>
<InfiniteScrollSentinel
onIntersect={loadMoreContent}
disabled={!more || loadingMore} />
{#if searchResults.length === 0}
<div
class="global-flex"
style:flex="1"
style:justify-content="center"
style:align-items="center">
<div
class="txt-missing txt-body-m-regular global-flex"
style:gap="0.25rem">
{#if items.length > 0}
No matching patches
{:else}
No {status === undefined ? "" : status} patches
{/if}
</div>
</div>
{:else}
<VirtualList
items={searchResults}
hasMore={more}
{loadingMore}
onLoadMore={() => loadMoreContent()}
startMargin={chromeHeight}
estimatedItemSize={80}
getKey={result => result.obj.patch.id}>
{#snippet row(result)}
<div class="row">
<PatchTeaser
focussed={searchResults.length === 1 && searchInput !== ""}
patch={result.obj.patch}
rid={repo.rid}
{status} />
</div>
{/snippet}
</VirtualList>
{/if}
</ScrollArea>
</div>
</Layout>
+94 -73
View File
@@ -13,10 +13,10 @@
import CobCommitTeaser from "@app/components/CobCommitTeaser.svelte";
import FuzzySearch from "@app/components/FuzzySearch.svelte";
import InfiniteScrollSentinel from "@app/components/InfiniteScrollSentinel.svelte";
import RepoHeader from "@app/components/RepoHeader.svelte";
import ScrollArea from "@app/components/ScrollArea.svelte";
import SourceHeader from "@app/components/SourceHeader.svelte";
import VirtualList from "@app/components/VirtualList.svelte";
import Layout from "./Layout.svelte";
@@ -172,6 +172,29 @@
return Object.values(groups);
});
type CommitRow =
| { type: "header"; key: string; label: string }
| { type: "commit"; commit: Commit; first: boolean; last: boolean };
// Flatten the day groups into a single row stream so the list can be
// virtualized; the row snippet renders headers and commits differently.
// `first`/`last` mark a group's boundaries so its commits form a bordered card.
const commitRows = $derived.by<CommitRow[]>(() => {
const rows: CommitRow[] = [];
for (const group of groupedCommits) {
rows.push({ type: "header", key: group.key, label: group.label });
group.commits.forEach((commit, i) => {
rows.push({
type: "commit",
commit,
first: i === 0,
last: i === group.commits.length - 1,
});
});
}
return rows;
});
</script>
<style>
@@ -181,34 +204,27 @@
height: 100%;
min-height: 0;
}
.content {
display: flex;
flex-direction: column;
flex: 1;
padding: 1rem;
}
.group + .group {
margin-top: 1.5rem;
}
.group-title {
color: var(--color-text-secondary);
font: var(--txt-body-m-regular);
margin-bottom: 0.75rem;
}
.commit-list {
display: flex;
flex-direction: column;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-md);
overflow: hidden;
background: var(--color-surface-canvas);
padding: 1rem 1rem 0.75rem;
}
.commit-item {
margin: 0 1rem;
padding: 0.625rem 1rem;
cursor: pointer;
background: var(--color-surface-canvas);
border: 1px solid var(--color-border-subtle);
border-bottom: none;
}
.commit-item + .commit-item {
border-top: 1px solid var(--color-border-subtle);
.commit-item.first {
border-top-left-radius: var(--border-radius-md);
border-top-right-radius: var(--border-radius-md);
}
.commit-item.last {
border-bottom: 1px solid var(--color-border-subtle);
border-bottom-left-radius: var(--border-radius-md);
border-bottom-right-radius: var(--border-radius-md);
}
.commit-item:hover {
background: var(--color-surface-subtle);
@@ -256,62 +272,67 @@
{/snippet}
</SourceHeader>
<ScrollArea style="flex: 1; min-height: 0;">
<div class="content">
{#each groupedCommits as group (group.key)}
<section class="group">
<div class="group-title">{group.label}</div>
<div class="commit-list">
{#each group.commits as commit (commit.id)}
<div
class="commit-item"
role="button"
tabindex="0"
onclick={() => {
{#if filteredCommits.length === 0}
<div
class="global-flex"
style:flex="1"
style:justify-content="center"
style:align-items="center">
<div
class="txt-missing txt-body-m-regular global-flex"
style:gap="0.25rem">
{#if items.length > 0}
No matching commits
{:else}
No commits
{/if}
</div>
</div>
{:else}
<VirtualList
items={commitRows}
hasMore={more}
{loadingMore}
onLoadMore={() => loadMoreContent()}
getKey={r =>
r.type === "header" ? `h:${r.key}` : `c:${r.commit.id}`}>
{#snippet row(item)}
{#if item.type === "header"}
<div class="group-title">{item.label}</div>
{:else}
<div
class="commit-item"
class:first={item.first}
class:last={item.last}
role="button"
tabindex="0"
onclick={() => {
void router.push({
resource: "repo.commit",
rid: repo.rid,
commit: item.commit.id,
});
}}
onkeydown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
void router.push({
resource: "repo.commit",
rid: repo.rid,
commit: commit.id,
commit: item.commit.id,
});
}}
onkeydown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
void router.push({
resource: "repo.commit",
rid: repo.rid,
commit: commit.id,
});
}
}}>
<CobCommitTeaser {commit} disabled={false} flush hoverable />
</div>
{/each}
</div>
</section>
{/each}
{#if filteredCommits.length === 0}
<div
class="global-flex"
style:flex="1"
style:justify-content="center"
style:align-items="center">
<div
class="txt-missing txt-body-m-regular global-flex"
style:gap="0.25rem">
{#if items.length > 0}
No matching commits
{:else}
No commits
{/if}
</div>
</div>
{/if}
<InfiniteScrollSentinel
onIntersect={loadMoreContent}
disabled={!more || loadingMore} />
</div>
}
}}>
<CobCommitTeaser
commit={item.commit}
disabled={false}
flush
hoverable />
</div>
{/if}
{/snippet}
</VirtualList>
{/if}
</ScrollArea>
</div>
</Layout>