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
This commit is contained in:
Alan Buscaglia
2026-06-11 16:47:55 +02:00
parent 4648bb29d3
commit 137fb6388f
5 changed files with 152 additions and 2 deletions
+59
View File
@@ -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(
<Collapsible open>
<CollapsibleTrigger>Toggle details</CollapsibleTrigger>
<CollapsibleContent>Expandable content</CollapsibleContent>
</Collapsible>,
);
// 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(
<Collapsible open>
<CollapsibleTrigger>Toggle details</CollapsibleTrigger>
<CollapsibleContent>Expandable content</CollapsibleContent>
</Collapsible>,
);
// When
const content = screen.getByText("Expandable content");
// Then
expect(content).toHaveClass(
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
+11
View File
@@ -2,6 +2,8 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import { cn } from "@/lib/utils";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
@@ -20,11 +22,20 @@ function CollapsibleTrigger({
}
function CollapsibleContent({
className,
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
className={cn(
"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",
"motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
{...props}
/>
);
@@ -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,
)}
+4 -2
View File
@@ -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}
>
<button
className="hover:bg-prowler-white/10 shrink-0 rounded p-0.5"
className="hover:bg-prowler-white/10 shrink-0 rounded p-0.5 transition-colors duration-150 ease-out motion-reduce:transition-none"
aria-label={isExpanded ? "Collapse" : "Expand"}
onClick={(e) => {
e.stopPropagation();
@@ -123,7 +125,7 @@ export function TreeNode({
) : (
<ChevronRightIcon
className={cn(
"h-4 w-4 transition-transform duration-200",
"h-4 w-4 transition-transform duration-200 ease-out motion-reduce:transition-none",
isExpanded && "rotate-90",
)}
/>
@@ -0,0 +1,76 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TreeView } from "./tree-view";
const treeData = [
{
id: "org-1",
name: "Organization",
children: [
{ id: "account-1", name: "Production" },
{ id: "account-2", name: "Development" },
],
},
];
describe("TreeView", () => {
it("animates node affordances and expanded content", () => {
// Given
render(<TreeView data={treeData} expandedIds={["org-1"]} showCheckboxes />);
// When
const node = screen.getByRole("treeitem", { name: /organization/i });
const expandButton = screen.getByRole("button", { name: /collapse/i });
const chevron = expandButton.querySelector("svg");
const group = screen.getByRole("group");
// Then
expect(node).toHaveClass(
"transition-[background-color,box-shadow,color]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(expandButton).toHaveClass(
"transition-colors",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(chevron).toHaveClass(
"transition-transform",
"duration-200",
"ease-out",
"motion-reduce:transition-none",
"rotate-90",
);
expect(group).toHaveClass("overflow-hidden");
});
it("animates selected leaf row feedback", () => {
// Given
render(
<TreeView
data={treeData}
expandedIds={["org-1"]}
selectedIds={["account-1"]}
onSelectionChange={vi.fn()}
showCheckboxes
/>,
);
// When
const selectedLeaf = screen.getByRole("treeitem", { name: /production/i });
// Then
expect(selectedLeaf).toHaveAttribute("aria-selected", "true");
expect(selectedLeaf).toHaveClass(
"bg-prowler-white/5",
"transition-[background-color,box-shadow,color]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
});
});