diff --git a/src/web-ui/src/features/market-account/MarketAccountControls.scss b/src/web-ui/src/features/market-account/MarketAccountControls.scss index 78999f2a2..51b55632b 100644 --- a/src/web-ui/src/features/market-account/MarketAccountControls.scss +++ b/src/web-ui/src/features/market-account/MarketAccountControls.scss @@ -48,11 +48,13 @@ } &__menu { - position: absolute; - z-index: 40; - top: calc(100% + #{$size-gap-2}); - right: 0; - width: 230px; + position: fixed; + z-index: $z-popover; + width: max-content; + min-width: min(190px, calc(100vw - 16px)); + max-width: min(230px, calc(100vw - 16px)); + max-height: calc(100vh - 16px); + overflow-y: auto; padding: $size-gap-2; border: 1px solid var(--bf-appearance-token-border-base); border-radius: $size-radius-base; diff --git a/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx b/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx index 58e117c58..da39d8390 100644 --- a/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx +++ b/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx @@ -4,6 +4,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MarketAccountControls } from './MarketAccountControls'; +import { calculateMarketAccountMenuPosition } from './marketAccountMenuPosition'; const mocks = vi.hoisted(() => ({ account: { @@ -77,6 +78,7 @@ describe('MarketAccountControls', () => { afterEach(() => { act(() => root.unmount()); container.remove(); + document.querySelector('[data-bf-overlay-host="true"]')?.remove(); }); it('opens the shared GitHub login dialog and starts the vault-backed flow', async () => { @@ -102,9 +104,28 @@ describe('MarketAccountControls', () => { const trigger = container.querySelector('[aria-haspopup="menu"]'); await act(async () => trigger?.click()); - const logout = container.querySelector('[role="menuitem"]'); + const menu = document.querySelector('[role="menu"]'); + expect(menu?.parentElement?.getAttribute('data-bf-overlay-host')).toBe('true'); + const logout = menu?.querySelector('[role="menuitem"]'); expect(logout?.textContent).toContain('market.signOut'); await act(async () => logout?.click()); expect(mocks.logout).toHaveBeenCalledOnce(); }); + + it('keeps the portalled menu aligned to the trigger and inside the viewport', () => { + const position = calculateMarketAccountMenuPosition( + { top: 16, right: 218, bottom: 46 }, + { width: 230, height: 112 }, + { width: 240, height: 180 }, + ); + + expect(position).toEqual({ top: 52, left: 8 }); + expect( + calculateMarketAccountMenuPosition( + { top: 150, right: 218, bottom: 180 }, + { width: 230, height: 112 }, + { width: 240, height: 200 }, + ), + ).toEqual({ top: 32, left: 8 }); + }); }); diff --git a/src/web-ui/src/features/market-account/MarketAccountControls.tsx b/src/web-ui/src/features/market-account/MarketAccountControls.tsx index f27b674f9..8c762ab1f 100644 --- a/src/web-ui/src/features/market-account/MarketAccountControls.tsx +++ b/src/web-ui/src/features/market-account/MarketAccountControls.tsx @@ -1,6 +1,8 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { ChevronDown, Github, Loader2, LogOut } from 'lucide-react'; import { Avatar, Button, Modal } from '@/component-library'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useI18n } from '@/infrastructure/i18n'; import { MarketAccountError, @@ -8,6 +10,10 @@ import { useMarketAccount, } from '@/infrastructure/market-account'; import { useNotification } from '@/shared/notification-system'; +import { + calculateMarketAccountMenuPosition, + type MarketAccountMenuPosition, +} from './marketAccountMenuPosition'; import './MarketAccountControls.scss'; export interface MarketAccountControlsProps { @@ -28,8 +34,11 @@ export function MarketAccountControls({ const account = useMarketAccount(); const [internalLoginOpen, setInternalLoginOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState(null); const menuRef = useRef(null); + const menuPanelRef = useRef(null); const menuTriggerRef = useRef(null); + const menuPositionFrameRef = useRef(null); const startedHere = useRef(false); const loginOpen = controlledLoginOpen ?? internalLoginOpen; @@ -38,10 +47,53 @@ export function MarketAccountControls({ onLoginOpenChange?.(open); }; + const updateMenuPosition = useCallback(() => { + if (!menuTriggerRef.current || !menuPanelRef.current) return; + setMenuPosition( + calculateMarketAccountMenuPosition( + menuTriggerRef.current.getBoundingClientRect(), + menuPanelRef.current.getBoundingClientRect(), + { width: window.innerWidth, height: window.innerHeight }, + ), + ); + }, []); + + const scheduleMenuPositionUpdate = useCallback(() => { + if (menuPositionFrameRef.current !== null) return; + menuPositionFrameRef.current = requestAnimationFrame(() => { + menuPositionFrameRef.current = null; + updateMenuPosition(); + }); + }, [updateMenuPosition]); + + useLayoutEffect(() => { + if (!menuOpen) { + return; + } + + updateMenuPosition(); + window.addEventListener('resize', scheduleMenuPositionUpdate, { passive: true }); + window.addEventListener('scroll', scheduleMenuPositionUpdate, { + capture: true, + passive: true, + }); + return () => { + window.removeEventListener('resize', scheduleMenuPositionUpdate); + window.removeEventListener('scroll', scheduleMenuPositionUpdate, { capture: true }); + if (menuPositionFrameRef.current !== null) { + cancelAnimationFrame(menuPositionFrameRef.current); + menuPositionFrameRef.current = null; + } + }; + }, [menuOpen, scheduleMenuPositionUpdate, updateMenuPosition]); + useEffect(() => { if (!menuOpen) return; const closeOnOutsideClick = (event: PointerEvent) => { - if (!menuRef.current?.contains(event.target as Node)) setMenuOpen(false); + const target = event.target as Node; + if (!menuRef.current?.contains(target) && !menuPanelRef.current?.contains(target)) { + setMenuOpen(false); + } }; const closeOnEscape = (event: KeyboardEvent) => { if (event.key !== 'Escape') return; @@ -126,12 +178,18 @@ export function MarketAccountControls({ @{account.me.user.login}