Compare commits

...

1 Commits

Author SHA1 Message Date
Alan Buscaglia adbe67d2f3 feat(ui): add form control microinteractions
- Add visible focus and clear feedback to search inputs
- Animate radio, text input, textarea, and file dropzone states
- Cover form control motion with focused unit tests
2026-06-05 21:07:12 +02:00
12 changed files with 265 additions and 28 deletions
@@ -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(<FileUploadDropzone onFileSelect={onFileSelect} />);
// 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));
});
});
@@ -24,7 +24,9 @@ export function FileUploadDropzone({
title = "Drag and drop your file here",
emptyDescription = "or",
selectText = "Select File",
icon = <FileUp className="text-text-neutral-secondary size-6" />,
icon = (
<FileUp className="text-text-neutral-secondary size-6 transition-transform duration-150 ease-out group-hover:-translate-y-0.5 motion-reduce:transform-none motion-reduce:transition-none" />
),
}: 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}
<span className="text-text-neutral-primary text-sm font-medium">
<span className="text-text-neutral-primary text-sm font-medium transition-colors duration-150 ease-out motion-reduce:transition-none">
{file ? file.name : title}
</span>
<span className="text-text-neutral-secondary text-xs">
<span className="text-text-neutral-secondary text-xs transition-colors duration-150 ease-out motion-reduce:transition-none">
{file
? `${Math.ceil(file.size / 1024).toLocaleString()} KB`
: emptyDescription}
</span>
{!file && (
<span className="text-button-tertiary text-sm font-medium">
<span className="text-button-tertiary text-sm font-medium transition-colors duration-150 ease-out motion-reduce:transition-none">
{selectText}
</span>
)}
+22
View File
@@ -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(<Input aria-label="Alias" />);
// 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",
);
});
});
+1 -1
View File
@@ -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: {
@@ -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(
<RadioGroup value="aws" onValueChange={onValueChange}>
<RadioGroupItem value="aws" aria-label="AWS" />
<RadioGroupItem value="azure" aria-label="Azure" />
</RadioGroup>,
);
// 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");
});
});
@@ -25,7 +25,7 @@ function RadioGroupItem({
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-border-input-primary aspect-square size-4 shrink-0 rounded-full border shadow-[0_1px_2px_0_rgba(0,0,0,0.1)] transition-all outline-none",
"border-border-input-primary aspect-square size-4 shrink-0 rounded-full border shadow-[0_1px_2px_0_rgba(0,0,0,0.1)] transition-[background-color,border-color,box-shadow] duration-200 ease-out outline-none motion-reduce:transition-none",
"focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press/50 focus-visible:ring-2",
"data-[state=checked]:border-button-primary",
"disabled:cursor-not-allowed disabled:opacity-40",
@@ -34,8 +34,9 @@ function RadioGroupItem({
{...props}
>
<RadioGroupPrimitive.Indicator
forceMount
data-slot="radio-group-indicator"
className="grid place-content-center"
className="grid place-content-center 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:scale-100 motion-reduce:transition-none"
>
<span className="bg-button-primary size-2 rounded-full" />
</RadioGroupPrimitive.Indicator>
@@ -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(
<SearchInput
aria-label="Search findings"
value="cloudflare"
readOnly
onClear={vi.fn()}
/>,
);
// 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",
);
});
});
@@ -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<HTMLInputElement, SearchInputProps>(
<SearchIcon
size={iconSize}
className={cn(
"text-text-neutral-tertiary pointer-events-none absolute",
"text-text-neutral-tertiary pointer-events-none absolute transition-colors duration-250 ease-out motion-reduce:transition-none",
iconPosition,
)}
/>
@@ -102,19 +103,27 @@ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
className={cn(searchInputVariants({ variant, size, className }))}
{...props}
/>
{hasValue && onClear && (
<button
type="button"
aria-label="Clear search"
onClick={onClear}
className={cn(
"text-text-neutral-tertiary hover:text-text-neutral-primary absolute transition-colors focus:outline-none",
clearButtonPosition,
)}
>
<XCircle size={iconSize} />
</button>
)}
<AnimatePresence initial={false}>
{hasValue && onClear && (
<motion.button
key="clear-search"
type="button"
data-slot="search-input-clear"
aria-label="Clear search"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.25, ease: "easeOut" }}
onClick={onClear}
className={cn(
"text-text-neutral-tertiary hover:text-text-neutral-primary absolute transition-colors duration-250 ease-out focus:outline-none motion-reduce:transition-none",
clearButtonPosition,
)}
>
<XCircle size={iconSize} />
</motion.button>
)}
</AnimatePresence>
</div>
);
},
@@ -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(<Textarea aria-label="Reason" />);
// When - The textarea renders
const textarea = screen.getByRole("textbox", { name: /reason/i });
// Then - The focus/hover state changes are intentionally timed
expect(textarea).toHaveClass(
"transition-[background-color,border-color,box-shadow,color]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
});
});
+1 -1
View File
@@ -6,7 +6,7 @@ import { ComponentProps, forwardRef } from "react";
import { cn } from "@/lib/utils";
const textareaVariants = cva(
"flex w-full rounded-lg border text-sm transition-all outline-none resize-none 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 resize-none disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
variant: {
@@ -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 { DataTableSearch } from "./data-table-search";
vi.mock("next/navigation", () => ({
usePathname: () => "/findings",
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/hooks/use-url-filters", () => ({
useUrlFilters: () => ({ updateFilter: vi.fn() }),
}));
describe("DataTableSearch", () => {
it("uses visible focus and icon microinteraction timing", async () => {
// Given - A table search field
const user = userEvent.setup();
render(<DataTableSearch placeholder="Search findings" />);
// When - The user focuses the table search
const input = screen.getByRole("searchbox", { name: /search findings/i });
await user.click(input);
const control = screen.getByTestId("data-table-search-control");
const icon = screen.getByTestId("data-table-search-icon");
// Then - The table search control has visible focus/highlight timing
expect(control).toHaveClass(
"transition-[background-color,border-color,box-shadow,color]",
"duration-250",
"ease-out",
"motion-reduce:transition-none",
"focus-within:ring-1",
);
expect(icon).toHaveClass(
"transition-colors",
"duration-250",
"ease-out",
"motion-reduce:transition-none",
);
});
});
+8 -3
View File
@@ -142,13 +142,17 @@ export const DataTableSearch = ({
>
<div className="relative w-full">
<div
data-testid="data-table-search-control"
className={cn(
"border-border-neutral-tertiary bg-bg-neutral-tertiary hover:bg-bg-neutral-secondary flex items-center gap-1.5 rounded-md border transition-colors",
isFocused && "border-border-input-primary-pressed",
"border-border-neutral-tertiary bg-bg-neutral-tertiary hover:bg-bg-neutral-secondary focus-within:ring-border-input-primary-press flex items-center gap-1.5 rounded-md border transition-[background-color,border-color,box-shadow,color] duration-250 ease-out focus-within:ring-1 focus-within:ring-inset motion-reduce:transition-none",
isFocused && "border-border-input-primary-press",
)}
>
<div className="flex shrink-0 items-center pl-3">
<SearchIcon className="text-text-neutral-tertiary size-4" />
<SearchIcon
data-testid="data-table-search-icon"
className="text-text-neutral-tertiary size-4 transition-colors duration-250 ease-out motion-reduce:transition-none"
/>
</div>
{hasBadge && (
@@ -180,6 +184,7 @@ export const DataTableSearch = ({
ref={inputRef}
id={id}
type="search"
aria-label={placeholder}
placeholder={placeholder}
value={value}
onChange={(e) => handleChange(e.target.value)}