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 4e33729a..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 @@ -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, @@ -154,30 +225,36 @@ export function ProjectContextMenu({ }, 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 + /** + * 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; - // 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; + const submenuBottomY = position.y + THEME_BUTTON_OFFSET + ESTIMATED_SUBMENU_HEIGHT; // Check if submenu would overflow bottom of viewport - const wouldOverflowBottom = submenuBottomY > viewportHeight - collisionPadding; + 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 - collisionPadding); + const overflowAmount = submenuBottomY - (viewportHeight - COLLISION_PADDING); return { top: -overflowAmount, - maxHeight: Math.min(estimatedSubmenuHeight, viewportHeight - collisionPadding * 2), + maxHeight: Math.min(ESTIMATED_SUBMENU_HEIGHT, viewportHeight - COLLISION_PADDING * 2), }; } @@ -185,8 +262,8 @@ export function ProjectContextMenu({ return { top: 0, maxHeight: Math.min( - estimatedSubmenuHeight, - viewportHeight - position.y - themeButtonOffset - collisionPadding + ESTIMATED_SUBMENU_HEIGHT, + viewportHeight - position.y - THEME_BUTTON_OFFSET - COLLISION_PADDING ), }; }, [position.y]); @@ -378,7 +455,9 @@ export function ProjectContextMenu({ {/* Dynamic max height with scroll for viewport overflow handling */}
boolean)) => void; + /** Callback to show the delete project confirmation dialog */ setShowDeleteProjectDialog: (show: boolean) => void; } +/** + * A project selector component with search, drag-and-drop reordering, and options menu. + * + * Features: + * - Searchable dropdown for quick project switching + * - Drag-and-drop reordering of projects + * - Project-specific theme selection with live preview + * - Project history navigation (previous/next) + * - Option to move project to trash + * + * The component uses viewport-aware positioning via THEME_SUBMENU_CONSTANTS + * for consistent submenu behavior across the application. + * + * @param props - Component props + * @returns The rendered project selector or null if sidebar is closed or no projects exist + */ export function ProjectSelectorWithOptions({ sidebarOpen, isProjectPickerOpen, @@ -246,7 +270,7 @@ export function ProjectSelectorWithOptions({ { // Clear preview theme when leaving the dropdown setPreviewTheme(null); diff --git a/apps/ui/src/components/layout/sidebar/constants.ts b/apps/ui/src/components/layout/sidebar/constants.ts index 0ca50172..58417fe3 100644 --- a/apps/ui/src/components/layout/sidebar/constants.ts +++ b/apps/ui/src/components/layout/sidebar/constants.ts @@ -1,5 +1,36 @@ import { darkThemes, lightThemes } from '@/config/theme-options'; +/** + * Shared constants for theme submenu positioning and layout. + * Used across project-context-menu and project-selector-with-options components + * to ensure consistent viewport-aware positioning and styling. + */ +export const THEME_SUBMENU_CONSTANTS = { + /** + * Estimated total height of the theme submenu content in pixels. + * Includes all theme options, headers, padding, and "Use Global" button. + */ + ESTIMATED_SUBMENU_HEIGHT: 620, + + /** + * Padding from viewport edges to prevent submenu overflow. + * Applied to both top and bottom edges when calculating available space. + */ + COLLISION_PADDING: 32, + + /** + * Vertical offset from context menu top to the "Project Theme" button. + * Used for calculating submenu position relative to trigger button. + */ + THEME_BUTTON_OFFSET: 50, + + /** + * Height reserved for submenu header area (includes "Use Global" button and separator). + * Subtracted from maxHeight to get scrollable content area height. + */ + SUBMENU_HEADER_HEIGHT: 80, +} as const; + export const PROJECT_DARK_THEMES = darkThemes.map((opt) => ({ value: opt.value, label: opt.label,