Compare commits

...

13 Commits

Author SHA1 Message Date
Alan Buscaglia f4051d52d9 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
2026-06-05 21:13:15 +02:00
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
Alan Buscaglia 65c0425729 fix(ui): animate finding group collapse
- Keep after-row content mounted during exit
- Animate inline resource container close state
- Preserve existing finding group table behavior
2026-06-05 14:55:00 +02:00
Alan Buscaglia 5828cce644 feat(ui): add Combobox trigger transition
- Animate combobox trigger and chevron state
- Keep trigger accessible with a stable label
- Cover Combobox motion with focused unit tests
2026-06-05 14:54:52 +02:00
Alan Buscaglia 87bd2e78a1 feat(ui): add Drawer content transition
- Animate drawer overlay and directional content states
- Preserve reduced-motion behavior
- Cover Drawer motion with focused unit tests
2026-06-05 14:54:45 +02:00
Alan Buscaglia ccae4afe68 feat(ui): add Dialog content transition
- Animate dialog overlay and content states
- Preserve reduced-motion behavior
- Cover Dialog motion with focused unit tests
2026-06-05 14:54:37 +02:00
Alan Buscaglia 0e2bb99f02 feat(ui): add Tabs content transition
- Animate tab panels when switching active content
- Preserve reduced-motion behavior
- Cover shared Tabs motion with focused unit tests
2026-06-05 14:49:09 +02:00
Alan Buscaglia 8fb59682d5 feat(ui): add multiselect selection microinteractions
- Animate selected pills and item feedback in multiselect components
- Add checkbox state transitions for provider group selections
- Cover shared selection motion with focused unit tests
2026-06-05 14:44:25 +02:00
Alan Buscaglia 799f062ee0 feat(ui): add Tooltip open close microinteraction
- Add visible Tooltip motion timing and easing

- Preserve reduced-motion fallbacks

- Cover Tooltip motion contract with unit tests
2026-06-05 13:47:11 +02:00
Alan Buscaglia 51945f5cc5 feat(ui): add Dropdown open close microinteraction
- Add visible Dropdown motion timing and easing

- Align submenu motion with dropdown content

- Cover reduced-motion behavior with unit tests
2026-06-05 13:46:53 +02:00
Alan Buscaglia b93e3f9d04 feat(ui): add Select open close microinteraction
- Add visible Select close motion without open-state conflicts
- Preserve reduced-motion behavior
- Cover controlled and uncontrolled close flows
2026-06-05 13:04:16 +02:00
Alan Buscaglia ef4d05a782 feat(ui): add Popover open close microinteraction
- Add explicit timing for Popover state transitions
- Add reduced-motion fallback utilities
- Cover controlled Popover motion behavior
2026-06-05 12:16:27 +02:00
Alan Buscaglia 7185e539c8 feat(ui): add Button press microinteraction
- Add targeted transition recipe for shared Button states
- Add press and reduced-motion behavior
- Cover link and menu motion exceptions
2026-06-05 12:00:35 +02:00
42 changed files with 1825 additions and 171 deletions
@@ -5,7 +5,7 @@ import {
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { AnimatePresence, motion } from "framer-motion";
import { motion } from "framer-motion";
import { ChevronsDown } from "lucide-react";
import { useImperativeHandle, useRef } from "react";
@@ -213,109 +213,112 @@ export function InlineResourceContainer({
onMuteComplete: handleMuteComplete,
}}
>
<tr>
<motion.tr
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<td colSpan={columnCount} className="p-0">
<AnimatePresence initial>
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="overflow-hidden"
>
<div className="relative">
<div
ref={combinedScrollRef}
className="max-h-[440px] overflow-y-auto pl-6"
>
{/* Resource rows or skeleton placeholder */}
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
<tbody>
{isLoading && rows.length === 0 ? (
Array.from({ length: skeletonRowCount }).map((_, i) => (
<ResourceSkeletonRow
key={i}
isEmptyStateSized={filteredResourceCount === 0}
/>
))
) : rows.length > 0 ? (
rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={(e) => {
// Don't open drawer if clicking interactive elements
// (links, buttons, checkboxes, dropdown items)
const target = e.target as HTMLElement;
if (
target.closest(
"a, button, input, [role=menuitem]",
)
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="relative">
<div
ref={combinedScrollRef}
className="max-h-[440px] overflow-y-auto pl-6"
>
{/* Resource rows or skeleton placeholder */}
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
<tbody>
{isLoading && rows.length === 0 ? (
Array.from({ length: skeletonRowCount }).map((_, i) => (
<ResourceSkeletonRow
key={i}
isEmptyStateSized={filteredResourceCount === 0}
/>
))
) : rows.length > 0 ? (
rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={(e) => {
// Don't open drawer if clicking interactive elements
// (links, buttons, checkboxes, dropdown items)
const target = e.target as HTMLElement;
if (
target.closest(
"a, button, input, [role=menuitem]",
)
return;
drawer.openDrawer(row.index);
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{getFindingGroupEmptyStateMessage(group, filters)}
</TableCell>
)
return;
drawer.openDrawer(row.index);
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
)}
</tbody>
</table>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{getFindingGroupEmptyStateMessage(group, filters)}
</TableCell>
</TableRow>
)}
</tbody>
</table>
{/* Loading state for infinite scroll (subsequent pages only) */}
{isLoading && rows.length > 0 && (
<LoadingState label="Loading resources..." />
)}
{/* Loading state for infinite scroll (subsequent pages only) */}
{isLoading && rows.length > 0 && (
<LoadingState label="Loading resources..." />
)}
{/* Sentinel for scroll hint detection */}
<div
ref={scrollHintSentinelRef}
aria-hidden
className="h-px shrink-0"
/>
{/* Sentinel for scroll hint detection */}
<div
ref={scrollHintSentinelRef}
aria-hidden
className="h-px shrink-0"
/>
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} className="h-1" />
</div>
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} className="h-1" />
</div>
{/* Gradients rendered after scroll container so they paint on top */}
<div className="from-bg-neutral-secondary pointer-events-none absolute top-0 right-0 left-6 z-20 h-6 bg-gradient-to-b to-transparent" />
<div className="from-bg-neutral-secondary pointer-events-none absolute right-0 bottom-0 left-6 z-20 h-6 bg-gradient-to-t to-transparent" />
{/* Gradients rendered after scroll container so they paint on top */}
<div className="from-bg-neutral-secondary pointer-events-none absolute top-0 right-0 left-6 z-20 h-6 bg-gradient-to-b to-transparent" />
<div className="from-bg-neutral-secondary pointer-events-none absolute right-0 bottom-0 left-6 z-20 h-6 bg-gradient-to-t to-transparent" />
{/* Scroll hint */}
{showScrollHint && (
<div className="pointer-events-none absolute right-0 bottom-0 left-6 z-30">
<div className="absolute inset-x-0 bottom-2 flex justify-center">
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary animate-bounce rounded-full px-3 py-1 text-xs shadow-md">
<ChevronsDown className="inline size-3.5" /> Scroll for
more
</div>
{/* Scroll hint */}
{showScrollHint && (
<div className="pointer-events-none absolute right-0 bottom-0 left-6 z-30">
<div className="absolute inset-x-0 bottom-2 flex justify-center">
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary animate-bounce rounded-full px-3 py-1 text-xs shadow-md">
<ChevronsDown className="inline size-3.5" /> Scroll for
more
</div>
</div>
)}
</div>
</motion.div>
</AnimatePresence>
</div>
)}
</div>
</motion.div>
</td>
</tr>
</motion.tr>
<ResourceDetailDrawer
open={drawer.isOpen}
@@ -32,4 +32,53 @@ describe("Button", () => {
"text-xs",
);
});
it("applies the shared press and reduced-motion contract to button-like variants", () => {
// Given
render(<Button>Start scan</Button>);
// When
const button = screen.getByRole("button", { name: "Start scan" });
// Then
expect(button).toHaveClass(
"transition-[background-color,border-color,color,box-shadow,transform,scale]",
"duration-150",
"ease-out",
"active:scale-[0.98]",
"motion-reduce:active:scale-100",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
expect(button).not.toHaveClass("transition-all");
});
it("keeps link buttons from scaling on press", () => {
// Given
render(<Button variant="link">Open details</Button>);
// When
const button = screen.getByRole("button", { name: "Open details" });
// Then
expect(button).toHaveClass("active:scale-100");
expect(button).not.toHaveClass("active:scale-[0.98]");
});
it("keeps menu buttons on the shared targeted transition recipe", () => {
// Given
render(<Button variant="menu">Open menu</Button>);
// When
const button = screen.getByRole("button", { name: "Open menu" });
// Then
expect(button).toHaveClass(
"transition-[background-color,border-color,color,box-shadow,transform,scale]",
"duration-200",
"active:scale-[0.98]",
"motion-reduce:active:scale-100",
);
expect(button).not.toHaveClass("transition-all");
});
});
+5 -5
View File
@@ -5,7 +5,7 @@ import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[8px] text-sm font-medium transition-all disabled:pointer-events-none disabled:bg-button-disabled disabled:text-text-neutral-tertiary outline-none focus-visible:ring-2 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[8px] text-sm font-medium transition-[background-color,border-color,color,box-shadow,transform,scale] duration-150 ease-out active:scale-[0.98] disabled:pointer-events-none disabled:bg-button-disabled disabled:text-text-neutral-tertiary motion-reduce:active:scale-100 motion-reduce:transform-none motion-reduce:transition-none outline-none focus-visible:ring-2 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
{
variants: {
variant: {
@@ -21,13 +21,13 @@ const buttonVariants = cva(
"border border-border-neutral-secondary bg-bg-neutral-secondary hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary text-text-neutral-primary focus-visible:ring-border-neutral-tertiary/50",
ghost:
"border border-transparent text-text-neutral-primary hover:bg-bg-neutral-tertiary active:bg-border-neutral-secondary focus-visible:ring-border-neutral-secondary/50",
link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover disabled:bg-transparent",
link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover active:scale-100 disabled:bg-transparent",
// Menu variant like secondary but more padding and the back is almost transparent
menu: "backdrop-blur-xl bg-white/60 dark:bg-white/5 border border-white/80 dark:border-white/10 text-text-neutral-primary dark:text-white shadow-lg hover:bg-white/70 dark:hover:bg-white/10 hover:border-white/90 dark:hover:border-white/30 active:bg-white/80 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200",
menu: "backdrop-blur-xl bg-white/60 dark:bg-white/5 border border-white/80 dark:border-white/10 text-text-neutral-primary dark:text-white shadow-lg hover:bg-white/70 dark:hover:bg-white/10 hover:border-white/90 dark:hover:border-white/30 active:bg-white/80 dark:active:bg-white/15 focus-visible:ring-button-primary/50 duration-200",
"menu-active":
"backdrop-blur-xl bg-white/50 dark:bg-white/5 border border-black/[0.08] dark:border-white/10 text-text-neutral-primary dark:text-white shadow-sm hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/[0.12] dark:hover:border-white/30 active:bg-white/70 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200",
"backdrop-blur-xl bg-white/50 dark:bg-white/5 border border-black/[0.08] dark:border-white/10 text-text-neutral-primary dark:text-white shadow-sm hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/[0.12] dark:hover:border-white/30 active:bg-white/70 dark:active:bg-white/15 focus-visible:ring-button-primary/50 duration-200",
"menu-inactive":
"text-text-neutral-primary border border-transparent hover:backdrop-blur-xl hover:bg-white/40 dark:hover:bg-white/5 hover:border-black/[0.08] dark:hover:border-white/10 hover:shadow-sm active:bg-white/50 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-border-neutral-secondary/50 transition-all duration-200",
"text-text-neutral-primary border border-transparent hover:backdrop-blur-xl hover:bg-white/40 dark:hover:bg-white/5 hover:border-black/[0.08] dark:hover:border-white/10 hover:shadow-sm active:bg-white/50 dark:active:bg-white/15 focus-visible:ring-border-neutral-secondary/50 duration-200",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
@@ -0,0 +1,52 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { Checkbox } from "./checkbox";
function ControlledCheckbox() {
const [checked, setChecked] = useState(false);
return (
<Checkbox
aria-label="Select provider"
checked={checked}
onCheckedChange={(value) => setChecked(value === true)}
/>
);
}
describe("Checkbox", () => {
it("animates the background and check mark as one state change", async () => {
// Given - A controlled checkbox in the unchecked state
const user = userEvent.setup();
render(<ControlledCheckbox />);
const checkbox = screen.getByRole("checkbox", { name: /select provider/i });
const indicator = checkbox.querySelector(
"[data-slot='checkbox-indicator']",
);
// When - The user checks the checkbox
await user.click(checkbox);
// Then - The background and check mark transitions use the same timing
expect(checkbox).toHaveClass(
"transition-colors",
"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",
);
});
});
+3 -2
View File
@@ -41,7 +41,7 @@ function Checkbox({
checked={indeterminate ? "indeterminate" : checked}
className={cn(
// Base styles
"peer shrink-0 rounded-sm border transition-all outline-none",
"peer shrink-0 rounded-sm border transition-colors duration-200 ease-out outline-none motion-reduce:transition-none",
sizeStyles.root,
// Default state
"bg-bg-input-primary border-border-input-primary shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]",
@@ -58,8 +58,9 @@ function Checkbox({
{...props}
>
<CheckboxPrimitive.Indicator
forceMount
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
className="grid place-content-center text-current transition-[opacity,transform] duration-200 ease-out data-[state=checked]:scale-100 data-[state=checked]:opacity-100 data-[state=indeterminate]:scale-100 data-[state=indeterminate]:opacity-100 data-[state=unchecked]:scale-75 data-[state=unchecked]:opacity-0 motion-reduce:scale-100 motion-reduce:transition-none"
>
{indeterminate || checked === "indeterminate" ? (
<MinusIcon className={sizeStyles.icon} />
+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}
/>
);
@@ -0,0 +1,94 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { Combobox } from "./combobox";
const options = [
{ value: "aws", label: "AWS" },
{ value: "azure", label: "Azure" },
{ value: "gcp", label: "GCP" },
];
beforeAll(() => {
global.ResizeObserver = class ResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
} as unknown as typeof ResizeObserver;
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
configurable: true,
value: vi.fn(() => false),
});
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
configurable: true,
value: vi.fn(),
});
});
describe("Combobox", () => {
it("renders a selectable combobox trigger", () => {
// Given
render(<Combobox options={options} placeholder="Select provider" />);
// When
const trigger = screen.getByRole("combobox", { name: /select provider/i });
// Then
expect(trigger).toBeVisible();
expect(trigger).toHaveAttribute("aria-expanded", "false");
});
it("uses visible trigger and chevron open-state motion", () => {
// Given
render(<Combobox options={options} placeholder="Select provider" />);
// When
const trigger = screen.getByRole("combobox", { name: /select provider/i });
const icon = trigger.querySelector("svg");
// Then
expect(trigger).toHaveClass(
"group",
"transition-[background-color,border-color,color,box-shadow]",
);
expect(icon).toHaveClass(
"transition-transform",
"duration-200",
"ease-out",
"group-aria-expanded:rotate-180",
"motion-reduce:rotate-0",
"motion-reduce:transition-none",
);
});
it("opens with the shared Popover content motion contract", async () => {
// Given
const user = userEvent.setup();
render(<Combobox options={options} placeholder="Select provider" />);
// When
await user.click(
screen.getByRole("combobox", { name: /select provider/i }),
);
const content = document.querySelector("[data-slot='popover-content']");
// Then
expect(content).toHaveClass(
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
+3 -1
View File
@@ -113,8 +113,10 @@ export function Combobox({
variant="outline"
role="combobox"
aria-expanded={open}
aria-label={selectedOption ? selectedOption.label : placeholder}
disabled={disabled}
className={cn(
"group transition-[background-color,border-color,color,box-shadow]",
comboboxTriggerVariants({ variant }),
triggerClassName,
className,
@@ -123,7 +125,7 @@ export function Combobox({
<span className="truncate">
{selectedOption ? selectedOption.label : placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 transition-transform duration-200 ease-out group-aria-expanded:rotate-180 motion-reduce:rotate-0 motion-reduce:transition-none" />
</Button>
</PopoverTrigger>
<PopoverContent
+84
View File
@@ -0,0 +1,84 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from "./dialog";
function renderOpenDialog() {
return render(
<Dialog open>
<DialogTrigger>Open modal</DialogTrigger>
<DialogContent>
<DialogTitle>Launch scan</DialogTitle>
<DialogDescription>Configure scan settings</DialogDescription>
</DialogContent>
</Dialog>,
);
}
describe("Dialog", () => {
it("renders controlled content through the Radix Dialog API", () => {
// Given
renderOpenDialog();
// When
const dialog = screen.getByRole("dialog", { name: "Launch scan" });
// Then
expect(dialog).toBeVisible();
expect(dialog).toHaveAttribute("data-slot", "dialog-content");
expect(screen.getByText("Configure scan settings")).toBeVisible();
});
it("uses an intentional overlay motion contract", () => {
// Given
renderOpenDialog();
// When
const overlay = document.querySelector("[data-slot='dialog-overlay']");
// Then
expect(overlay).toHaveClass(
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
"motion-reduce:animate-none",
"motion-reduce:transition-none",
);
});
it("uses an intentional content motion contract", () => {
// Given
renderOpenDialog();
// When
const dialog = screen.getByRole("dialog", { name: "Launch scan" });
// Then
expect(dialog).toHaveClass(
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:zoom-out-95",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
+2 -2
View File
@@ -37,7 +37,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
{...props}
@@ -59,7 +59,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none sm:max-w-lg",
className,
)}
onClick={(e) => e.stopPropagation()}
+88
View File
@@ -0,0 +1,88 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerTitle,
DrawerTrigger,
} from "./drawer";
function renderOpenDrawer() {
return render(
<Drawer open>
<DrawerTrigger>Open drawer</DrawerTrigger>
<DrawerContent>
<DrawerTitle>Resource details</DrawerTitle>
<DrawerDescription>Review resource metadata</DrawerDescription>
</DrawerContent>
</Drawer>,
);
}
describe("Drawer", () => {
it("renders controlled content through the Vaul Drawer API", () => {
// Given
renderOpenDrawer();
// When
const drawer = screen.getByRole("dialog", { name: "Resource details" });
// Then
expect(drawer).toBeVisible();
expect(drawer).toHaveAttribute("data-slot", "drawer-content");
expect(screen.getByText("Review resource metadata")).toBeVisible();
});
it("uses an intentional overlay motion contract", () => {
// Given
renderOpenDrawer();
// When
const overlay = document.querySelector("[data-slot='drawer-overlay']");
// Then
expect(overlay).toHaveClass(
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
"motion-reduce:animate-none",
"motion-reduce:transition-none",
);
});
it("uses direction-aware drawer content motion", () => {
// Given
renderOpenDrawer();
// When
const drawer = screen.getByRole("dialog", { name: "Resource details" });
// Then
expect(drawer).toHaveClass(
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=closed]:animate-out",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
"data-[vaul-drawer-direction=bottom]:slide-in-from-bottom-full",
"data-[vaul-drawer-direction=bottom]:data-[state=closed]:slide-out-to-bottom-full",
"data-[vaul-drawer-direction=top]:slide-in-from-top-full",
"data-[vaul-drawer-direction=top]:data-[state=closed]:slide-out-to-top-full",
"data-[vaul-drawer-direction=right]:slide-in-from-right-full",
"data-[vaul-drawer-direction=right]:data-[state=closed]:slide-out-to-right-full",
"data-[vaul-drawer-direction=left]:slide-in-from-left-full",
"data-[vaul-drawer-direction=left]:data-[state=closed]:slide-out-to-left-full",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
+6 -6
View File
@@ -35,7 +35,7 @@ function DrawerOverlay({
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
{...props}
@@ -54,11 +54,11 @@ function DrawerContent({
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:border-l-border-neutral-secondary data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:border-l",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:border-r",
"group/drawer-content bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex h-auto flex-col duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
"data-[vaul-drawer-direction=top]:slide-in-from-top-full data-[vaul-drawer-direction=top]:data-[state=closed]:slide-out-to-top-full data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:slide-in-from-bottom-full data-[vaul-drawer-direction=bottom]:data-[state=closed]:slide-out-to-bottom-full data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:border-l-border-neutral-secondary data-[vaul-drawer-direction=right]:slide-in-from-right-full data-[vaul-drawer-direction=right]:data-[state=closed]:slide-out-to-right-full data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:border-l",
"data-[vaul-drawer-direction=left]:slide-in-from-left-full data-[vaul-drawer-direction=left]:data-[state=closed]:slide-out-to-left-full data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:border-r",
className,
)}
{...props}
@@ -0,0 +1,114 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./dropdown";
function renderActionsDropdown() {
return render(
<DropdownMenu open>
<DropdownMenuTrigger>Open actions</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
);
}
describe("DropdownMenu", () => {
it("renders open menu content through the Radix DropdownMenu API", () => {
// Given
renderActionsDropdown();
// When
const menu = screen.getByRole("menu");
// Then
expect(menu).toBeVisible();
expect(menu).toHaveAttribute("data-slot", "dropdown-menu-content");
expect(screen.getByRole("menuitem", { name: "Edit" })).toBeVisible();
expect(screen.getByRole("menuitem", { name: "Delete" })).toBeVisible();
});
it("uses an intentional open and close motion contract", () => {
// Given
renderActionsDropdown();
// When
const menu = screen.getByRole("menu");
// Then
expect(menu).toHaveClass(
"origin-(--radix-dropdown-menu-content-transform-origin)",
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:zoom-out-95",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
);
});
it("removes transform-heavy menu motion for reduced-motion users", () => {
// Given
renderActionsDropdown();
// When
const menu = screen.getByRole("menu");
// Then
expect(menu).toHaveClass(
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
it("applies the same motion contract to submenu content", () => {
// Given
render(
<DropdownMenu open>
<DropdownMenuTrigger>Open actions</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuSub open>
<DropdownMenuSubTrigger>More actions</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Archive</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>,
);
// When
const submenuContent = screen
.getByRole("menuitem", { name: "Archive" })
.closest("[data-slot='dropdown-menu-sub-content']");
// Then
expect(submenuContent).toHaveClass(
"origin-(--radix-dropdown-menu-content-transform-origin)",
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=closed]:animate-out",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
+2 -2
View File
@@ -42,7 +42,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
{...props}
@@ -227,7 +227,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none 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>
)}
+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: {
+87
View File
@@ -0,0 +1,87 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
describe("Popover", () => {
afterEach(() => {
document.body.innerHTML = "";
});
it("renders controlled content through the Radix Popover API", () => {
// Given
const portalContainer = document.createElement("div");
document.body.appendChild(portalContainer);
// When
render(
<Popover open>
<PopoverTrigger>Open filters</PopoverTrigger>
<PopoverContent container={portalContainer}>
Filter content
</PopoverContent>
</Popover>,
);
// Then
expect(screen.getByText("Filter content")).toBeVisible();
expect(screen.getByText("Filter content")).toHaveAttribute(
"data-slot",
"popover-content",
);
});
it("uses an intentional open and close motion contract", () => {
// Given
const portalContainer = document.createElement("div");
document.body.appendChild(portalContainer);
// When
render(
<Popover open>
<PopoverTrigger>Open filters</PopoverTrigger>
<PopoverContent container={portalContainer}>
Filter content
</PopoverContent>
</Popover>,
);
// Then
expect(screen.getByText("Filter content")).toHaveClass(
"origin-(--radix-popover-content-transform-origin)",
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:zoom-out-95",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
);
});
it("removes transform-heavy motion for reduced-motion users", () => {
// Given
const portalContainer = document.createElement("div");
document.body.appendChild(portalContainer);
// When
render(
<Popover open>
<PopoverTrigger>Open filters</PopoverTrigger>
<PopoverContent container={portalContainer}>
Filter content
</PopoverContent>
</Popover>,
);
// Then
expect(screen.getByText("Filter content")).toHaveClass(
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
+1 -1
View File
@@ -32,7 +32,7 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 pointer-events-auto z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 pointer-events-auto z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
{...props}
@@ -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,138 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { EnhancedMultiSelect } from "./enhanced-multi-select";
const options = [
{ value: "aws-prod", label: "Production AWS" },
{ value: "azure-dev", label: "Development Azure" },
];
beforeAll(() => {
global.ResizeObserver = class ResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
} as unknown as typeof ResizeObserver;
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
});
describe("EnhancedMultiSelect", () => {
it("uses visible trigger and chevron open-state motion", () => {
render(
<EnhancedMultiSelect
options={options}
onValueChange={() => {}}
placeholder="Select providers"
aria-label="Select providers"
/>,
);
const trigger = screen.getByRole("combobox", { name: /select providers/i });
const icon = trigger.querySelector("svg");
expect(trigger).toHaveClass(
"group",
"transition-[background-color,border-color,color,box-shadow]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(icon).toHaveClass(
"transition-transform",
"duration-200",
"ease-out",
"group-aria-expanded:rotate-180",
"motion-reduce:rotate-0",
"motion-reduce:transition-none",
);
});
it("animates item selection feedback and checkbox visibility", async () => {
const user = userEvent.setup();
render(
<EnhancedMultiSelect
options={options}
onValueChange={() => {}}
placeholder="Select providers"
aria-label="Select providers"
/>,
);
await user.click(
screen.getByRole("combobox", { name: /select providers/i }),
);
const option = screen.getByRole("option", { name: /production aws/i });
const checkbox = option.querySelector("[data-slot='checkbox']");
const checkboxIndicator = checkbox?.querySelector(
"[data-slot='checkbox-indicator']",
);
expect(option).toHaveClass(
"transition-colors",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(checkbox).toHaveClass(
"transition-colors",
"duration-200",
"ease-out",
"motion-reduce:transition-none",
);
expect(checkboxIndicator).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",
);
});
it("animates selected pills when values are added to the trigger", async () => {
const user = userEvent.setup();
const onValueChange = vi.fn();
render(
<EnhancedMultiSelect
options={options}
onValueChange={onValueChange}
placeholder="Select providers"
aria-label="Select providers"
/>,
);
await user.click(
screen.getByRole("combobox", { name: /select providers/i }),
);
await user.click(screen.getByRole("option", { name: /production aws/i }));
const pill = within(
screen.getByRole("combobox", { name: /select providers/i }),
)
.getByText("Production AWS")
.closest("[data-slot='enhanced-multiselect-pill']");
expect(onValueChange).toHaveBeenCalledWith(["aws-prod"]);
expect(pill).toHaveClass(
"animate-in",
"fade-in-0",
"zoom-in-95",
"duration-150",
"ease-out",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
@@ -183,7 +183,7 @@ export function EnhancedMultiSelect({
aria-controls={open ? listboxId : undefined}
aria-label={ariaLabel}
className={cn(
"border-border-input-primary bg-bg-input-primary text-text-neutral-primary data-[placeholder]:text-text-neutral-tertiary [&_svg:not([class*='text-'])]:text-text-neutral-tertiary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-input-primary active:bg-bg-input-primary focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex h-auto min-h-12 w-full items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 [&_svg]:pointer-events-auto",
"group border-border-input-primary bg-bg-input-primary text-text-neutral-primary data-[placeholder]:text-text-neutral-tertiary [&_svg:not([class*='text-'])]:text-text-neutral-tertiary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-input-primary active:bg-bg-input-primary focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex h-auto min-h-12 w-full items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm shadow-xs transition-[background-color,border-color,color,box-shadow] duration-150 ease-out outline-none focus-visible:ring-1 focus-visible:ring-offset-1 motion-reduce:transition-none [&_svg]:pointer-events-auto",
disabled && "cursor-not-allowed opacity-50",
className,
)}
@@ -200,7 +200,8 @@ export function EnhancedMultiSelect({
<Badge
key={value}
variant="tag"
className="m-1 cursor-default [&>svg]:pointer-events-auto"
data-slot="enhanced-multiselect-pill"
className="animate-in fade-in-0 zoom-in-95 m-1 cursor-default duration-150 ease-out motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none [&>svg]:pointer-events-auto"
>
<span className="cursor-default">{option.label}</span>
<span
@@ -224,7 +225,8 @@ export function EnhancedMultiSelect({
{selectedValues.length > maxCount && (
<Badge
variant="tag"
className="m-1 cursor-default [&>svg]:pointer-events-auto"
data-slot="enhanced-multiselect-pill"
className="animate-in fade-in-0 zoom-in-95 m-1 cursor-default duration-150 ease-out motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none [&>svg]:pointer-events-auto"
>
{`+ ${selectedValues.length - maxCount} more`}
<span
@@ -271,7 +273,7 @@ export function EnhancedMultiSelect({
className="flex h-full min-h-6"
/>
<ChevronDown
className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer"
className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer transition-transform duration-200 ease-out group-aria-expanded:rotate-180 motion-reduce:rotate-0 motion-reduce:transition-none"
aria-hidden="true"
/>
</div>
@@ -281,7 +283,7 @@ export function EnhancedMultiSelect({
<span className="text-text-neutral-tertiary mx-3 text-sm">
{placeholder}
</span>
<ChevronDown className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer" />
<ChevronDown className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer transition-transform duration-200 ease-out group-aria-expanded:rotate-180 motion-reduce:rotate-0 motion-reduce:transition-none" />
</div>
)}
</Button>
@@ -339,7 +341,7 @@ export function EnhancedMultiSelect({
aria-selected={isSelected}
aria-disabled={option.disabled}
className={cn(
"cursor-pointer",
"cursor-pointer transition-colors duration-150 ease-out motion-reduce:transition-none",
option.disabled && "cursor-not-allowed opacity-50",
)}
disabled={option.disabled}
@@ -83,6 +83,138 @@ describe("MultiSelect", () => {
).not.toBeInTheDocument();
});
it("uses visible trigger and chevron open-state motion", () => {
render(
<MultiSelect values={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
const trigger = screen.getByRole("combobox");
const icon = trigger.querySelector("svg");
expect(trigger).toHaveClass(
"transition-[background-color,border-color,color,box-shadow]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(icon).toHaveClass(
"transition-transform",
"duration-200",
"ease-out",
"group-aria-expanded:rotate-180",
"motion-reduce:rotate-0",
"motion-reduce:transition-none",
);
});
it("uses visible content open and close motion", async () => {
const user = userEvent.setup();
render(
<MultiSelect values={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
await user.click(screen.getByRole("combobox"));
const content = document.querySelector("[data-slot='multiselect-content']");
expect(content).toHaveClass(
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:zoom-out-95",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
it("animates item selection feedback and check visibility", async () => {
const user = userEvent.setup();
render(
<MultiSelect defaultValues={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
await user.click(screen.getByRole("combobox"));
const option = screen.getByRole("option", { name: /production aws/i });
const checkIcon = option.querySelector("svg");
expect(option).toHaveClass(
"transition-colors",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(checkIcon).toHaveClass(
"transition-[opacity,transform]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
});
it("animates selected pills when values are added to the trigger", async () => {
const user = userEvent.setup();
render(
<MultiSelect defaultValues={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("option", { name: /production aws/i }));
const pill = within(screen.getByRole("combobox"))
.getByText("Production AWS")
.closest("[data-selected-item]");
expect(pill).toHaveClass(
"animate-in",
"fade-in-0",
"zoom-in-95",
"duration-150",
"ease-out",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
it("filters items without crashing when search is enabled", async () => {
const user = userEvent.setup();
+6 -7
View File
@@ -153,15 +153,14 @@ export function MultiSelectTrigger({
data-slot="multiselect-trigger"
data-size={size}
className={cn(
"border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=multiselect-value]:line-clamp-1 *:data-[slot=multiselect-value]:flex *:data-[slot=multiselect-value]:items-center *:data-[slot=multiselect-value]:gap-2 dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
"group border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[background-color,border-color,color,box-shadow] duration-150 ease-out outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=multiselect-value]:line-clamp-1 *:data-[slot=multiselect-value]:flex *:data-[slot=multiselect-value]:items-center *:data-[slot=multiselect-value]:gap-2 motion-reduce:transition-none dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
className,
)}
>
{children}
<ChevronDown
className={cn(
"text-bg-button-secondary size-6 shrink-0 opacity-70 transition-transform duration-200",
open && "rotate-180",
"text-bg-button-secondary size-6 shrink-0 opacity-70 transition-transform duration-200 ease-out group-aria-expanded:rotate-180 motion-reduce:rotate-0 motion-reduce:transition-none",
)}
/>
</Button>
@@ -267,7 +266,7 @@ export function MultiSelectValue({
<Badge
variant="tag"
data-selected-item
className="group flex items-center gap-1.5 px-2 py-1 text-xs font-medium"
className="group animate-in fade-in-0 zoom-in-95 flex items-center gap-1.5 px-2 py-1 text-xs font-medium duration-150 ease-out motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none"
key={value}
onClick={
clickToRemove
@@ -415,7 +414,7 @@ export function MultiSelectItem({
keywords={keywords}
data-slot="multiselect-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary my-1 flex w-full cursor-pointer items-center justify-between gap-3 overflow-hidden rounded-lg px-4 py-3 text-sm outline-hidden select-none first:mt-0 last:mb-0 hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary my-1 flex w-full cursor-pointer items-center justify-between gap-3 overflow-hidden rounded-lg px-4 py-3 text-sm outline-hidden transition-colors duration-150 ease-out select-none first:mt-0 last:mb-0 hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 motion-reduce:transition-none dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
isSelected && "bg-slate-100 dark:bg-slate-800/50",
disabled && "cursor-not-allowed opacity-50 hover:bg-transparent",
className,
@@ -431,8 +430,8 @@ export function MultiSelectItem({
</span>
<CheckIcon
className={cn(
"text-bg-button-secondary size-5 shrink-0",
isSelected ? "opacity-100" : "opacity-0",
"text-bg-button-secondary size-5 shrink-0 transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none",
isSelected ? "scale-100 opacity-100" : "scale-95 opacity-0",
)}
/>
</CommandItem>
+222
View File
@@ -0,0 +1,222 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./select";
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
configurable: true,
value: vi.fn(() => false),
});
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
});
afterEach(() => {
vi.useRealTimers();
});
function renderTypeSelect({ open = false }: { open?: boolean } = {}) {
return render(
<Select defaultValue="all" open={open} onValueChange={() => {}}>
<SelectTrigger aria-label="All Types">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="scheduled">Scheduled</SelectItem>
</SelectContent>
</Select>,
);
}
describe("Select", () => {
it("renders an open dropdown with selectable options", () => {
// Given
renderTypeSelect({ open: true });
// When
const listbox = screen.getByRole("listbox");
// Then
expect(
within(listbox).getByRole("option", { name: "All Types" }),
).toBeVisible();
expect(
within(listbox).getByRole("option", { name: "Manual" }),
).toBeVisible();
expect(
within(listbox).getByRole("option", { name: "Scheduled" }),
).toBeVisible();
});
it("uses robust trigger transitions for hover, focus, and chevron state", () => {
// Given
renderTypeSelect();
// When
const trigger = screen.getByRole("combobox", { name: "All Types" });
const icon = trigger.querySelector("svg");
// Then
expect(trigger).toHaveClass(
"transition-[background-color,border-color,color,box-shadow]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(icon).toHaveClass(
"transition-[rotate]",
"duration-200",
"ease-out",
"group-data-[state=open]:rotate-180",
"motion-reduce:rotate-0",
"motion-reduce:transition-none",
);
});
it("preserves the Radix open data-state model", () => {
// Given
renderTypeSelect({ open: true });
// When
const listbox = screen.getByRole("listbox");
const content = listbox.closest("[data-slot='select-content']");
// Then
expect(content).toHaveAttribute("data-state", "open");
});
it("keeps content mounted briefly with a closing state", async () => {
// Given
const { rerender } = renderTypeSelect({ open: true });
expect(screen.getByRole("listbox")).toBeVisible();
// When
rerender(
<Select defaultValue="all" open={false} onValueChange={() => {}}>
<SelectTrigger aria-label="All Types">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="scheduled">Scheduled</SelectItem>
</SelectContent>
</Select>,
);
// Then
const content = await waitFor(() =>
screen.getByRole("listbox").closest("[data-slot='select-content']"),
);
const trigger = document.querySelector("[data-slot='select-trigger']");
expect(content).toHaveAttribute("data-closing", "true");
expect(trigger).toHaveAttribute("data-closing", "true");
await waitFor(() => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
it("keeps uncontrolled content mounted briefly after selecting an option", async () => {
// Given
const user = userEvent.setup();
render(
<Select defaultOpen defaultValue="all" onValueChange={() => {}}>
<SelectTrigger aria-label="All Types">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="scheduled">Scheduled</SelectItem>
</SelectContent>
</Select>,
);
expect(screen.getByRole("listbox")).toBeVisible();
// When
await user.click(screen.getByRole("option", { name: "Manual" }));
// Then
const content = await waitFor(() =>
screen.getByRole("listbox").closest("[data-slot='select-content']"),
);
const trigger = document.querySelector("[data-slot='select-trigger']");
expect(content).toHaveAttribute("data-closing", "true");
expect(content).toHaveClass(
"animate-out",
"fade-out-0",
"zoom-out-95",
"pointer-events-none",
"duration-100",
"ease-in",
);
expect(content).not.toHaveClass(
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
);
expect(trigger).toHaveAttribute("data-closing", "true");
});
it("uses explicit open and close motion classes", () => {
// Given
renderTypeSelect({ open: true });
// When
const content = screen
.getByRole("listbox")
.closest("[data-slot='select-content']");
// Then
expect(content).toHaveClass(
"data-[state=open]:animate-in",
"data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0",
"data-[state=closed]:fade-out-0",
"data-[state=open]:zoom-in-95",
"data-[state=closed]:zoom-out-95",
"duration-200",
"ease-out",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
);
});
it("removes transform-heavy dropdown motion for reduced motion", () => {
// Given
renderTypeSelect({ open: true });
// When
const content = screen
.getByRole("listbox")
.closest("[data-slot='select-content']");
// Then
expect(content).toHaveClass(
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
+92 -9
View File
@@ -2,20 +2,90 @@
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { ComponentProps, type WheelEvent } from "react";
import {
ComponentProps,
createContext,
useContext,
useEffect,
useRef,
useState,
type WheelEvent,
} from "react";
import { cn } from "@/lib/utils";
const SELECT_CLOSE_ANIMATION_MS = 100;
interface SelectMotionContextValue {
isClosing: boolean;
}
const SelectMotionContext = createContext<SelectMotionContextValue>({
isClosing: false,
});
const stopWheelPropagation = (event: WheelEvent<HTMLElement>) => {
event.stopPropagation();
};
function Select({
allowDeselect = false,
open,
defaultOpen,
onOpenChange,
...props
}: ComponentProps<typeof SelectPrimitive.Root> & {
allowDeselect?: boolean;
}) {
const isControlled = open !== undefined;
const [uncontrolledOpen, setUncontrolledOpen] = useState(
defaultOpen ?? false,
);
const requestedOpen = isControlled ? open : uncontrolledOpen;
const [renderedOpen, setRenderedOpen] = useState(requestedOpen);
const [isClosing, setIsClosing] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
if (requestedOpen) {
setIsClosing(false);
setRenderedOpen(true);
return;
}
if (!renderedOpen) {
setIsClosing(false);
return;
}
setIsClosing(true);
closeTimerRef.current = setTimeout(() => {
setRenderedOpen(false);
setIsClosing(false);
closeTimerRef.current = null;
}, SELECT_CLOSE_ANIMATION_MS);
return () => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
};
}, [requestedOpen, renderedOpen]);
const handleOpenChange = (nextOpen: boolean) => {
if (!isControlled) {
setUncontrolledOpen(nextOpen);
}
onOpenChange?.(nextOpen);
};
const handleValueChange = (nextValue: string) => {
if (allowDeselect && props.value === nextValue) {
// Single-select with deselect
@@ -27,11 +97,15 @@ function Select({
};
return (
<SelectPrimitive.Root
data-slot="select"
{...props}
onValueChange={handleValueChange}
/>
<SelectMotionContext.Provider value={{ isClosing }}>
<SelectPrimitive.Root
data-slot="select"
{...props}
open={renderedOpen}
onOpenChange={handleOpenChange}
onValueChange={handleValueChange}
/>
</SelectMotionContext.Provider>
);
}
@@ -57,12 +131,15 @@ function SelectTrigger({
size?: "sm" | "default";
iconSize?: "sm" | "default";
}) {
const { isClosing } = useContext(SelectMotionContext);
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
data-closing={isClosing ? "true" : undefined}
className={cn(
"group border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 has-[>svg]:px-3 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
"group border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[background-color,border-color,color,box-shadow] duration-150 ease-out outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 has-[>svg]:px-3 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 motion-reduce:transition-none dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
className,
)}
{...props}
@@ -71,7 +148,8 @@ function SelectTrigger({
<SelectPrimitive.Icon asChild>
<ChevronDownIcon
className={cn(
"text-bg-button-secondary shrink-0 opacity-70 transition-transform duration-200 group-data-[state=open]:rotate-180",
"text-bg-button-secondary shrink-0 opacity-70 transition-[rotate] duration-200 ease-out motion-reduce:rotate-0 motion-reduce:transition-none",
isClosing ? "rotate-0" : "group-data-[state=open]:rotate-180",
iconSize === "sm" ? "size-4" : "size-6",
)}
aria-hidden="true"
@@ -92,6 +170,7 @@ function SelectContent({
}: ComponentProps<typeof SelectPrimitive.Content> & {
width?: "default" | "wide";
}) {
const { isClosing } = useContext(SelectMotionContext);
const widthClasses =
width === "wide"
? "w-[min(max(var(--radix-select-trigger-width),24rem),calc(100vw-2rem))] max-w-[32rem]"
@@ -101,8 +180,12 @@ function SelectContent({
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-closing={isClosing ? "true" : undefined}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-border-input-primary bg-bg-input-primary relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-lg border",
"bg-popover text-popover-foreground data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-border-input-primary bg-bg-input-primary relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-lg border duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
isClosing
? "animate-out fade-out-0 zoom-out-95 pointer-events-none duration-100 ease-in"
: "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
widthClasses,
+35
View File
@@ -0,0 +1,35 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
describe("Tabs", () => {
it("animates tab content when switching between tabs", async () => {
// Given - A tabs group with two content panels
const user = userEvent.setup();
render(
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="events">Events</TabsTrigger>
</TabsList>
<TabsContent value="overview">Overview content</TabsContent>
<TabsContent value="events">Events content</TabsContent>
</Tabs>,
);
// When - The user switches to another tab
await user.click(screen.getByRole("tab", { name: /events/i }));
const eventsPanel = screen.getByText("Events content");
// Then - The newly active content keeps the motion-ready content element
expect(eventsPanel).toHaveAttribute("data-slot", "tabs-content");
expect(eventsPanel).toHaveClass(
"will-change-transform",
"motion-reduce:transform-none",
);
expect(eventsPanel).toHaveAttribute("data-state", "active");
});
});
+20 -6
View File
@@ -1,6 +1,7 @@
"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { motion, useReducedMotion } from "framer-motion";
import type { ComponentProps, ReactNode } from "react";
import {
@@ -30,7 +31,7 @@ const TRIGGER_STYLES = {
* Content component styles
*/
const CONTENT_STYLES =
"mt-2 focus-visible:rounded-md focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-ring/50" as const;
"mt-2 will-change-transform motion-reduce:transform-none focus-visible:rounded-md focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-ring/50" as const;
/**
* Build trigger className by combining style parts
@@ -122,14 +123,27 @@ function TabsTrigger({
function TabsContent({
className,
children,
...props
}: ComponentProps<typeof TabsPrimitive.Content>) {
const shouldReduceMotion = useReducedMotion();
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn(CONTENT_STYLES, className)}
{...props}
/>
<TabsPrimitive.Content asChild {...props}>
<motion.div
data-slot="tabs-content"
initial={shouldReduceMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={
shouldReduceMotion
? { duration: 0 }
: { duration: 0.2, ease: "easeOut" }
}
className={cn(CONTENT_STYLES, className)}
>
{children}
</motion.div>
</TabsPrimitive.Content>
);
}
@@ -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: {
+66
View File
@@ -0,0 +1,66 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
function renderOpenTooltip() {
return render(
<Tooltip open>
<TooltipTrigger>Copy ARN</TooltipTrigger>
<TooltipContent>Copy resource identifier</TooltipContent>
</Tooltip>,
);
}
describe("Tooltip", () => {
it("renders controlled content through the Radix Tooltip API", () => {
// Given
renderOpenTooltip();
// When
const tooltip = document.querySelector("[data-slot='tooltip-content']");
// Then
expect(screen.getByRole("tooltip")).toBeVisible();
expect(tooltip).toBeVisible();
expect(tooltip).toHaveTextContent("Copy resource identifier");
});
it("uses an intentional open and close motion contract", () => {
// Given
renderOpenTooltip();
// When
const tooltip = document.querySelector("[data-slot='tooltip-content']");
// Then
expect(tooltip).toHaveClass(
"origin-(--radix-tooltip-content-transform-origin)",
"animate-in",
"fade-in-0",
"zoom-in-95",
"duration-150",
"ease-out",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:zoom-out-95",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
);
});
it("removes transform-heavy tooltip motion for reduced-motion users", () => {
// Given
renderOpenTooltip();
// When
const tooltip = document.querySelector("[data-slot='tooltip-content']");
// Then
expect(tooltip).toHaveClass(
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
@@ -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",
);
});
});
@@ -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)}
+3 -1
View File
@@ -329,7 +329,9 @@ export function DataTable<TData, TValue>({
</TableCell>
))}
</TableRow>
{renderAfterRow?.(row)}
<AnimatePresence initial={false}>
{renderAfterRow?.(row)}
</AnimatePresence>
</Fragment>
),
)