diff --git a/package.json b/package.json index a52b510c1fc..5ffbb741d03 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@radix-ui/react-compose-refs": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-focus-scope": "^1.1.8", "@radix-ui/react-navigation-menu": "^1.2.0", "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-portal": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5cfe3f09a4..28bb2ec3a66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.1 version: 2.1.15(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-scope': + specifier: ^1.1.8 + version: 1.1.8(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-navigation-menu': specifier: ^1.2.0 version: 1.2.13(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2431,6 +2434,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-focus-scope@1.1.8': + resolution: {integrity: sha512-BFjgXkfyRXxFJ0t/Xs4QSsb2wmkDfJ983j4vzC95on81gKPtJdJ+5ESHOuwKGm/umcWd2En33AiEMgyUGSKWQw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-id@1.1.1': resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} peerDependencies: @@ -2544,6 +2560,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-progress@1.1.7': resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} peerDependencies: @@ -2618,6 +2647,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-switch@1.2.5': resolution: {integrity: sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ==} peerDependencies: @@ -11870,6 +11908,17 @@ snapshots: '@types/react': 18.2.57 '@types/react-dom': 18.2.19 + '@radix-ui/react-focus-scope@1.1.8(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.57)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.2.57)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.2.57 + '@types/react-dom': 18.2.19 + '@radix-ui/react-id@1.1.1(@types/react@18.2.57)(react@18.3.1)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.57)(react@18.3.1) @@ -12005,6 +12054,15 @@ snapshots: '@types/react': 18.2.57 '@types/react-dom': 18.2.19 + '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@18.2.57)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.2.57 + '@types/react-dom': 18.2.19 + '@radix-ui/react-progress@1.1.7(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-context': 1.1.2(@types/react@18.2.57)(react@18.3.1) @@ -12103,6 +12161,13 @@ snapshots: optionalDependencies: '@types/react': 18.2.57 + '@radix-ui/react-slot@1.2.4(@types/react@18.2.57)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.57)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.2.57 + '@radix-ui/react-switch@1.2.5(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.2 diff --git a/src/components/Nav/MobileMenu/MobileMenuClient.tsx b/src/components/Nav/MobileMenu/MobileMenuClient.tsx new file mode 100644 index 00000000000..33d9b9b0d4d --- /dev/null +++ b/src/components/Nav/MobileMenu/MobileMenuClient.tsx @@ -0,0 +1,51 @@ +"use client" + +import * as React from "react" + +import { PersistentPanel } from "@/components/ui/persistent-panel" +import { Sheet, SheetTrigger } from "@/components/ui/sheet" + +import { cn } from "@/lib/utils/cn" + +import HamburgerButton from "./HamburgerButton" + +import { useCloseOnNavigate } from "@/hooks/useCloseOnNavigate" + +type MobileMenuClientProps = { + className?: string + side: "left" | "right" + children: React.ReactNode +} + +const MobileMenuClient = ({ + className, + side, + children, +}: MobileMenuClientProps) => { + const [open, setOpen] = useCloseOnNavigate() + const triggerRef = React.useRef(null) + + return ( + + + + + + + {children} + + + ) +} + +export default MobileMenuClient diff --git a/src/components/Nav/MobileMenu/index.tsx b/src/components/Nav/MobileMenu/index.tsx index 9163ef93f3f..8625b941f46 100644 --- a/src/components/Nav/MobileMenu/index.tsx +++ b/src/components/Nav/MobileMenu/index.tsx @@ -12,13 +12,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible" -import { - SheetContent, - SheetFooter, - SheetHeader, - SheetTrigger, -} from "@/components/ui/sheet" -import { SheetCloseOnNavigate } from "@/components/ui/sheet-close-on-navigate" +import { SheetFooter, SheetHeader } from "@/components/ui/sheet" import { cn } from "@/lib/utils/cn" import { isLangRightToLeft } from "@/lib/utils/translations" @@ -28,8 +22,8 @@ import { MOBILE_LANGUAGE_BUTTON_NAME, SECTION_LABELS } from "@/lib/constants" import FooterButton from "./FooterButton" import FooterItemText from "./FooterItemText" -import HamburgerButton from "./HamburgerButton" import MenuHeader from "./MenuHeader" +import MobileMenuClient from "./MobileMenuClient" import ThemeToggleFooterButton from "./ThemeToggleFooterButton" import { getLanguagesDisplayInfo, getNavigation } from "@/lib/nav/links" @@ -49,73 +43,59 @@ export default async function MobileMenu({ const dir = isRtl ? "rtl" : "ltr" return ( - - - - - + + + + + - - - - - - - - - - - - - - -
- - - {t("languages")} - - -
-
- -
-
- - - {t("menu")} - - -
-
-
-
-
-
+ + + + + + + + +
+ + + {t("languages")} + + +
+
+ +
+
+ + + {t("menu")} + + +
+
+
+ + ) } 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..ec7b18d8c66 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" @@ -48,23 +40,27 @@ const MobileFilters = ({ mobileFiltersLabel, }: MobileFiltersProps) => { const { t } = useTranslation("table") + const triggerRef = React.useRef(null) + + 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..dffdd8c8796 --- /dev/null +++ b/src/components/ui/persistent-panel.tsx @@ -0,0 +1,174 @@ +import * as React from "react" +import { createPortal } from "react-dom" +import { FocusScope } from "@radix-ui/react-focus-scope" + +import { cn } from "@/lib/utils/cn" + +interface PersistentPanelProps { + open: boolean + side?: "left" | "right" | "top" | "bottom" + className?: string + children: React.ReactNode + onOpenChange?: (open: boolean) => void + /** Ref to the trigger element - focus returns here on close */ + triggerRef?: React.RefObject +} + +/** + * 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, focus trap, aria attributes + */ +const PersistentPanel = ({ + open, + side = "left", + className, + children, + onOpenChange, + triggerRef, +}: 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) + const contentRef = 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]) + + // Track previous showContent state to detect visibility transitions + const prevShowContentRef = React.useRef(showContent) + + // Handle focus on visibility transitions + // We use showContent (not open) because FocusScope trapped={showContent} + // must be false before focus can leave the panel + React.useEffect(() => { + const wasVisible = prevShowContentRef.current + prevShowContentRef.current = showContent + + if (showContent && !wasVisible && contentRef.current) { + // Panel became visible: focus first element + const firstFocusable = contentRef.current.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + firstFocusable?.focus() + } else if (!showContent && wasVisible) { + // Panel became hidden: restore focus to trigger + // This runs AFTER FocusScope trapped becomes false + triggerRef?.current?.focus() + } + }, [showContent, triggerRef]) + + // 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( + <> +