fix: position context menu and customize popover at right-click coordinates

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 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-02 20:16:28 +01:00
parent ef675dab7c
commit f4d3640f49
2 changed files with 91 additions and 6 deletions

View File

@@ -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(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} 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(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} 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(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} 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(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} 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(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} 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(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} 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,

View File

@@ -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<HTMLDivElement>
@@ -230,16 +243,21 @@ function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize }: {
)
}
function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChangeTemplate, onClose }: {
target: string | null; typeEntryMap: Record<string, VaultEntry>
function CustomizeOverlay({ target, pos, typeEntryMap, innerRef, onCustomize, onChangeTemplate, onClose }: {
target: string | null
pos: { x: number; y: number } | null
typeEntryMap: Record<string, VaultEntry>
innerRef: React.Ref<HTMLDivElement>
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 (
<div ref={innerRef} className="fixed z-50" style={{ left: 20, top: 100 }}>
<div ref={innerRef} className="fixed z-50" style={{ left, top }}>
<TypeCustomizePopover
currentIcon={typeEntryMap[target]?.icon ?? null}
currentColor={typeEntryMap[target]?.color ?? null}
@@ -261,6 +279,7 @@ export const Sidebar = memo(function Sidebar({
}: SidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [customizeTarget, setCustomizeTarget] = useState<string | null>(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<string | null>(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({
</nav>
<CommitButton modifiedCount={modifiedCount} onClick={onCommitPush} />
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} />
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onChangeTemplate={handleChangeTemplate} onClose={closeCustomizeTarget} />
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { setCustomizePos(contextMenuPos); closeContextMenu(); setCustomizeTarget(type) }} />
<CustomizeOverlay target={customizeTarget} pos={customizePos} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onChangeTemplate={handleChangeTemplate} onClose={closeCustomizeTarget} />
</aside>
)
})