From dcefbab827b0c172e5371d475de63ddeffb5d3dc Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Thu, 11 Jun 2026 13:49:32 +0200 Subject: [PATCH] fix(ui): animate component internal states - Add motion feedback for dropdown checkbox and radio indicators - Animate select and combobox selected indicators - Add dialog close affordance motion --- .../shadcn/combobox/combobox.test.tsx | 33 ++++++ ui/components/shadcn/combobox/combobox.tsx | 14 ++- ui/components/shadcn/command.tsx | 2 +- ui/components/shadcn/dialog.test.tsx | 27 +++++ ui/components/shadcn/dialog.tsx | 4 +- .../shadcn/dropdown/dropdown.test.tsx | 105 ++++++++++++++++++ ui/components/shadcn/dropdown/dropdown.tsx | 16 +-- ui/components/shadcn/select/select.test.tsx | 25 +++++ ui/components/shadcn/select/select.tsx | 4 +- 9 files changed, 211 insertions(+), 19 deletions(-) diff --git a/ui/components/shadcn/combobox/combobox.test.tsx b/ui/components/shadcn/combobox/combobox.test.tsx index f5ea9e492d..928472f0eb 100644 --- a/ui/components/shadcn/combobox/combobox.test.tsx +++ b/ui/components/shadcn/combobox/combobox.test.tsx @@ -68,6 +68,39 @@ describe("Combobox", () => { ); }); + it("animates option rows and selected check indicators as internal feedback", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + + // When + await user.click(screen.getByRole("combobox", { name: /aws/i })); + const selectedItem = screen.getByRole("option", { name: /aws/i }); + const unselectedItem = screen.getByRole("option", { name: /azure/i }); + const selectedCheck = selectedItem.querySelector("svg"); + const unselectedCheck = unselectedItem.querySelector("svg"); + + // Then + expect(selectedItem).toHaveClass( + "transition-[background-color,color]", + "duration-150", + "ease-out", + "motion-reduce:transition-none", + ); + expect(selectedCheck).toHaveClass( + "transition-[opacity,scale]", + "duration-150", + "ease-out", + "scale-100", + "opacity-100", + "motion-reduce:scale-100", + "motion-reduce:transition-none", + ); + expect(unselectedCheck).toHaveClass("scale-95", "opacity-0"); + }); + it("opens with the shared Popover content motion contract", async () => { // Given const user = userEvent.setup(); diff --git a/ui/components/shadcn/combobox/combobox.tsx b/ui/components/shadcn/combobox/combobox.tsx index 8279a4147b..90493f62aa 100644 --- a/ui/components/shadcn/combobox/combobox.tsx +++ b/ui/components/shadcn/combobox/combobox.tsx @@ -152,7 +152,7 @@ export function Combobox({ value={selectedOption.value} onSelect={handleSelect} > - + {selectedOption.label} @@ -177,10 +177,10 @@ export function Combobox({ > {option.label} @@ -205,8 +205,10 @@ export function Combobox({ > {option.label} diff --git a/ui/components/shadcn/command.tsx b/ui/components/shadcn/command.tsx index b7d9067f96..7afbc4eef1 100644 --- a/ui/components/shadcn/command.tsx +++ b/ui/components/shadcn/command.tsx @@ -152,7 +152,7 @@ function CommandItem({ { ); }); + it("animates the close affordance as internal dialog feedback", () => { + // Given + renderOpenDialog(); + + // When + const closeButton = screen.getByRole("button", { name: /close/i }); + const closeIcon = closeButton.querySelector("svg"); + + // Then + expect(closeButton).toHaveClass( + "transition-[opacity,scale,background-color,color]", + "duration-150", + "ease-out", + "active:scale-95", + "motion-reduce:active:scale-100", + "motion-reduce:transition-none", + ); + expect(closeIcon).toHaveClass( + "transition-transform", + "duration-150", + "ease-out", + "group-hover/dialog-close:rotate-90", + "motion-reduce:rotate-0", + "motion-reduce:transition-none", + ); + }); + it("uses an intentional content motion contract", () => { // Given renderOpenDialog(); diff --git a/ui/components/shadcn/dialog.tsx b/ui/components/shadcn/dialog.tsx index ee275f5c5f..24379960b1 100644 --- a/ui/components/shadcn/dialog.tsx +++ b/ui/components/shadcn/dialog.tsx @@ -70,9 +70,9 @@ function DialogContent({ {showCloseButton && ( - + Close )} diff --git a/ui/components/shadcn/dropdown/dropdown.test.tsx b/ui/components/shadcn/dropdown/dropdown.test.tsx index 72f8f13410..484e48384e 100644 --- a/ui/components/shadcn/dropdown/dropdown.test.tsx +++ b/ui/components/shadcn/dropdown/dropdown.test.tsx @@ -3,8 +3,11 @@ import { describe, expect, it } from "vitest"; import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, @@ -76,6 +79,108 @@ describe("DropdownMenu", () => { ); }); + it("animates checkbox and radio item indicators as internal state feedback", () => { + // Given + render( + + Open filters + + + Show muted + + + Failed + + + , + ); + + // When + const checkboxItem = screen.getByRole("menuitemcheckbox", { + name: "Show muted", + }); + const radioItem = screen.getByRole("menuitemradio", { name: "Failed" }); + const checkboxIndicator = checkboxItem.querySelector("span"); + const checkboxIcon = checkboxItem.querySelector("svg"); + const radioIndicator = radioItem.querySelector("span"); + const radioIcon = radioItem.querySelector("svg"); + + // Then + expect(checkboxItem).toHaveClass( + "transition-colors", + "duration-150", + "ease-out", + "motion-reduce:transition-none", + ); + expect(checkboxIndicator).toHaveClass( + "transition-[opacity,scale]", + "duration-150", + "ease-out", + "motion-reduce:scale-100", + "motion-reduce:transition-none", + ); + expect(checkboxIcon).toHaveClass( + "animate-in", + "zoom-in-75", + "motion-reduce:animate-none", + ); + expect(radioItem).toHaveClass( + "transition-colors", + "duration-150", + "ease-out", + "motion-reduce:transition-none", + ); + expect(radioIndicator).toHaveClass( + "transition-[opacity,scale]", + "duration-150", + "ease-out", + "motion-reduce:scale-100", + "motion-reduce:transition-none", + ); + expect(radioIcon).toHaveClass( + "animate-in", + "zoom-in-75", + "motion-reduce:animate-none", + ); + }); + + it("animates submenu chevrons as internal open-state feedback", () => { + // Given + render( + + Open actions + + + More actions + + Archive + + + + , + ); + + // When + const subTrigger = screen.getByRole("menuitem", { name: "More actions" }); + const chevron = subTrigger.querySelector("svg"); + + // Then + expect(subTrigger).toHaveClass( + "transition-colors", + "duration-150", + "ease-out", + "motion-reduce:transition-none", + ); + expect(chevron).toHaveClass( + "transition-[translate,opacity]", + "duration-150", + "ease-out", + "group-data-[state=open]:translate-x-0.5", + "motion-reduce:translate-x-0", + "motion-reduce:transition-none", + ); + }); + it("applies the same motion contract to submenu content", () => { // Given render( diff --git a/ui/components/shadcn/dropdown/dropdown.tsx b/ui/components/shadcn/dropdown/dropdown.tsx index 13b43522c6..a30de7c57c 100644 --- a/ui/components/shadcn/dropdown/dropdown.tsx +++ b/ui/components/shadcn/dropdown/dropdown.tsx @@ -92,15 +92,15 @@ function DropdownMenuCheckboxItem({ - + - + {children} @@ -128,14 +128,14 @@ function DropdownMenuRadioItem({ - + - + {children} @@ -208,13 +208,13 @@ function DropdownMenuSubTrigger({ data-slot="dropdown-menu-sub-trigger" data-inset={inset} className={cn( - "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground group flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors duration-150 ease-out select-none data-[inset]:pl-8 motion-reduce:transition-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...props} > {children} - + ); } diff --git a/ui/components/shadcn/select/select.test.tsx b/ui/components/shadcn/select/select.test.tsx index 366466f425..3c77931dcb 100644 --- a/ui/components/shadcn/select/select.test.tsx +++ b/ui/components/shadcn/select/select.test.tsx @@ -179,6 +179,31 @@ describe("Select", () => { expect(trigger).toHaveAttribute("data-closing", "true"); }); + it("animates option rows and selected check indicators as internal feedback", () => { + // Given + renderTypeSelect({ open: true }); + + // When + const selectedOption = screen.getByRole("option", { name: "All Types" }); + const checkIcon = selectedOption.querySelector("svg"); + + // Then + expect(selectedOption).toHaveClass( + "transition-colors", + "duration-150", + "ease-out", + "motion-reduce:transition-none", + ); + expect(checkIcon).toHaveClass( + "animate-in", + "fade-in-0", + "zoom-in-75", + "duration-150", + "ease-out", + "motion-reduce:animate-none", + ); + }); + it("uses explicit open and close motion classes", () => { // Given renderTypeSelect({ open: true }); diff --git a/ui/components/shadcn/select/select.tsx b/ui/components/shadcn/select/select.tsx index b0d64bd5c1..6f8b49f5e8 100644 --- a/ui/components/shadcn/select/select.tsx +++ b/ui/components/shadcn/select/select.tsx @@ -245,7 +245,7 @@ function SelectItem({ - + );