add virtual scroll and fix empty results overlay

This commit is contained in:
Eduard Gert
2026-06-17 17:26:38 +02:00
parent 6881c0f985
commit f5e5a12a90
5 changed files with 121 additions and 60 deletions

View File

@@ -36,6 +36,7 @@
"react-i18next": "^17.0.8",
"react-loading-skeleton": "^3.5.0",
"react-router-dom": "^7.1.3",
"react-virtuoso": "^4.12.5",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {

View File

@@ -74,6 +74,9 @@ importers:
react-router-dom:
specifier: ^7.1.3
version: 7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-virtuoso:
specifier: ^4.12.5
version: 4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
tailwind-merge:
specifier: ^2.6.0
version: 2.6.1
@@ -1456,6 +1459,12 @@ packages:
'@types/react':
optional: true
react-virtuoso@4.18.7:
resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==}
peerDependencies:
react: '>=16 || >=17 || >= 18 || >= 19'
react-dom: '>=16 || >=17 || >= 18 || >=19'
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
@@ -2800,6 +2809,11 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.29
react-virtuoso@4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react@18.3.1:
dependencies:
loose-envify: 1.4.0

View File

@@ -16,7 +16,7 @@ export const NoResults = ({ icon = FunnelXIcon, title, description }: Props) =>
icon={icon}
title={title ?? t("common.noResults.title")}
description={description ?? t("common.noResults.description")}
className={"relative -top-[3.8rem]"}
className={"relative -top-[3.8rem] pointer-events-none"}
/>
);
};

View File

@@ -1,6 +1,13 @@
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import {
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
} from "react";
import { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Virtuoso } from "react-virtuoso";
import { GlobeIcon, Layers3Icon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
import type { Network } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
@@ -67,6 +74,7 @@ export const Networks = () => {
const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<NetworkFilter>("all");
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -162,28 +170,37 @@ export const Networks = () => {
</div>
<NetworkFilters value={filter} onChange={setFilter} counts={counts} />
</div>
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
{filtered.length === 0 ? (
<NoResults />
) : (
<NetworksList data={filtered} onToggle={toggleNetwork} />
)}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
{filtered.length === 0 ? (
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport
ref={setScrollParent}
className={"h-full w-full"}
>
{scrollParent && (
<NetworksList
data={filtered}
onToggle={toggleNetwork}
scrollParent={scrollParent}
/>
)}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
)}
{filtered.length > 0 && (
<div
className={cn(
@@ -218,18 +235,25 @@ export const Networks = () => {
type NetworksListProps = {
data: Network[];
onToggle: (id: string, selected: boolean) => void;
scrollParent: HTMLElement;
};
const NetworksList = ({ data, onToggle }: NetworksListProps) => {
const NetworksHeader = () => <div className={"h-2"} />;
const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
const { t } = useTranslation();
return (
<ul className={"flex flex-col"}>
{data.map((n) => (
<li
key={n.id}
<Virtuoso
data={data}
customScrollParent={scrollParent}
increaseViewportBy={400}
computeItemKey={(_, n) => n.id}
components={{ Header: NetworksHeader }}
itemContent={(_, n) => (
<div
className={cn(
"group relative flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0 first:mt-2",
"group relative flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable",
)}
@@ -265,9 +289,9 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
label={n.selected ? t("networks.selected") : t("networks.unselected")}
/>
</div>
</li>
))}
</ul>
</div>
)}
/>
);
};

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Virtuoso } from "react-virtuoso";
import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react";
import type { PeerStatus } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
@@ -45,6 +46,7 @@ export const Peers = () => {
const { status } = useStatus();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -130,41 +132,61 @@ export const Peers = () => {
</div>
<PeerFilters value={statusFilter} onChange={setStatusFilter} counts={counts} />
</div>
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
{filtered.length === 0 ? <NoResults /> : <PeersList data={filtered} />}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
{filtered.length === 0 ? (
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport
ref={setScrollParent}
className={"h-full w-full"}
>
{scrollParent && (
<PeersList data={filtered} scrollParent={scrollParent} />
)}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
)}
</div>
);
};
const PeersList = ({ data }: { data: PeerStatus[] }) => {
const ListTopSpacer = () => <div className={"h-2"} />;
type PeersListProps = {
data: PeerStatus[];
scrollParent: HTMLElement;
};
const PeersList = ({ data, scrollParent }: PeersListProps) => {
const { t } = useTranslation();
const { setSelected } = usePeerDetail();
return (
<ul className={"flex flex-col"}>
{data.map((peer) => {
<Virtuoso
data={data}
customScrollParent={scrollParent}
increaseViewportBy={400}
computeItemKey={(_, peer) => peer.pubKey}
components={{ Header: ListTopSpacer }}
itemContent={(_, peer) => {
const isConnected = peer.connStatus === "Connected";
return (
<li
key={peer.pubKey}
<div
className={cn(
"group relative flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0 first:mt-2",
"group relative flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable",
)}
@@ -229,9 +251,9 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
"opacity-0 group-hover:opacity-100 transition-opacity",
)}
/>
</li>
</div>
);
})}
</ul>
}}
/>
);
};