From 30a2c3d740c7c8210498118f30e6d520b2c2e74a Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 18 Jan 2026 21:36:23 +0100 Subject: [PATCH 1/2] feat: enhance project context menu with theme submenu improvements - Added handlers for theme submenu to manage mouse enter/leave events with a delay, preventing premature closure. - Implemented dynamic positioning for the submenu to avoid viewport overflow, ensuring better visibility. - Updated styles to accommodate new positioning logic and added scroll functionality for theme selection. These changes improve user experience by making the theme selection process more intuitive and visually accessible. --- .../components/project-context-menu.tsx | 91 +++++++++++++++++-- .../project-selector-with-options.tsx | 4 +- 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx index af63af32..4e33729a 100644 --- a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, memo, useCallback } from 'react'; +import { useEffect, useRef, useState, memo, useCallback, useMemo } from 'react'; import type { LucideIcon } from 'lucide-react'; import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react'; import { toast } from 'sonner'; @@ -130,9 +130,76 @@ export function ProjectContextMenu({ const [showThemeSubmenu, setShowThemeSubmenu] = useState(false); const [removeConfirmed, setRemoveConfirmed] = useState(false); const themeSubmenuRef = useRef(null); + const closeTimeoutRef = useRef | null>(null); const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); + // Handler to open theme submenu and cancel any pending close + const handleThemeMenuEnter = useCallback(() => { + // Cancel any pending close timeout + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + setShowThemeSubmenu(true); + }, []); + + // Handler to close theme submenu with a small delay + // This prevents the submenu from closing when mouse crosses the gap between trigger and submenu + const handleThemeMenuLeave = useCallback(() => { + // Add a small delay before closing to allow mouse to reach submenu + closeTimeoutRef.current = setTimeout(() => { + setShowThemeSubmenu(false); + setPreviewTheme(null); + }, 100); // 100ms delay is enough to cross the gap + }, [setPreviewTheme]); + + // Calculate submenu positioning to avoid viewport overflow + // Detects if submenu would overflow and flips it upward if needed + const submenuPosition = useMemo(() => { + const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800; + // Estimated submenu height: ~620px for all themes + header + padding + const estimatedSubmenuHeight = 620; + // Extra padding from bottom to ensure full visibility + const collisionPadding = 32; + // The "Project Theme" button is approximately 50px down from the top of the context menu + const themeButtonOffset = 50; + + // Calculate where the submenu's bottom edge would be if positioned normally + const submenuBottomY = position.y + themeButtonOffset + estimatedSubmenuHeight; + + // Check if submenu would overflow bottom of viewport + const wouldOverflowBottom = submenuBottomY > viewportHeight - collisionPadding; + + // If it would overflow, calculate how much to shift it up + if (wouldOverflowBottom) { + // Calculate the offset needed to align submenu bottom with viewport bottom minus padding + const overflowAmount = submenuBottomY - (viewportHeight - collisionPadding); + return { + top: -overflowAmount, + maxHeight: Math.min(estimatedSubmenuHeight, viewportHeight - collisionPadding * 2), + }; + } + + // Default: submenu opens at top of parent (aligned with the theme button) + return { + top: 0, + maxHeight: Math.min( + estimatedSubmenuHeight, + viewportHeight - position.y - themeButtonOffset - collisionPadding + ), + }; + }, [position.y]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + }; + }, []); + useEffect(() => { const handleClickOutside = (event: globalThis.MouseEvent) => { // Don't close if a confirmation dialog is open (dialog is in a portal) @@ -242,11 +309,8 @@ export function ProjectContextMenu({ {/* Theme Submenu Trigger */}
setShowThemeSubmenu(true)} - onMouseLeave={() => { - setShowThemeSubmenu(false); - setPreviewTheme(null); - }} + onMouseEnter={handleThemeMenuEnter} + onMouseLeave={handleThemeMenuLeave} >