diff --git a/src/components/ProductTable/Filter.tsx b/src/components/ProductTable/Filter.tsx index 37ee1e48e26..6687cdcc2af 100644 --- a/src/components/ProductTable/Filter.tsx +++ b/src/components/ProductTable/Filter.tsx @@ -14,6 +14,37 @@ interface FilterProps { onChange: (updatedFilter: FilterOption) => void } +const arePropsEqual = (prevProps: FilterProps, nextProps: FilterProps) => { + if (prevProps.filterIndex !== nextProps.filterIndex) return false + if (prevProps.filter.title !== nextProps.filter.title) return false + if (prevProps.filter.showFilterOption !== nextProps.filter.showFilterOption) + return false + + const prevItems = prevProps.filter.items + const nextItems = nextProps.filter.items + + if (prevItems.length !== nextItems.length) return false + + for (let i = 0; i < prevItems.length; i++) { + const prevItem = prevItems[i] + const nextItem = nextItems[i] + + if (prevItem.filterKey !== nextItem.filterKey) return false + if (prevItem.inputState !== nextItem.inputState) return false + + if (prevItem.options.length !== nextItem.options.length) return false + + for (let j = 0; j < prevItem.options.length; j++) { + if (prevItem.options[j].filterKey !== nextItem.options[j].filterKey) + return false + if (prevItem.options[j].inputState !== nextItem.options[j].inputState) + return false + } + } + + return true +} + const Filter = ({ filter, filterIndex, onChange }: FilterProps) => { const handleChange = ( _: number, @@ -110,4 +141,4 @@ const Filter = ({ filter, filterIndex, onChange }: FilterProps) => { ) } -export default memo(Filter) +export default memo(Filter, arePropsEqual) diff --git a/src/components/ProductTable/MobileFilters.tsx b/src/components/ProductTable/MobileFilters.tsx index 3ca6714b111..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 { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@/components/ui/drawer" +import { PersistentPanel } from "@/components/ui/persistent-panel" +import { Sheet, SheetTrigger } from "@/components/ui/sheet" import { trackCustomEvent } from "@/lib/utils/matomo" @@ -49,21 +41,23 @@ 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/ProductTable/index.tsx b/src/components/ProductTable/index.tsx index 0467c21eeb2..5afba62ad64 100644 --- a/src/components/ProductTable/index.tsx +++ b/src/components/ProductTable/index.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from "react" +import { + startTransition, + useCallback, + useDeferredValue, + useEffect, + useMemo, + useState, +} from "react" import { useSearchParams } from "next/navigation" import type { FilterOption, TPresetFilters } from "@/lib/types" @@ -77,15 +84,17 @@ const ProductTable = ({ const updateFilters = useCallback( (filters: FilterOption | FilterOption[]) => { - setFilters((prevFilters) => { - return prevFilters.map((prevFilter) => { - const filter = Array.isArray(filters) - ? filters.find((f) => f.title === prevFilter.title) - : filters.title === prevFilter.title - ? filters - : prevFilter - if (!filter) return prevFilter - return filter + startTransition(() => { + setFilters((prevFilters) => { + return prevFilters.map((prevFilter) => { + const filter = Array.isArray(filters) + ? filters.find((f) => f.title === prevFilter.title) + : filters.title === prevFilter.title + ? filters + : prevFilter + if (!filter) return prevFilter + return filter + }) }) }) }, @@ -93,7 +102,9 @@ const ProductTable = ({ ) const resetFilters = useCallback(() => { - setFilters(initialFilters) + startTransition(() => { + setFilters(initialFilters) + }) onResetFilters?.() }, [initialFilters, onResetFilters]) @@ -114,10 +125,7 @@ const ProductTable = ({ }) }, [filteredData, presetFilters]) - const activeFiltersCount = useMemo( - () => getActiveFiltersCount(filters), - [filters] - ) + const activeFiltersCount = useDeferredValue(getActiveFiltersCount(filters)) return (
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 */} +