mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
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:
@@ -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<HTMLDivElement>(null);
|
||||
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 */}
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setShowThemeSubmenu(true)}
|
||||
onMouseLeave={() => {
|
||||
setShowThemeSubmenu(false);
|
||||
setPreviewTheme(null);
|
||||
}}
|
||||
onMouseEnter={handleThemeMenuEnter}
|
||||
onMouseLeave={handleThemeMenuLeave}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)}
|
||||
@@ -273,13 +414,18 @@ export function ProjectContextMenu({
|
||||
<div
|
||||
ref={themeSubmenuRef}
|
||||
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',
|
||||
'border border-border shadow-lg',
|
||||
'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"
|
||||
onMouseEnter={handleThemeMenuEnter}
|
||||
onMouseLeave={handleThemeMenuLeave}
|
||||
>
|
||||
<div className="p-2">
|
||||
{/* Use Global Option */}
|
||||
@@ -306,7 +452,13 @@ export function ProjectContextMenu({
|
||||
<div className="h-px bg-border my-2" />
|
||||
|
||||
{/* 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
|
||||
title="Dark"
|
||||
icon={Moon}
|
||||
|
||||
@@ -30,17 +30,41 @@ import {
|
||||
import { DndContext, closestCenter } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
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 { 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 {
|
||||
/** Whether the sidebar is currently expanded */
|
||||
sidebarOpen: boolean;
|
||||
/** Whether the project picker dropdown is currently open */
|
||||
isProjectPickerOpen: boolean;
|
||||
/** Callback to control the project picker dropdown open state */
|
||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => 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,6 +270,7 @@ export function ProjectSelectorWithOptions({
|
||||
<DropdownMenuSubContent
|
||||
className="w-[420px] bg-popover/95 backdrop-blur-xl"
|
||||
data-testid="project-theme-menu"
|
||||
collisionPadding={THEME_SUBMENU_CONSTANTS.COLLISION_PADDING}
|
||||
onPointerLeave={() => {
|
||||
// Clear preview theme when leaving the dropdown
|
||||
setPreviewTheme(null);
|
||||
@@ -286,7 +311,8 @@ export function ProjectSelectorWithOptions({
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{/* 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 */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user