mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-05 03:12:14 +00:00
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:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user