mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-17 04:52:05 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c41e7e735b | |||
| 137fb6388f | |||
| 4648bb29d3 | |||
| 44e36421b1 |
@@ -0,0 +1,50 @@
|
||||
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",
|
||||
"data-[state=open]:animate-collapsible-down",
|
||||
"data-[state=closed]:animate-collapsible-up",
|
||||
);
|
||||
});
|
||||
|
||||
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: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,19 @@ function CollapsibleTrigger({
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
className={cn(
|
||||
"overflow-hidden",
|
||||
"data-[state=open]:animate-collapsible-down",
|
||||
"data-[state=closed]:animate-collapsible-up",
|
||||
"motion-reduce:animate-none motion-reduce:transition-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,68 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getClearButtonMotion, SearchInput } from "./search-input";
|
||||
|
||||
describe("getClearButtonMotion", () => {
|
||||
it("animates with scale when motion is allowed", () => {
|
||||
// Given / When
|
||||
const motion = getClearButtonMotion(false);
|
||||
|
||||
// Then
|
||||
expect(motion.animate).toHaveProperty("scale", 1);
|
||||
expect(motion.initial).toHaveProperty("scale");
|
||||
expect(motion.exit).toHaveProperty("scale");
|
||||
expect(motion.transition.duration).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("degrades to opacity-only with no scale under reduced motion", () => {
|
||||
// Given / When
|
||||
const motion = getClearButtonMotion(true);
|
||||
|
||||
// Then
|
||||
expect(motion.initial).not.toHaveProperty("scale");
|
||||
expect(motion.animate).not.toHaveProperty("scale");
|
||||
expect(motion.exit).not.toHaveProperty("scale");
|
||||
expect(motion.transition.duration).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
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, useReducedMotion } 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: {
|
||||
@@ -66,6 +67,24 @@ export interface SearchInputProps
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
export function getClearButtonMotion(shouldReduceMotion: boolean) {
|
||||
if (shouldReduceMotion) {
|
||||
return {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
transition: { duration: 0, ease: "easeOut" as const },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
initial: { opacity: 0, scale: 0.95 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.95 },
|
||||
transition: { duration: 0.25, ease: "easeOut" as const },
|
||||
};
|
||||
}
|
||||
|
||||
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
(
|
||||
{
|
||||
@@ -83,13 +102,15 @@ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
const iconPosition = iconPositionMap[size || "default"];
|
||||
const clearButtonPosition = clearButtonPositionMap[size || "default"];
|
||||
const hasValue = value && String(value).length > 0;
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const clearButtonMotion = getClearButtonMotion(!!shouldReduceMotion);
|
||||
|
||||
return (
|
||||
<div className={cn(searchInputWrapperVariants({ size }))}>
|
||||
<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 +123,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={clearButtonMotion.initial}
|
||||
animate={clearButtonMotion.animate}
|
||||
exit={clearButtonMotion.exit}
|
||||
transition={clearButtonMotion.transition}
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import { KeyboardEvent } from "react";
|
||||
|
||||
@@ -14,6 +14,24 @@ import { TreeSpinner } from "./tree-spinner";
|
||||
import { TreeStatusIndicator } from "./tree-status-indicator";
|
||||
import { getAllDescendantIds, getTreeNodePadding } from "./utils";
|
||||
|
||||
export function getTreeChildrenMotion(shouldReduceMotion: boolean) {
|
||||
if (shouldReduceMotion) {
|
||||
return {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
transition: { duration: 0, ease: "easeInOut" as const },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
initial: { opacity: 0, height: 0 },
|
||||
animate: { opacity: 1, height: "auto" as const },
|
||||
exit: { opacity: 0, height: 0 },
|
||||
transition: { duration: 0.2, ease: "easeInOut" as const },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TreeNode component for rendering expandable nodes with children.
|
||||
*
|
||||
@@ -36,6 +54,8 @@ export function TreeNode({
|
||||
renderItem,
|
||||
enableSelectChildren,
|
||||
}: TreeNodeProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const childrenMotion = getTreeChildrenMotion(!!shouldReduceMotion);
|
||||
const isExpanded = expandedIds.includes(item.id);
|
||||
const isSelected = selectedIds.includes(item.id);
|
||||
const statusIcon =
|
||||
@@ -96,7 +116,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 +132,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 +145,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",
|
||||
)}
|
||||
/>
|
||||
@@ -164,10 +186,10 @@ export function TreeNode({
|
||||
{isExpanded && (
|
||||
<motion.ul
|
||||
key={`children-${item.id}`}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
initial={childrenMotion.initial}
|
||||
animate={childrenMotion.animate}
|
||||
exit={childrenMotion.exit}
|
||||
transition={childrenMotion.transition}
|
||||
className="mt-1 space-y-1 overflow-hidden"
|
||||
role="group"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getTreeChildrenMotion } from "./tree-node";
|
||||
import { TreeView } from "./tree-view";
|
||||
|
||||
describe("getTreeChildrenMotion", () => {
|
||||
it("animates height when motion is allowed", () => {
|
||||
// Given / When
|
||||
const motion = getTreeChildrenMotion(false);
|
||||
|
||||
// Then
|
||||
expect(motion.initial).toHaveProperty("height", 0);
|
||||
expect(motion.animate).toHaveProperty("height", "auto");
|
||||
expect(motion.transition.duration).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("degrades to opacity-only with no height under reduced motion", () => {
|
||||
// Given / When
|
||||
const motion = getTreeChildrenMotion(true);
|
||||
|
||||
// Then
|
||||
expect(motion.initial).not.toHaveProperty("height");
|
||||
expect(motion.animate).not.toHaveProperty("height");
|
||||
expect(motion.exit).not.toHaveProperty("height");
|
||||
expect(motion.transition.duration).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user