diff --git a/frontend/src/components/redpanda-ui/components/dropdown-menu.test.tsx b/frontend/src/components/redpanda-ui/components/dropdown-menu.test.tsx new file mode 100644 index 0000000000..1344e99b0b --- /dev/null +++ b/frontend/src/components/redpanda-ui/components/dropdown-menu.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { Button } from './button'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './dropdown-menu'; + +function TestDropdownMenu() { + return ( + + + + + + Preferences + + + ); +} + +describe('DropdownMenu', () => { + it('keeps Base UI popup behavior on the visible menu content element', async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: 'Open user menu' })); + + const menu = await screen.findByRole('menu'); + expect(menu).toHaveAttribute('data-slot', 'dropdown-menu-content'); + expect(menu).toHaveAttribute('data-state', 'open'); + expect(menu).toHaveAttribute('data-testid', 'user-menu'); + expect(menu).toHaveAttribute('id', 'user-menu'); + expect(menu).toHaveClass('custom-menu'); + const item = screen.getByRole('menuitem', { name: 'Preferences' }); + expect(item).toBeInTheDocument(); + + await user.hover(item); + expect(screen.getByRole('menu')).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/redpanda-ui/components/dropdown-menu.tsx b/frontend/src/components/redpanda-ui/components/dropdown-menu.tsx index f407738934..ab553257ca 100644 --- a/frontend/src/components/redpanda-ui/components/dropdown-menu.tsx +++ b/frontend/src/components/redpanda-ui/components/dropdown-menu.tsx @@ -2,7 +2,7 @@ import { Menu as DropdownMenuPrimitive } from '@base-ui/react/menu'; import { Check, ChevronRight, Circle } from 'lucide-react'; -import { AnimatePresence, type HTMLMotionProps, motion, type Transition } from 'motion/react'; +import { motion, type Transition } from 'motion/react'; import React from 'react'; import { MotionHighlight, MotionHighlightItem } from './motion-highlight'; @@ -179,10 +179,9 @@ function DropdownMenuSubContent({ className, ...props }: DropdownMenuSubContentP ); } -type DropdownMenuContentProps = React.ComponentProps & - HTMLMotionProps<'div'> & - Pick & { - transition?: Transition; +type DropdownMenuContentProps = React.HTMLAttributes & + Pick, 'finalFocus'> & + Pick & { sideOffset?: number; align?: 'start' | 'center' | 'end'; alignOffset?: number; @@ -190,8 +189,9 @@ type DropdownMenuContentProps = React.ComponentProps` / `` needs to outlive menu close; - * trades the close animation for subtree survival. Avoid on long lists — - * prefer the sibling-dialog pattern there. See the + * closed-state CSS animation classes only get a chance to run in this + * mode because the default path unmounts immediately on close. Avoid on + * long lists — prefer the sibling-dialog pattern there. See the * `dropdown-menu-nested-dialog` demo. @default false */ keepMounted?: boolean; @@ -204,10 +204,10 @@ function DropdownMenuContent({ align, alignOffset, side, - transition = { duration: 0.2 }, container, - onOpenAutoFocus: _onOpenAutoFocus, + finalFocus, keepMounted = false, + style, ...props }: DropdownMenuContentProps) { const { isOpen, highlightTransition, animateOnHover } = useDropdownMenu(); @@ -221,58 +221,38 @@ function DropdownMenuContent({ side={side} sideOffset={sideOffset} > - - - ( +
- {children} - - - + + {children} + +
+ )} + /> ); - // No `` here on purpose — wrapping it would reintroduce the - // unmount-on-close that `keepMounted` exists to avoid. + // No `` here on purpose: the rendered popup must remain the + // Base UI popup element so focus and pointer interactions stay pinned to it. if (keepMounted) { return ( - {isOpen ? ( - - {popup} - - ) : null} - + + {popup} + ); }