From 137fb6388fa8e6ac866edbec79f417d0d7418ef6 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 5 Jun 2026 21:13:15 +0200 Subject: [PATCH] feat(ui): add expandable microinteractions - Add visible open and close motion to collapsible content - Animate tree row, chevron, and selection feedback - Cover expandable motion behavior with focused unit tests --- ui/components/shadcn/collapsible.test.tsx | 59 ++++++++++++++ ui/components/shadcn/collapsible.tsx | 11 +++ ui/components/shadcn/tree-view/tree-leaf.tsx | 2 + ui/components/shadcn/tree-view/tree-node.tsx | 6 +- .../shadcn/tree-view/tree-view.test.tsx | 76 +++++++++++++++++++ 5 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 ui/components/shadcn/collapsible.test.tsx create mode 100644 ui/components/shadcn/tree-view/tree-view.test.tsx diff --git a/ui/components/shadcn/collapsible.test.tsx b/ui/components/shadcn/collapsible.test.tsx new file mode 100644 index 0000000000..827e2ce5dc --- /dev/null +++ b/ui/components/shadcn/collapsible.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "./collapsible"; + +describe("Collapsible", () => { + it("uses an intentional open and close motion contract", () => { + // Given + render( + + Toggle details + Expandable content + , + ); + + // When + const content = screen.getByText("Expandable content"); + + // Then + expect(content).toHaveAttribute("data-slot", "collapsible-content"); + expect(content).toHaveClass( + "overflow-hidden", + "duration-200", + "ease-out", + "data-[state=open]:animate-in", + "data-[state=open]:fade-in-0", + "data-[state=open]:slide-in-from-top-1", + "data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0", + "data-[state=closed]:slide-out-to-top-1", + "data-[state=closed]:duration-150", + "data-[state=closed]:ease-in", + ); + }); + + it("removes transform-heavy motion for reduced-motion users", () => { + // Given + render( + + Toggle details + Expandable content + , + ); + + // When + const content = screen.getByText("Expandable content"); + + // Then + expect(content).toHaveClass( + "motion-reduce:animate-none", + "motion-reduce:transform-none", + "motion-reduce:transition-none", + ); + }); +}); diff --git a/ui/components/shadcn/collapsible.tsx b/ui/components/shadcn/collapsible.tsx index 58a2ac20a5..da4f1384ce 100644 --- a/ui/components/shadcn/collapsible.tsx +++ b/ui/components/shadcn/collapsible.tsx @@ -2,6 +2,8 @@ import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; +import { cn } from "@/lib/utils"; + function Collapsible({ ...props }: React.ComponentProps) { @@ -20,11 +22,20 @@ function CollapsibleTrigger({ } function CollapsibleContent({ + className, ...props }: React.ComponentProps) { return ( ); diff --git a/ui/components/shadcn/tree-view/tree-leaf.tsx b/ui/components/shadcn/tree-view/tree-leaf.tsx index 1348bd9324..ba857bd784 100644 --- a/ui/components/shadcn/tree-view/tree-leaf.tsx +++ b/ui/components/shadcn/tree-view/tree-leaf.tsx @@ -58,7 +58,9 @@ export function TreeLeaf({ className={cn( "flex items-center gap-2 rounded-md px-2 py-1.5", "hover:bg-prowler-white/5 cursor-pointer", + "transition-[background-color,box-shadow,color] duration-150 ease-out motion-reduce:transition-none", "focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none", + isSelected && "bg-prowler-white/5", item.disabled && "cursor-not-allowed opacity-50", item.className, )} diff --git a/ui/components/shadcn/tree-view/tree-node.tsx b/ui/components/shadcn/tree-view/tree-node.tsx index fbd0703df8..c50bddd090 100644 --- a/ui/components/shadcn/tree-view/tree-node.tsx +++ b/ui/components/shadcn/tree-view/tree-node.tsx @@ -96,7 +96,9 @@ export function TreeNode({ className={cn( "flex items-center gap-2 rounded-md px-2 py-1.5", "hover:bg-prowler-white/5 cursor-pointer", + "transition-[background-color,box-shadow,color] duration-150 ease-out motion-reduce:transition-none", "focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none", + isSelected && "bg-prowler-white/5", item.disabled && "cursor-not-allowed opacity-50", item.className, )} @@ -110,7 +112,7 @@ export function TreeNode({ onKeyDown={handleKeyDown} >