diff --git a/ui/components/shadcn/file-upload/file-upload-dropzone.test.tsx b/ui/components/shadcn/file-upload/file-upload-dropzone.test.tsx new file mode 100644 index 0000000000..06db5920f2 --- /dev/null +++ b/ui/components/shadcn/file-upload/file-upload-dropzone.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { FileUploadDropzone } from "./file-upload-dropzone"; + +describe("FileUploadDropzone", () => { + it("animates drag feedback and selected file content", async () => { + // Given - A dropzone without a selected file + const user = userEvent.setup(); + const onFileSelect = vi.fn(); + render(); + + // When - The dropzone renders + const dropzone = screen.getByText(/drag and drop/i).closest("label"); + const input = screen.getByLabelText(/drag and drop/i, { + selector: "input", + }); + + // Then - Drag feedback and internal content have visible motion contracts + expect(dropzone).toHaveClass( + "transition-[background-color,border-color,box-shadow,transform]", + "duration-150", + "ease-out", + "motion-reduce:transition-none", + ); + expect(dropzone?.querySelector("svg")).toHaveClass( + "transition-transform", + "duration-150", + "ease-out", + "group-hover:-translate-y-0.5", + "motion-reduce:transform-none", + ); + + await user.upload( + input, + new File(["prowler"], "evidence.json", { type: "application/json" }), + ); + + expect(onFileSelect).toHaveBeenCalledWith(expect.any(File)); + }); +}); diff --git a/ui/components/shadcn/file-upload/file-upload-dropzone.tsx b/ui/components/shadcn/file-upload/file-upload-dropzone.tsx index 120bdea59d..23162947ce 100644 --- a/ui/components/shadcn/file-upload/file-upload-dropzone.tsx +++ b/ui/components/shadcn/file-upload/file-upload-dropzone.tsx @@ -24,7 +24,9 @@ export function FileUploadDropzone({ title = "Drag and drop your file here", emptyDescription = "or", selectText = "Select File", - icon = , + icon = ( + + ), }: FileUploadDropzoneProps) { const inputId = useId(); const [isDragging, setIsDragging] = useState(false); @@ -45,23 +47,23 @@ export function FileUploadDropzone({ onDragLeave={() => setIsDragging(false)} onDrop={handleDrop} className={cn( - "border-border-neutral-tertiary bg-bg-neutral-primary hover:bg-bg-neutral-tertiary flex min-h-[132px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-8 text-center transition-colors", + "border-border-neutral-tertiary bg-bg-neutral-primary hover:bg-bg-neutral-tertiary group flex min-h-[132px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-8 text-center transition-[background-color,border-color,box-shadow,transform] duration-150 ease-out motion-reduce:transition-none", isDragging && - "border-border-input-primary-press bg-bg-neutral-tertiary", + "border-border-input-primary-press bg-bg-neutral-tertiary scale-[1.01] shadow-sm motion-reduce:scale-100", className, )} > {icon} - + {file ? file.name : title} - + {file ? `${Math.ceil(file.size / 1024).toLocaleString()} KB` : emptyDescription} {!file && ( - + {selectText} )} diff --git a/ui/components/shadcn/input/input.test.tsx b/ui/components/shadcn/input/input.test.tsx new file mode 100644 index 0000000000..eca1b9cc61 --- /dev/null +++ b/ui/components/shadcn/input/input.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Input } from "./input"; + +describe("Input", () => { + it("uses visible hover and focus microinteraction timing", () => { + // Given - A standard text input + render(); + + // When - The input renders + const input = screen.getByRole("textbox", { name: /alias/i }); + + // Then - The focus/hover state changes are intentionally timed + expect(input).toHaveClass( + "transition-[background-color,border-color,box-shadow,color]", + "duration-150", + "ease-out", + "motion-reduce:transition-none", + ); + }); +}); diff --git a/ui/components/shadcn/input/input.tsx b/ui/components/shadcn/input/input.tsx index a6c9e67eff..7fe2699d8b 100644 --- a/ui/components/shadcn/input/input.tsx +++ b/ui/components/shadcn/input/input.tsx @@ -6,7 +6,7 @@ import { ComponentProps, forwardRef } from "react"; import { cn } from "@/lib/utils"; const inputVariants = cva( - "flex w-full rounded-lg border text-sm transition-all outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50", + "flex w-full rounded-lg border text-sm transition-[background-color,border-color,box-shadow,color] duration-150 ease-out outline-none motion-reduce:transition-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50", { variants: { variant: { diff --git a/ui/components/shadcn/radio-group/radio-group.test.tsx b/ui/components/shadcn/radio-group/radio-group.test.tsx new file mode 100644 index 0000000000..26e7ab49aa --- /dev/null +++ b/ui/components/shadcn/radio-group/radio-group.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { RadioGroup, RadioGroupItem } from "./radio-group"; + +describe("RadioGroup", () => { + it("animates item state and indicator entry", async () => { + // Given - A controlled radio group + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + + + , + ); + + // When - The user selects another radio option + const azure = screen.getByRole("radio", { name: /azure/i }); + await user.click(azure); + const indicator = azure.querySelector( + "[data-slot='radio-group-indicator']", + ); + + // Then - The item and dot use synchronized visual feedback + expect(azure).toHaveClass( + "transition-[background-color,border-color,box-shadow]", + "duration-200", + "ease-out", + "motion-reduce:transition-none", + ); + expect(indicator).toHaveClass( + "transition-[opacity,transform]", + "duration-200", + "ease-out", + "data-[state=checked]:scale-100", + "data-[state=checked]:opacity-100", + "data-[state=unchecked]:scale-75", + "data-[state=unchecked]:opacity-0", + "motion-reduce:transition-none", + ); + expect(onValueChange).toHaveBeenCalledWith("azure"); + }); +}); diff --git a/ui/components/shadcn/radio-group/radio-group.tsx b/ui/components/shadcn/radio-group/radio-group.tsx index 5765073031..c91f0cbf72 100644 --- a/ui/components/shadcn/radio-group/radio-group.tsx +++ b/ui/components/shadcn/radio-group/radio-group.tsx @@ -25,7 +25,7 @@ function RadioGroupItem({ diff --git a/ui/components/shadcn/search-input/search-input.test.tsx b/ui/components/shadcn/search-input/search-input.test.tsx new file mode 100644 index 0000000000..1daca44e79 --- /dev/null +++ b/ui/components/shadcn/search-input/search-input.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { SearchInput } from "./search-input"; + +describe("SearchInput", () => { + it("animates input focus, icon color, and clear button entry", () => { + // Given - A search input with a clear action + render( + , + ); + + // When - The search field has a value + const input = screen.getByRole("textbox", { name: /search findings/i }); + const clearButton = screen.getByRole("button", { name: /clear search/i }); + const searchIcon = input.parentElement?.querySelector("svg"); + + // Then - Search-specific affordances have visible motion + expect(input).toHaveClass( + "transition-[background-color,border-color,box-shadow,color]", + "duration-250", + "ease-out", + "motion-reduce:transition-none", + ); + expect(searchIcon).toHaveClass( + "transition-colors", + "duration-250", + "ease-out", + "motion-reduce:transition-none", + ); + expect(clearButton).toHaveAttribute("data-slot", "search-input-clear"); + expect(clearButton).toHaveClass( + "transition-colors", + "duration-250", + "ease-out", + "motion-reduce:transition-none", + ); + }); +}); diff --git a/ui/components/shadcn/search-input/search-input.tsx b/ui/components/shadcn/search-input/search-input.tsx index 18ee5428f2..2cb9be1736 100644 --- a/ui/components/shadcn/search-input/search-input.tsx +++ b/ui/components/shadcn/search-input/search-input.tsx @@ -1,6 +1,7 @@ "use client"; import { cva, type VariantProps } from "class-variance-authority"; +import { AnimatePresence, motion } from "framer-motion"; import { SearchIcon, XCircle } from "lucide-react"; import { ComponentProps, forwardRef } from "react"; @@ -20,7 +21,7 @@ const searchInputWrapperVariants = cva("relative flex items-center w-full", { }); const searchInputVariants = cva( - "flex w-full rounded-lg border text-sm transition-all outline-none placeholder:text-text-neutral-tertiary disabled:cursor-not-allowed disabled:opacity-50", + "flex w-full rounded-lg border text-sm transition-[background-color,border-color,box-shadow,color] duration-250 ease-out outline-none motion-reduce:transition-none placeholder:text-text-neutral-tertiary disabled:cursor-not-allowed disabled:opacity-50", { variants: { variant: { @@ -89,7 +90,7 @@ const SearchInput = forwardRef( @@ -102,19 +103,27 @@ const SearchInput = forwardRef( className={cn(searchInputVariants({ variant, size, className }))} {...props} /> - {hasValue && onClear && ( - - )} + + {hasValue && onClear && ( + + + + )} + ); }, diff --git a/ui/components/shadcn/textarea/textarea.test.tsx b/ui/components/shadcn/textarea/textarea.test.tsx new file mode 100644 index 0000000000..27147e7d16 --- /dev/null +++ b/ui/components/shadcn/textarea/textarea.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Textarea } from "./textarea"; + +describe("Textarea", () => { + it("uses visible hover and focus microinteraction timing", () => { + // Given - A standard textarea + render(