feat(ui): add Dropdown open close microinteraction

- Add visible Dropdown motion timing and easing

- Align submenu motion with dropdown content

- Cover reduced-motion behavior with unit tests
This commit is contained in:
Alan Buscaglia committed 2026-06-05 13:46:53 +02:00
1 parent b93e3f9d04
commit 51945f5cc5
2 files changed
+116 -2

No files matched your search

@@ -0,0 +1,114 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./dropdown";
function renderActionsDropdown() {
return render(
<DropdownMenu open>
<DropdownMenuTrigger>Open actions</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
);
}
describe("DropdownMenu", () => {
it("renders open menu content through the Radix DropdownMenu API", () => {
// Given
renderActionsDropdown();
// When
const menu = screen.getByRole("menu");
// Then
expect(menu).toBeVisible();
expect(menu).toHaveAttribute("data-slot", "dropdown-menu-content");
expect(screen.getByRole("menuitem", { name: "Edit" })).toBeVisible();
expect(screen.getByRole("menuitem", { name: "Delete" })).toBeVisible();
});
it("uses an intentional open and close motion contract", () => {
// Given
renderActionsDropdown();
// When
const menu = screen.getByRole("menu");
// Then
expect(menu).toHaveClass(
"origin-(--radix-dropdown-menu-content-transform-origin)",
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:zoom-out-95",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
);
});
it("removes transform-heavy menu motion for reduced-motion users", () => {
// Given
renderActionsDropdown();
// When
const menu = screen.getByRole("menu");
// Then
expect(menu).toHaveClass(
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
it("applies the same motion contract to submenu content", () => {
// Given
render(
<DropdownMenu open>
<DropdownMenuTrigger>Open actions</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuSub open>
<DropdownMenuSubTrigger>More actions</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Archive</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>,
);
// When
const submenuContent = screen
.getByRole("menuitem", { name: "Archive" })
.closest("[data-slot='dropdown-menu-sub-content']");
// Then
expect(submenuContent).toHaveClass(
"origin-(--radix-dropdown-menu-content-transform-origin)",
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=closed]:animate-out",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
+2 -2
View File
@@ -42,7 +42,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out 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-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out 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-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
{...props}
@@ -227,7 +227,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out 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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out 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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
{...props}