mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-01 01:30:46 +08:00
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:
Generated
+31
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user