From b24ba8a4c656f952d8e153b70ebc7014d8132a22 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 2 Mar 2026 20:16:28 +0100 Subject: [PATCH] fix: position context menu and customize popover at right-click coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CustomizeIconColor popover was hardcoded to (20, 100) — always wrong. Bottom sections also failed because the context menu could extend below the viewport, making "Customize icon & color" unreachable. - Capture context menu click position and use it to position the customize popover (via new `customizePos` state in Sidebar) - Clamp context menu position to flip above the click point when near the bottom of the viewport (CONTEXT_MENU_HEIGHT guard) - Clamp customize popover to stay within viewport bounds via clampToViewport() - Add 6 tests covering context menu flow, positioning, and color callback Co-Authored-By: Claude Sonnet 4.6 --- src/components/Sidebar.test.tsx | 65 +++++++++++++++++++++++++++++++++ src/components/Sidebar.tsx | 32 +++++++++++++--- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index 821b1ce2..234a480d 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -766,6 +766,71 @@ describe('Sidebar', () => { }) }) + describe('context menu — customize icon & color', () => { + const onCustomizeType = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 }) + Object.defineProperty(window, 'innerHeight', { writable: true, configurable: true, value: 800 }) + }) + + it('shows context menu when right-clicking a section header', () => { + render( {}} onCustomizeType={onCustomizeType} />) + fireEvent.contextMenu(screen.getByText('Projects'), { clientX: 100, clientY: 200 }) + expect(screen.getByText('Customize icon & color…')).toBeInTheDocument() + }) + + it('closes context menu and opens customize popover when clicking menu item', () => { + render( {}} onCustomizeType={onCustomizeType} />) + fireEvent.contextMenu(screen.getByText('Projects'), { clientX: 100, clientY: 200 }) + fireEvent.click(screen.getByText('Customize icon & color…')) + expect(screen.queryByText('Customize icon & color…')).not.toBeInTheDocument() + expect(screen.getByText('Done')).toBeInTheDocument() + }) + + it('calls onCustomizeType when a color is selected in the popover', () => { + render( {}} onCustomizeType={onCustomizeType} />) + fireEvent.contextMenu(screen.getByText('Projects'), { clientX: 100, clientY: 200 }) + fireEvent.click(screen.getByText('Customize icon & color…')) + const colorButtons = screen.getAllByTitle(/red|blue|green|purple|yellow|orange|teal|pink/i) + fireEvent.click(colorButtons[0]) + expect(onCustomizeType).toHaveBeenCalled() + }) + + it('positions context menu above click point when near bottom of viewport', () => { + render( {}} onCustomizeType={onCustomizeType} />) + // Click near the bottom — y=780, menu height=48, so 780+48>800 → flip above + fireEvent.contextMenu(screen.getByText('Projects'), { clientX: 100, clientY: 780 }) + const menu = screen.getByText('Customize icon & color…').parentElement! + const menuTop = parseInt(menu.style.top, 10) + // Should be flipped up to 780-48=732 + expect(menuTop).toBe(732) + }) + + it('positions context menu at click point when not near bottom', () => { + render( {}} onCustomizeType={onCustomizeType} />) + fireEvent.contextMenu(screen.getByText('Projects'), { clientX: 100, clientY: 200 }) + const menu = screen.getByText('Customize icon & color…').parentElement! + expect(parseInt(menu.style.top, 10)).toBe(200) + }) + + it('customize popover appears at clamped position near the right-click point', () => { + render( {}} onCustomizeType={onCustomizeType} />) + fireEvent.contextMenu(screen.getByText('Projects'), { clientX: 100, clientY: 200 }) + fireEvent.click(screen.getByText('Customize icon & color…')) + // Find the popover wrapper div (parent of TypeCustomizePopover content) + const doneButton = screen.getByText('Done') + // Walk up to find the fixed positioned div + let el: HTMLElement | null = doneButton + while (el && !el.style.left) el = el.parentElement + expect(el).not.toBeNull() + // Position should be near (100, 200), clamped to viewport + expect(parseInt(el!.style.left, 10)).toBeGreaterThanOrEqual(8) + expect(parseInt(el!.style.top, 10)).toBeGreaterThanOrEqual(8) + }) + }) + describe('section ordering by type order property', () => { const entriesWithOrder: VaultEntry[] = [ ...mockEntries, diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 7294386b..af83d9e4 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -215,6 +215,19 @@ function SidebarTitleBar({ onCollapse }: { onCollapse?: () => void }) { ) } +const CONTEXT_MENU_HEIGHT = 48 +const CUSTOMIZE_POPOVER_WIDTH = 320 +const CUSTOMIZE_POPOVER_HEIGHT = 480 + +const CLAMP_MARGIN = 8 + +function clampToViewport(x: number, y: number, width: number, height: number): { left: number; top: number } { + return { + left: Math.min(Math.max(x, CLAMP_MARGIN), window.innerWidth - width - CLAMP_MARGIN), + top: Math.min(Math.max(y, CLAMP_MARGIN), window.innerHeight - height - CLAMP_MARGIN), + } +} + function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize }: { pos: { x: number; y: number } | null; type: string | null innerRef: React.Ref @@ -230,16 +243,21 @@ function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize }: { ) } -function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChangeTemplate, onClose }: { - target: string | null; typeEntryMap: Record +function CustomizeOverlay({ target, pos, typeEntryMap, innerRef, onCustomize, onChangeTemplate, onClose }: { + target: string | null + pos: { x: number; y: number } | null + typeEntryMap: Record innerRef: React.Ref onCustomize: (prop: 'icon' | 'color', value: string) => void onChangeTemplate: (template: string) => void onClose: () => void }) { if (!target) return null + const { left, top } = pos + ? clampToViewport(pos.x, pos.y, CUSTOMIZE_POPOVER_WIDTH, CUSTOMIZE_POPOVER_HEIGHT) + : { left: 20, top: 100 } return ( -
+
>({}) const [customizeTarget, setCustomizeTarget] = useState(null) + const [customizePos, setCustomizePos] = useState<{ x: number; y: number } | null>(null) const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null) const [contextMenuType, setContextMenuType] = useState(null) const [showCustomize, setShowCustomize] = useState(false) @@ -299,7 +318,8 @@ export const Sidebar = memo(function Sidebar({ const handleContextMenu = useCallback((e: React.MouseEvent, type: string) => { e.preventDefault(); e.stopPropagation() - setContextMenuPos({ x: e.clientX, y: e.clientY }); setContextMenuType(type) + const y = e.clientY + CONTEXT_MENU_HEIGHT > window.innerHeight ? e.clientY - CONTEXT_MENU_HEIGHT : e.clientY + setContextMenuPos({ x: e.clientX, y }); setContextMenuType(type) }, []) const handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => { @@ -353,8 +373,8 @@ export const Sidebar = memo(function Sidebar({ - { closeContextMenu(); setCustomizeTarget(type) }} /> - + { setCustomizePos(contextMenuPos); closeContextMenu(); setCustomizeTarget(type) }} /> + ) })