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..2e913e83 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'; @@ -6,35 +6,67 @@ import { cn } from '@/lib/utils'; import { type ThemeMode, useAppStore } from '@/store/app-store'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import type { Project } from '@/lib/electron'; -import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants'; +import { + PROJECT_DARK_THEMES, + PROJECT_LIGHT_THEMES, + THEME_SUBMENU_CONSTANTS, +} from '@/components/layout/sidebar/constants'; import { useThemePreview } from '@/components/layout/sidebar/hooks'; -// Constant for "use global theme" option +/** + * Constant representing the "use global theme" option. + * An empty string is used to indicate that no project-specific theme is set. + */ const USE_GLOBAL_THEME = '' as const; -// Constants for z-index values +/** + * Z-index values for context menu layering. + * Ensures proper stacking order when menus overlap. + */ const Z_INDEX = { + /** Base z-index for the main context menu */ CONTEXT_MENU: 100, + /** Higher z-index for theme submenu to appear above parent menu */ THEME_SUBMENU: 101, } as const; -// Theme option type - using ThemeMode for type safety +/** + * Represents a selectable theme option in the theme submenu. + * Uses ThemeMode from app-store for type safety. + */ interface ThemeOption { + /** The theme mode value (e.g., 'dark', 'light', 'dracula') */ value: ThemeMode; + /** Display label for the theme option */ label: string; + /** Lucide icon component to display alongside the label */ icon: LucideIcon; + /** CSS color value for the icon */ color: string; } -// Reusable theme button component to avoid duplication (DRY principle) +/** + * Props for the ThemeButton component. + * Defines the interface for rendering individual theme selection buttons. + */ interface ThemeButtonProps { + /** The theme option data to display */ option: ThemeOption; + /** Whether this theme is currently selected */ isSelected: boolean; + /** Handler for pointer enter events (used for preview) */ onPointerEnter: () => void; + /** Handler for pointer leave events (used to clear preview) */ onPointerLeave: (e: React.PointerEvent) => void; + /** Handler for click events (used to select theme) */ onClick: () => void; } +/** + * A reusable button component for individual theme options. + * Implements hover preview and selection functionality. + * Memoized to prevent unnecessary re-renders when parent state changes. + */ const ThemeButton = memo(function ThemeButton({ option, isSelected, @@ -63,17 +95,33 @@ const ThemeButton = memo(function ThemeButton({ ); }); -// Reusable theme column component +/** + * Props for the ThemeColumn component. + * Defines the interface for rendering a column of related theme options (e.g., dark or light themes). + */ interface ThemeColumnProps { + /** Column header title (e.g., "Dark", "Light") */ title: string; + /** Icon to display in the column header */ icon: LucideIcon; + /** Array of theme options to display in this column */ themes: ThemeOption[]; + /** Currently selected theme value, or null if using global theme */ selectedTheme: ThemeMode | null; + /** Handler called when user hovers over a theme option for preview */ onPreviewEnter: (value: ThemeMode) => void; + /** Handler called when user stops hovering over a theme option */ onPreviewLeave: (e: React.PointerEvent) => void; + /** Handler called when user clicks to select a theme */ onSelect: (value: ThemeMode) => void; } +/** + * A reusable column component for displaying themed options. + * Renders a group of related themes (e.g., all dark themes or all light themes) + * with a header and scrollable list of ThemeButton components. + * Memoized to prevent unnecessary re-renders. + */ const ThemeColumn = memo(function ThemeColumn({ title, icon: Icon, @@ -105,13 +153,36 @@ const ThemeColumn = memo(function ThemeColumn({ ); }); +/** + * Props for the ProjectContextMenu component. + * Defines the interface for the project right-click context menu. + */ interface ProjectContextMenuProps { + /** The project this context menu is for */ project: Project; + /** Screen coordinates where the context menu should appear */ position: { x: number; y: number }; + /** Callback to close the context menu */ onClose: () => void; + /** Callback when user selects "Edit Name & Icon" option */ onEdit: (project: Project) => void; } +/** + * A context menu component for project-specific actions. + * + * Provides options for: + * - Editing project name and icon + * - Setting project-specific theme (with live preview on hover) + * - Removing project from the workspace + * + * Features viewport-aware positioning for the theme submenu to prevent + * overflow, and implements delayed hover handling to improve UX when + * navigating between the trigger button and submenu. + * + * @param props - Component props + * @returns The rendered context menu or null if not visible + */ export function ProjectContextMenu({ project, position, @@ -130,9 +201,82 @@ 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]); + + /** + * Calculates theme submenu position to prevent viewport overflow. + * + * This memoized calculation determines the optimal vertical position and maximum + * height for the theme submenu based on the current viewport dimensions and + * the trigger button's position. + * + * @returns Object containing: + * - top: Vertical offset from default position (negative values shift submenu up) + * - maxHeight: Maximum height constraint to prevent overflow with scrolling + */ + const submenuPosition = useMemo(() => { + const { ESTIMATED_SUBMENU_HEIGHT, COLLISION_PADDING, THEME_BUTTON_OFFSET } = + THEME_SUBMENU_CONSTANTS; + + const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800; + + // Calculate where the submenu's bottom edge would be if positioned normally + const submenuBottomY = position.y + THEME_BUTTON_OFFSET + ESTIMATED_SUBMENU_HEIGHT; + + // Check if submenu would overflow bottom of viewport + const wouldOverflowBottom = submenuBottomY > viewportHeight - COLLISION_PADDING; + + // 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 - COLLISION_PADDING); + return { + top: -overflowAmount, + maxHeight: Math.min(ESTIMATED_SUBMENU_HEIGHT, viewportHeight - COLLISION_PADDING * 2), + }; + } + + // Default: submenu opens at top of parent (aligned with the theme button) + return { + top: 0, + maxHeight: Math.min( + ESTIMATED_SUBMENU_HEIGHT, + viewportHeight - position.y - THEME_BUTTON_OFFSET - COLLISION_PADDING + ), + }; + }, [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 +386,8 @@ export function ProjectContextMenu({ {/* Theme Submenu Trigger */}
setShowThemeSubmenu(true)} - onMouseLeave={() => { - setShowThemeSubmenu(false); - setPreviewTheme(null); - }} + onMouseEnter={handleThemeMenuEnter} + onMouseLeave={handleThemeMenuLeave} >