mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-09 21:04:53 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4051d52d9 | |||
| adbe67d2f3 | |||
| 65c0425729 | |||
| 5828cce644 | |||
| 87bd2e78a1 | |||
| ccae4afe68 | |||
| 0e2bb99f02 | |||
| 8fb59682d5 | |||
| 799f062ee0 | |||
| 51945f5cc5 | |||
| b93e3f9d04 | |||
| ef4d05a782 | |||
| 7185e539c8 |
@@ -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,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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "./collapsible";
|
||||
|
||||
describe("Collapsible", () => {
|
||||
it("uses an intentional open and close motion contract", () => {
|
||||
// Given
|
||||
render(
|
||||
<Collapsible open>
|
||||
<CollapsibleTrigger>Toggle details</CollapsibleTrigger>
|
||||
<CollapsibleContent>Expandable content</CollapsibleContent>
|
||||
</Collapsible>,
|
||||
);
|
||||
|
||||
// When
|
||||
const content = screen.getByText("Expandable content");
|
||||
|
||||
// Then
|
||||
expect(content).toHaveAttribute("data-slot", "collapsible-content");
|
||||
expect(content).toHaveClass(
|
||||
"overflow-hidden",
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"data-[state=open]:animate-in",
|
||||
"data-[state=open]:fade-in-0",
|
||||
"data-[state=open]:slide-in-from-top-1",
|
||||
"data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0",
|
||||
"data-[state=closed]:slide-out-to-top-1",
|
||||
"data-[state=closed]:duration-150",
|
||||
"data-[state=closed]:ease-in",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes transform-heavy motion for reduced-motion users", () => {
|
||||
// Given
|
||||
render(
|
||||
<Collapsible open>
|
||||
<CollapsibleTrigger>Toggle details</CollapsibleTrigger>
|
||||
<CollapsibleContent>Expandable content</CollapsibleContent>
|
||||
</Collapsible>,
|
||||
);
|
||||
|
||||
// When
|
||||
const content = screen.getByText("Expandable content");
|
||||
|
||||
// Then
|
||||
expect(content).toHaveClass(
|
||||
"motion-reduce:animate-none",
|
||||
"motion-reduce:transform-none",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
@@ -20,11 +22,20 @@ function CollapsibleTrigger({
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
className={cn(
|
||||
"overflow-hidden duration-200 ease-out",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1",
|
||||
"data-[state=closed]:duration-150 data-[state=closed]:ease-in",
|
||||
"motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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()}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { ComponentProps, forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const textareaVariants = cva(
|
||||
"flex w-full rounded-lg border text-sm transition-all outline-none resize-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex w-full rounded-lg border text-sm transition-[background-color,border-color,box-shadow,color] duration-150 ease-out outline-none motion-reduce:transition-none resize-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -0,0 +1,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,
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
@@ -329,7 +329,9 @@ export function DataTable<TData, TValue>({
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{renderAfterRow?.(row)}
|
||||
<AnimatePresence initial={false}>
|
||||
{renderAfterRow?.(row)}
|
||||
</AnimatePresence>
|
||||
</Fragment>
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user