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:
Kacper
2026-01-18 21:36:23 +01:00
parent 518c81815e
commit 30a2c3d740
2 changed files with 85 additions and 10 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 { 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}

View File

@@ -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">