Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>Open user menu</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="custom-menu" data-testid="user-menu" id="user-menu" side="top">
<DropdownMenuItem>Preferences</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

describe('DropdownMenu', () => {
it('keeps Base UI popup behavior on the visible menu content element', async () => {
const user = userEvent.setup();

render(<TestDropdownMenu />);

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();
});
});
112 changes: 44 additions & 68 deletions frontend/src/components/redpanda-ui/components/dropdown-menu.tsx

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a big change, maybe there is an easier way to get this resolved, basically when you open a user menu dropdown in the sidebar of console, it will sometimes not allow you to interact with the component because it keeps closing as soon as it loses focus

Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -179,19 +179,19 @@ function DropdownMenuSubContent({ className, ...props }: DropdownMenuSubContentP
);
}

type DropdownMenuContentProps = React.ComponentProps<typeof DropdownMenuPrimitive.Popup> &
HTMLMotionProps<'div'> &
Pick<PortalContentProps, 'container' | 'onOpenAutoFocus'> & {
transition?: Transition;
type DropdownMenuContentProps = React.HTMLAttributes<HTMLDivElement> &
Pick<React.ComponentProps<typeof DropdownMenuPrimitive.Popup>, 'finalFocus'> &
Pick<PortalContentProps, 'container'> & {
sideOffset?: number;
align?: 'start' | 'center' | 'end';
alignOffset?: number;
side?: 'top' | 'right' | 'bottom' | 'left';
/**
* Keep the portal subtree mounted across close cycles. Set this when a
* descendant `<Dialog>` / `<AlertDialog>` 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;
Expand All @@ -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();
Expand All @@ -221,58 +221,38 @@ function DropdownMenuContent({
side={side}
sideOffset={sideOffset}
>
<DropdownMenuPrimitive.Popup data-slot="dropdown-menu-popup" render={renderWithDataState('div')}>
<motion.div
animate={
keepMounted
? undefined
: {
opacity: 1,
scale: 1,
}
}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--available-height) min-w-[8rem] origin-(--transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
data-slot="dropdown-menu-content"
exit={
keepMounted
? undefined
: {
opacity: 0,
scale: 0.95,
}
}
initial={
keepMounted
? undefined
: {
opacity: 0,
scale: 0.95,
}
}
key="dropdown-menu-content"
style={{ willChange: 'opacity, transform' }}
transition={keepMounted ? undefined : transition}
{...props}
>
<MotionHighlight
className="rounded-sm"
controlledItems
enabled={animateOnHover}
hover
transition={highlightTransition}
<DropdownMenuPrimitive.Popup
finalFocus={finalFocus}
render={(popupProps, state) => (
<div
{...popupProps}
{...props}
className={cn(
popupProps.className,
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--available-height) min-w-[8rem] origin-(--transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
data-slot="dropdown-menu-content"
data-state={state.open ? 'open' : 'closed'}
style={{ ...popupProps.style, ...style, willChange: 'opacity, transform' }}
>
{children}
</MotionHighlight>
</motion.div>
</DropdownMenuPrimitive.Popup>
<MotionHighlight
className="rounded-sm"
controlledItems
enabled={animateOnHover}
hover
transition={highlightTransition}
>
{children}
</MotionHighlight>
</div>
)}
/>
</DropdownMenuPrimitive.Positioner>
);

// No `<AnimatePresence>` here on purpose — wrapping it would reintroduce the
// unmount-on-close that `keepMounted` exists to avoid.
// No `<AnimatePresence>` 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 (
<DropdownMenuPrimitive.Portal
Expand All @@ -285,18 +265,14 @@ function DropdownMenuContent({
);
}

if (!isOpen) {
return null;
}

return (
<AnimatePresence>
{isOpen ? (
<DropdownMenuPrimitive.Portal
container={container ?? portalContainer}
data-slot="dropdown-menu-portal"
keepMounted
>
{popup}
</DropdownMenuPrimitive.Portal>
) : null}
</AnimatePresence>
<DropdownMenuPrimitive.Portal container={container ?? portalContainer} data-slot="dropdown-menu-portal">
{popup}
</DropdownMenuPrimitive.Portal>
);
}

Expand Down
Loading