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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user