From 298e00f9bde3880d4c673d087a13fd047c5afbb2 Mon Sep 17 00:00:00 2001 From: Pablo Pettinari Date: Mon, 24 Nov 2025 12:40:17 +0100 Subject: [PATCH 1/5] optimize wallet filtering by combining chained filters into single pass --- src/lib/utils/wallets.ts | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/lib/utils/wallets.ts b/src/lib/utils/wallets.ts index 4f62afc490b..741c6b021b4 100644 --- a/src/lib/utils/wallets.ts +++ b/src/lib/utils/wallets.ts @@ -196,27 +196,30 @@ export const filterFn = (data: WalletRow[], filters: FilterOption[]) => { const activeFilterKeys = getActiveFilterKeys(filters) - filters.forEach((filter) => { - filter.items.forEach((item) => { + for (const filter of filters) { + for (const item of filter.items) { if (item.filterKey === "languages") { selectedLanguage = item.inputState as string } else if (item.filterKey === "layer_2_support") { selectedLayer2 = (item.inputState as ChainName[]) || [] } - }) - }) + } + } - return data - .filter((item) => { - return item.languages_supported.includes(selectedLanguage as Lang) - }) - .filter((item) => { - return ( - selectedLayer2.length === 0 || - selectedLayer2.every((chain) => item.supported_chains.includes(chain)) - ) - }) - .filter((item) => { - return activeFilterKeys.every((key) => item[key]) - }) + return data.filter((wallet) => { + // Check language support + const matchesLanguage = wallet.languages_supported.includes( + selectedLanguage as Lang + ) + + // Check layer 2 support (empty array means no filter applied) + const matchesLayer2 = + selectedLayer2.length === 0 || + selectedLayer2.every((chain) => wallet.supported_chains.includes(chain)) + + // Check active filter keys + const matchesActiveFilters = activeFilterKeys.every((key) => wallet[key]) + + return matchesLanguage && matchesLayer2 && matchesActiveFilters + }) } From 678002b9c18782814b2d8b4ae7721c92a52fbea5 Mon Sep 17 00:00:00 2001 From: Pablo Pettinari Date: Mon, 24 Nov 2025 17:36:09 +0100 Subject: [PATCH 2/5] replace Drawer with Sheet --- src/components/ProductTable/MobileFilters.tsx | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/src/components/ProductTable/MobileFilters.tsx b/src/components/ProductTable/MobileFilters.tsx index 3ca6714b111..2dce080ead7 100644 --- a/src/components/ProductTable/MobileFilters.tsx +++ b/src/components/ProductTable/MobileFilters.tsx @@ -6,15 +6,15 @@ import { FilterOption, TPresetFilters } from "@/lib/types" import Filters from "@/components/ProductTable/Filters" import PresetFilters from "@/components/ProductTable/PresetFilters" import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@/components/ui/drawer" + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet" import { trackCustomEvent } from "@/lib/utils/matomo" @@ -51,8 +51,7 @@ const MobileFilters = ({ return (
- { setMobileFiltersOpen(open) @@ -63,7 +62,7 @@ const MobileFilters = ({ }) }} > - +
- - + +
- + - +
- - {t("table-filters")} - + + {t("table-filters")} + {`${activeFiltersCount} ${t("table-active")}`} - - + +
- +
- + - +
-
-
- + + + ) } From 0c6dd2cf492110d854e9a7d166b0ea030e052c38 Mon Sep 17 00:00:00 2001 From: Pablo Pettinari Date: Tue, 25 Nov 2025 14:01:00 +0100 Subject: [PATCH 3/5] fix: first-open animation for persistent panel and extract to reusable component --- src/components/ProductTable/MobileFilters.tsx | 137 +++++++++-------- src/components/ui/persistent-panel.tsx | 141 ++++++++++++++++++ 2 files changed, 208 insertions(+), 70 deletions(-) create mode 100644 src/components/ui/persistent-panel.tsx diff --git a/src/components/ProductTable/MobileFilters.tsx b/src/components/ProductTable/MobileFilters.tsx index 2dce080ead7..88d7d315d46 100644 --- a/src/components/ProductTable/MobileFilters.tsx +++ b/src/components/ProductTable/MobileFilters.tsx @@ -5,16 +5,8 @@ import { FilterOption, TPresetFilters } from "@/lib/types" import Filters from "@/components/ProductTable/Filters" import PresetFilters from "@/components/ProductTable/PresetFilters" -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet" +import { PersistentPanel } from "@/components/ui/persistent-panel" +import { Sheet, SheetTrigger } from "@/components/ui/sheet" import { trackCustomEvent } from "@/lib/utils/matomo" @@ -49,19 +41,22 @@ const MobileFilters = ({ }: MobileFiltersProps) => { const { t } = useTranslation("table") + const handleOpenChange = (open: boolean) => { + setMobileFiltersOpen(open) + trackCustomEvent({ + eventCategory: "MobileFilterToggle", + eventAction: "Tap MobileFilterToggle", + eventName: `show mobile filters ${open}`, + }) + } + + const handleClose = () => { + handleOpenChange(false) + } + return (
- { - setMobileFiltersOpen(open) - trackCustomEvent({ - eventCategory: "MobileFilterToggle", - eventAction: "Tap MobileFilterToggle", - eventName: `show mobile filters ${open}`, - }) - }} - > + - -
- - +
+
+

+ {t("table-filters")} +

+

+ {`${activeFiltersCount} ${t("table-active")}`} +

+
+
+ + +
+
+
+
+ - -
- - {t("table-filters")} - - {`${activeFiltersCount} ${t("table-active")}`} - - -
- - -
- -
-
- -
- - -
-
- - + +
+
+
) } diff --git a/src/components/ui/persistent-panel.tsx b/src/components/ui/persistent-panel.tsx new file mode 100644 index 00000000000..c65d8ddcc71 --- /dev/null +++ b/src/components/ui/persistent-panel.tsx @@ -0,0 +1,141 @@ +import * as React from "react" +import { createPortal } from "react-dom" + +import { cn } from "@/lib/utils/cn" + +interface PersistentPanelProps { + open: boolean + side?: "left" | "right" | "top" | "bottom" + className?: string + children: React.ReactNode + onOpenChange?: (open: boolean) => void +} + +/** + * PersistentPanel keeps content mounted after first render to avoid expensive + * re-renders. It controls visibility with CSS instead of mounting/unmounting. + * + * Use this as an alternative to Sheet, Dialog, or Drawer content when the + * content is expensive to render (e.g., complex filter forms, large lists). + * + * Features: + * - Lazy mount: only renders after first open + * - Stays mounted: avoids re-render cost on subsequent opens + * - Animated: slide-in/out transitions work correctly + * - Accessible: escape key, overlay click, scroll lock, aria attributes + */ +const PersistentPanel = ({ + open, + side = "left", + className, + children, + onOpenChange, +}: PersistentPanelProps) => { + // Track if component should be in DOM (lazy mount, stays mounted after first open) + const [isMounted, setIsMounted] = React.useState(false) + // Track CSS visibility state for animations (separate from open to allow animation timing) + const [showContent, setShowContent] = React.useState(false) + + const overlayRef = React.useRef(null) + + // Mount component on first open + React.useEffect(() => { + if (open && !isMounted) { + setIsMounted(true) + } + }, [open, isMounted]) + + // Handle animation timing - runs after isMounted changes + React.useEffect(() => { + if (!isMounted) return + + if (open) { + // Small delay ensures browser paints the hidden state first, + // allowing the CSS transition to animate + const timer = setTimeout(() => { + setShowContent(true) + }, 20) + return () => clearTimeout(timer) + } else { + // Close immediately to trigger exit animation + setShowContent(false) + } + }, [open, isMounted]) + + // Handle overlay click to close + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === overlayRef.current && onOpenChange) { + onOpenChange(false) + } + } + + // Handle escape key + React.useEffect(() => { + if (!isMounted || !open) return + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && onOpenChange) { + onOpenChange(false) + } + } + + document.addEventListener("keydown", handleEscape) + return () => document.removeEventListener("keydown", handleEscape) + }, [isMounted, open, onOpenChange]) + + // Lock body scroll when panel is visible + React.useEffect(() => { + if (!isMounted || !showContent) return + + const originalOverflow = document.body.style.overflow + document.body.style.overflow = "hidden" + return () => { + document.body.style.overflow = originalOverflow + } + }, [showContent, isMounted]) + + // Don't render until first open + if (!isMounted) { + return null + } + + const overlayClasses = cn( + "fixed inset-0 z-modal bg-gray-800 transition-opacity duration-300", + showContent ? "opacity-70" : "opacity-0 pointer-events-none" + ) + + const contentClasses = cn( + "fixed z-modal bg-background shadow-xl transition-transform duration-300 ease-in-out flex h-full flex-col p-2", + side === "left" && "inset-y-0 left-0 h-full w-full sm:max-w-lg", + side === "right" && "inset-y-0 right-0 h-full w-full sm:max-w-lg", + side === "top" && "inset-x-0 top-0", + side === "bottom" && "inset-x-0 bottom-0", + // Slide animations based on visibility + side === "left" && (showContent ? "translate-x-0" : "-translate-x-full"), + side === "right" && (showContent ? "translate-x-0" : "translate-x-full"), + side === "top" && (showContent ? "translate-y-0" : "-translate-y-full"), + side === "bottom" && (showContent ? "translate-y-0" : "translate-y-full"), + className + ) + + return createPortal( + <> + {/* Overlay */} +