mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
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.
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';
|
||||
@@ -130,9 +130,76 @@ 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]);
|
||||
|
||||
// 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 */}
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setShowThemeSubmenu(true)}
|
||||
onMouseLeave={() => {
|
||||
setShowThemeSubmenu(false);
|
||||
setPreviewTheme(null);
|
||||
}}
|
||||
onMouseEnter={handleThemeMenuEnter}
|
||||
onMouseLeave={handleThemeMenuLeave}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)}
|
||||
@@ -273,13 +337,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 +375,11 @@ 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 - 80}px` }}
|
||||
>
|
||||
<ThemeColumn
|
||||
title="Dark"
|
||||
icon={Moon}
|
||||
|
||||
@@ -246,6 +246,7 @@ export function ProjectSelectorWithOptions({
|
||||
<DropdownMenuSubContent
|
||||
className="w-[420px] bg-popover/95 backdrop-blur-xl"
|
||||
data-testid="project-theme-menu"
|
||||
collisionPadding={32}
|
||||
onPointerLeave={() => {
|
||||
// Clear preview theme when leaving the dropdown
|
||||
setPreviewTheme(null);
|
||||
@@ -286,7 +287,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">
|
||||
|
||||
Reference in New Issue
Block a user