Merge pull request #587 from AutoMaker-Org/fix/sidebar-project-theme-ui-overlap

fix: enhance project context menu with theme submenu improvements
This commit is contained in:
Web Dev Cody
2026-01-18 15:51:24 -05:00
committed by GitHub
3 changed files with 226 additions and 17 deletions

View File

@@ -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 type { LucideIcon } from 'lucide-react';
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react'; import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -6,35 +6,67 @@ import { cn } from '@/lib/utils';
import { type ThemeMode, useAppStore } from '@/store/app-store'; import { type ThemeMode, useAppStore } from '@/store/app-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import type { Project } from '@/lib/electron'; 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'; 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; 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 = { const Z_INDEX = {
/** Base z-index for the main context menu */
CONTEXT_MENU: 100, CONTEXT_MENU: 100,
/** Higher z-index for theme submenu to appear above parent menu */
THEME_SUBMENU: 101, THEME_SUBMENU: 101,
} as const; } 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 { interface ThemeOption {
/** The theme mode value (e.g., 'dark', 'light', 'dracula') */
value: ThemeMode; value: ThemeMode;
/** Display label for the theme option */
label: string; label: string;
/** Lucide icon component to display alongside the label */
icon: LucideIcon; icon: LucideIcon;
/** CSS color value for the icon */
color: string; 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 { interface ThemeButtonProps {
/** The theme option data to display */
option: ThemeOption; option: ThemeOption;
/** Whether this theme is currently selected */
isSelected: boolean; isSelected: boolean;
/** Handler for pointer enter events (used for preview) */
onPointerEnter: () => void; onPointerEnter: () => void;
/** Handler for pointer leave events (used to clear preview) */
onPointerLeave: (e: React.PointerEvent) => void; onPointerLeave: (e: React.PointerEvent) => void;
/** Handler for click events (used to select theme) */
onClick: () => void; 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({ const ThemeButton = memo(function ThemeButton({
option, option,
isSelected, 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 { interface ThemeColumnProps {
/** Column header title (e.g., "Dark", "Light") */
title: string; title: string;
/** Icon to display in the column header */
icon: LucideIcon; icon: LucideIcon;
/** Array of theme options to display in this column */
themes: ThemeOption[]; themes: ThemeOption[];
/** Currently selected theme value, or null if using global theme */
selectedTheme: ThemeMode | null; selectedTheme: ThemeMode | null;
/** Handler called when user hovers over a theme option for preview */
onPreviewEnter: (value: ThemeMode) => void; onPreviewEnter: (value: ThemeMode) => void;
/** Handler called when user stops hovering over a theme option */
onPreviewLeave: (e: React.PointerEvent) => void; onPreviewLeave: (e: React.PointerEvent) => void;
/** Handler called when user clicks to select a theme */
onSelect: (value: ThemeMode) => void; 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({ const ThemeColumn = memo(function ThemeColumn({
title, title,
icon: Icon, 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 { interface ProjectContextMenuProps {
/** The project this context menu is for */
project: Project; project: Project;
/** Screen coordinates where the context menu should appear */
position: { x: number; y: number }; position: { x: number; y: number };
/** Callback to close the context menu */
onClose: () => void; onClose: () => void;
/** Callback when user selects "Edit Name & Icon" option */
onEdit: (project: Project) => void; 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({ export function ProjectContextMenu({
project, project,
position, position,
@@ -130,9 +201,82 @@ export function ProjectContextMenu({
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false); const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
const [removeConfirmed, setRemoveConfirmed] = useState(false); const [removeConfirmed, setRemoveConfirmed] = useState(false);
const themeSubmenuRef = useRef<HTMLDivElement>(null); const themeSubmenuRef = useRef<HTMLDivElement>(null);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); 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(() => { useEffect(() => {
const handleClickOutside = (event: globalThis.MouseEvent) => { const handleClickOutside = (event: globalThis.MouseEvent) => {
// Don't close if a confirmation dialog is open (dialog is in a portal) // Don't close if a confirmation dialog is open (dialog is in a portal)
@@ -242,11 +386,8 @@ export function ProjectContextMenu({
{/* Theme Submenu Trigger */} {/* Theme Submenu Trigger */}
<div <div
className="relative" className="relative"
onMouseEnter={() => setShowThemeSubmenu(true)} onMouseEnter={handleThemeMenuEnter}
onMouseLeave={() => { onMouseLeave={handleThemeMenuLeave}
setShowThemeSubmenu(false);
setPreviewTheme(null);
}}
> >
<button <button
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)} onClick={() => setShowThemeSubmenu(!showThemeSubmenu)}
@@ -273,13 +414,18 @@ export function ProjectContextMenu({
<div <div
ref={themeSubmenuRef} ref={themeSubmenuRef}
className={cn( className={cn(
'absolute left-full top-0 ml-1 min-w-[420px] rounded-lg', 'absolute left-full ml-1 min-w-[420px] rounded-lg',
'bg-popover text-popover-foreground', 'bg-popover text-popover-foreground',
'border border-border shadow-lg', 'border border-border shadow-lg',
'animate-in fade-in zoom-in-95 duration-100' 'animate-in fade-in zoom-in-95 duration-100'
)} )}
style={{ zIndex: Z_INDEX.THEME_SUBMENU }} style={{
zIndex: Z_INDEX.THEME_SUBMENU,
top: `${submenuPosition.top}px`,
}}
data-testid="project-theme-submenu" data-testid="project-theme-submenu"
onMouseEnter={handleThemeMenuEnter}
onMouseLeave={handleThemeMenuLeave}
> >
<div className="p-2"> <div className="p-2">
{/* Use Global Option */} {/* Use Global Option */}
@@ -306,7 +452,13 @@ export function ProjectContextMenu({
<div className="h-px bg-border my-2" /> <div className="h-px bg-border my-2" />
{/* Two Column Layout - Using reusable ThemeColumn component */} {/* Two Column Layout - Using reusable ThemeColumn component */}
<div className="flex gap-2"> {/* Dynamic max height with scroll for viewport overflow handling */}
<div
className="flex gap-2 overflow-y-auto scrollbar-styled"
style={{
maxHeight: `${submenuPosition.maxHeight - THEME_SUBMENU_CONSTANTS.SUBMENU_HEADER_HEIGHT}px`,
}}
>
<ThemeColumn <ThemeColumn
title="Dark" title="Dark"
icon={Moon} icon={Moon}

View File

@@ -30,17 +30,41 @@ import {
import { DndContext, closestCenter } from '@dnd-kit/core'; import { DndContext, closestCenter } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { SortableProjectItem, ThemeMenuItem } from './'; import { SortableProjectItem, ThemeMenuItem } from './';
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '../constants'; import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES, THEME_SUBMENU_CONSTANTS } from '../constants';
import { useProjectPicker, useDragAndDrop, useProjectTheme } from '../hooks'; import { useProjectPicker, useDragAndDrop, useProjectTheme } from '../hooks';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
/**
* Props for the ProjectSelectorWithOptions component.
* Defines the interface for the project selector dropdown with additional options menu.
*/
interface ProjectSelectorWithOptionsProps { interface ProjectSelectorWithOptionsProps {
/** Whether the sidebar is currently expanded */
sidebarOpen: boolean; sidebarOpen: boolean;
/** Whether the project picker dropdown is currently open */
isProjectPickerOpen: boolean; isProjectPickerOpen: boolean;
/** Callback to control the project picker dropdown open state */
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void; setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
/** Callback to show the delete project confirmation dialog */
setShowDeleteProjectDialog: (show: boolean) => void; 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({ export function ProjectSelectorWithOptions({
sidebarOpen, sidebarOpen,
isProjectPickerOpen, isProjectPickerOpen,
@@ -246,6 +270,7 @@ export function ProjectSelectorWithOptions({
<DropdownMenuSubContent <DropdownMenuSubContent
className="w-[420px] bg-popover/95 backdrop-blur-xl" className="w-[420px] bg-popover/95 backdrop-blur-xl"
data-testid="project-theme-menu" data-testid="project-theme-menu"
collisionPadding={THEME_SUBMENU_CONSTANTS.COLLISION_PADDING}
onPointerLeave={() => { onPointerLeave={() => {
// Clear preview theme when leaving the dropdown // Clear preview theme when leaving the dropdown
setPreviewTheme(null); setPreviewTheme(null);
@@ -286,7 +311,8 @@ export function ProjectSelectorWithOptions({
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{/* Two Column Layout */} {/* Two Column Layout */}
<div className="flex gap-2 p-2"> {/* Max height with scroll to ensure all themes are visible when menu is near screen edge */}
<div className="flex gap-2 p-2 max-h-[60vh] overflow-y-auto scrollbar-styled">
{/* Dark Themes Column */} {/* Dark Themes Column */}
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground"> <div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">

View File

@@ -1,5 +1,36 @@
import { darkThemes, lightThemes } from '@/config/theme-options'; 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) => ({ export const PROJECT_DARK_THEMES = darkThemes.map((opt) => ({
value: opt.value, value: opt.value,
label: opt.label, label: opt.label,