diff --git a/.changeset/evil-webs-create.md b/.changeset/evil-webs-create.md new file mode 100644 index 0000000000..0ed36a703a --- /dev/null +++ b/.changeset/evil-webs-create.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Fix scroll jumping when opening menu in Backstage UI. diff --git a/packages/ui/src/components/Menu/Menu.stories.tsx b/packages/ui/src/components/Menu/Menu.stories.tsx index a9a45402d1..7171b3ef83 100644 --- a/packages/ui/src/components/Menu/Menu.stories.tsx +++ b/packages/ui/src/components/Menu/Menu.stories.tsx @@ -412,3 +412,38 @@ export const VirtualizedMaxHeight: Story = { ); }, }; + +export const WithScroll: Story = { + args: { + children: null, + }, + decorators: [ + Story => ( +
+ +
+ ), + ], + render: () => ( + + + + Edit + Duplicate + Rename + + }>Share + }>Feedback + + + }>Settings + + Edit + Duplicate + Rename + + + + + ), +}; diff --git a/packages/ui/src/components/Menu/Menu.tsx b/packages/ui/src/components/Menu/Menu.tsx index 574a26393c..696fb7997e 100644 --- a/packages/ui/src/components/Menu/Menu.tsx +++ b/packages/ui/src/components/Menu/Menu.tsx @@ -33,6 +33,7 @@ import { RouterProvider, Virtualizer, ListLayout, + OverlayTriggerStateContext, } from 'react-aria-components'; import { useStyles } from '../../hooks/useStyles'; import type { @@ -54,6 +55,7 @@ import { } from '@remixicon/react'; import { useNavigate, useHref } from 'react-router-dom'; import { isExternalLink } from '../../utils/isExternalLink'; +import { useRef, useEffect, useContext } from 'react'; // The height will be used for virtualized menus. It should match the size set in CSS for each menu item. const rowHeight = 32; @@ -86,6 +88,33 @@ export const Menu = (props: MenuProps) => { const { classNames } = useStyles('Menu'); const navigate = useNavigate(); let newMaxWidth = maxWidth || (virtualized ? '260px' : 'undefined'); + const popoverRef = useRef(null); + const state = useContext(OverlayTriggerStateContext); + + // Custom click-outside handler for non-modal popovers + useEffect(() => { + if (!state?.isOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + + // Check if click is outside the popover + if (popoverRef.current && !popoverRef.current.contains(target)) { + // Check if click is on a trigger button or submenu + const isOnTrigger = (target as Element).closest('[data-trigger]'); + const isOnSubmenu = (target as Element).closest('[role="menu"]'); + + if (!isOnTrigger && !isOnSubmenu) { + state.close(); + } + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [state]); const menuContent = ( ) => { ); return ( - + {virtualized ? (