From f5e5a12a901839ce205c415c114d0d1d5e9307ae Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Wed, 17 Jun 2026 17:26:38 +0200 Subject: [PATCH] add virtual scroll and fix empty results overlay --- client/ui/frontend/package.json | 1 + client/ui/frontend/pnpm-lock.yaml | 14 +++ .../src/components/empty-state/NoResults.tsx | 2 +- .../main/advanced/networks/Networks.tsx | 88 ++++++++++++------- .../src/modules/main/advanced/peers/Peers.tsx | 76 ++++++++++------ 5 files changed, 121 insertions(+), 60 deletions(-) diff --git a/client/ui/frontend/package.json b/client/ui/frontend/package.json index 5094e043b..e3435cb76 100644 --- a/client/ui/frontend/package.json +++ b/client/ui/frontend/package.json @@ -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": { diff --git a/client/ui/frontend/pnpm-lock.yaml b/client/ui/frontend/pnpm-lock.yaml index efa4c0c42..c798ac4e1 100644 --- a/client/ui/frontend/pnpm-lock.yaml +++ b/client/ui/frontend/pnpm-lock.yaml @@ -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 diff --git a/client/ui/frontend/src/components/empty-state/NoResults.tsx b/client/ui/frontend/src/components/empty-state/NoResults.tsx index c14e18686..77d9dbb94 100644 --- a/client/ui/frontend/src/components/empty-state/NoResults.tsx +++ b/client/ui/frontend/src/components/empty-state/NoResults.tsx @@ -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"} /> ); }; diff --git a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx index ffba91c39..8a01a7991 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx @@ -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("all"); + const [scrollParent, setScrollParent] = useState(null); const searchRef = useRef(null); useEffect(() => { @@ -162,28 +170,37 @@ export const Networks = () => { - - - {filtered.length === 0 ? ( - - ) : ( - - )} - - - - - + {filtered.length === 0 ? ( + + ) : ( + + + {scrollParent && ( + + )} + + + + + + )} {filtered.length > 0 && (
{ type NetworksListProps = { data: Network[]; onToggle: (id: string, selected: boolean) => void; + scrollParent: HTMLElement; }; -const NetworksList = ({ data, onToggle }: NetworksListProps) => { +const NetworksHeader = () =>
; + +const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => { const { t } = useTranslation(); return ( -
    - {data.map((n) => ( -
  • n.id} + components={{ Header: NetworksHeader }} + itemContent={(_, n) => ( +
    { label={n.selected ? t("networks.selected") : t("networks.unselected")} />
    -
  • - ))} -
+
+ )} + /> ); }; diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx index 0e46055e6..2ed86eeb9 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -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("all"); + const [scrollParent, setScrollParent] = useState(null); const searchRef = useRef(null); useEffect(() => { @@ -130,41 +132,61 @@ export const Peers = () => {
- - - {filtered.length === 0 ? : } - - - - - + {filtered.length === 0 ? ( + + ) : ( + + + {scrollParent && ( + + )} + + + + + + )} ); }; -const PeersList = ({ data }: { data: PeerStatus[] }) => { +const ListTopSpacer = () =>
; + +type PeersListProps = { + data: PeerStatus[]; + scrollParent: HTMLElement; +}; + +const PeersList = ({ data, scrollParent }: PeersListProps) => { const { t } = useTranslation(); const { setSelected } = usePeerDetail(); return ( -
    - {data.map((peer) => { + peer.pubKey} + components={{ Header: ListTopSpacer }} + itemContent={(_, peer) => { const isConnected = peer.connStatus === "Connected"; return ( -
  • { "opacity-0 group-hover:opacity-100 transition-opacity", )} /> -
  • +
); - })} - + }} + /> ); };