mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-05 03:12:14 +00:00
fix(ui): animate component internal states
- Add motion feedback for dropdown checkbox and radio indicators - Animate select and combobox selected indicators - Add dialog close affordance motion
This commit is contained in:
@@ -68,6 +68,39 @@ describe("Combobox", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("animates option rows and selected check indicators as internal feedback", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Combobox value="aws" options={options} placeholder="Select provider" />,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: /aws/i }));
|
||||
const selectedItem = screen.getByRole("option", { name: /aws/i });
|
||||
const unselectedItem = screen.getByRole("option", { name: /azure/i });
|
||||
const selectedCheck = selectedItem.querySelector("svg");
|
||||
const unselectedCheck = unselectedItem.querySelector("svg");
|
||||
|
||||
// Then
|
||||
expect(selectedItem).toHaveClass(
|
||||
"transition-[background-color,color]",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(selectedCheck).toHaveClass(
|
||||
"transition-[opacity,scale]",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"scale-100",
|
||||
"opacity-100",
|
||||
"motion-reduce:scale-100",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(unselectedCheck).toHaveClass("scale-95", "opacity-0");
|
||||
});
|
||||
|
||||
it("opens with the shared Popover content motion contract", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -152,7 +152,7 @@ export function Combobox({
|
||||
value={selectedOption.value}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4 opacity-100" />
|
||||
<Check className="mr-2 h-4 w-4 scale-100 opacity-100 transition-[opacity,scale] duration-150 ease-out motion-reduce:scale-100 motion-reduce:transition-none" />
|
||||
{selectedOption.label}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
@@ -177,10 +177,10 @@ export function Combobox({
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
"mr-2 h-4 w-4 transition-[opacity,scale] duration-150 ease-out motion-reduce:scale-100 motion-reduce:transition-none",
|
||||
value === option.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
? "scale-100 opacity-100"
|
||||
: "scale-95 opacity-0",
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
@@ -205,8 +205,10 @@ export function Combobox({
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === option.value ? "opacity-100" : "opacity-0",
|
||||
"mr-2 h-4 w-4 transition-[opacity,scale] duration-150 ease-out motion-reduce:scale-100 motion-reduce:transition-none",
|
||||
value === option.value
|
||||
? "scale-100 opacity-100"
|
||||
: "scale-95 opacity-0",
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
|
||||
@@ -152,7 +152,7 @@ function CommandItem({
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors select-none",
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-[background-color,color] duration-150 ease-out select-none motion-reduce:transition-none",
|
||||
"hover:bg-bg-neutral-tertiary hover:text-text-neutral-primary",
|
||||
"data-[selected=true]:bg-bg-neutral-tertiary data-[selected=true]:text-text-neutral-primary",
|
||||
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
|
||||
|
||||
@@ -57,6 +57,33 @@ describe("Dialog", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("animates the close affordance as internal dialog feedback", () => {
|
||||
// Given
|
||||
renderOpenDialog();
|
||||
|
||||
// When
|
||||
const closeButton = screen.getByRole("button", { name: /close/i });
|
||||
const closeIcon = closeButton.querySelector("svg");
|
||||
|
||||
// Then
|
||||
expect(closeButton).toHaveClass(
|
||||
"transition-[opacity,scale,background-color,color]",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"active:scale-95",
|
||||
"motion-reduce:active:scale-100",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(closeIcon).toHaveClass(
|
||||
"transition-transform",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"group-hover/dialog-close:rotate-90",
|
||||
"motion-reduce:rotate-0",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses an intentional content motion contract", () => {
|
||||
// Given
|
||||
renderOpenDialog();
|
||||
|
||||
@@ -70,9 +70,9 @@ function DialogContent({
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground group/dialog-close absolute top-4 right-4 rounded-xs opacity-70 transition-[opacity,scale,background-color,color] duration-150 ease-out hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden active:scale-95 disabled:pointer-events-none motion-reduce:transition-none motion-reduce:active:scale-100 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<XIcon className="transition-transform duration-150 ease-out group-hover/dialog-close:rotate-90 motion-reduce:rotate-0 motion-reduce:transition-none" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
|
||||
@@ -3,8 +3,11 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
@@ -76,6 +79,108 @@ describe("DropdownMenu", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("animates checkbox and radio item indicators as internal state feedback", () => {
|
||||
// Given
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>Open filters</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuCheckboxItem checked>
|
||||
Show muted
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuRadioGroup value="failed">
|
||||
<DropdownMenuRadioItem value="failed">Failed</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
|
||||
// When
|
||||
const checkboxItem = screen.getByRole("menuitemcheckbox", {
|
||||
name: "Show muted",
|
||||
});
|
||||
const radioItem = screen.getByRole("menuitemradio", { name: "Failed" });
|
||||
const checkboxIndicator = checkboxItem.querySelector("span");
|
||||
const checkboxIcon = checkboxItem.querySelector("svg");
|
||||
const radioIndicator = radioItem.querySelector("span");
|
||||
const radioIcon = radioItem.querySelector("svg");
|
||||
|
||||
// Then
|
||||
expect(checkboxItem).toHaveClass(
|
||||
"transition-colors",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(checkboxIndicator).toHaveClass(
|
||||
"transition-[opacity,scale]",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"motion-reduce:scale-100",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(checkboxIcon).toHaveClass(
|
||||
"animate-in",
|
||||
"zoom-in-75",
|
||||
"motion-reduce:animate-none",
|
||||
);
|
||||
expect(radioItem).toHaveClass(
|
||||
"transition-colors",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(radioIndicator).toHaveClass(
|
||||
"transition-[opacity,scale]",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"motion-reduce:scale-100",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(radioIcon).toHaveClass(
|
||||
"animate-in",
|
||||
"zoom-in-75",
|
||||
"motion-reduce:animate-none",
|
||||
);
|
||||
});
|
||||
|
||||
it("animates submenu chevrons as internal open-state feedback", () => {
|
||||
// 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 subTrigger = screen.getByRole("menuitem", { name: "More actions" });
|
||||
const chevron = subTrigger.querySelector("svg");
|
||||
|
||||
// Then
|
||||
expect(subTrigger).toHaveClass(
|
||||
"transition-colors",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(chevron).toHaveClass(
|
||||
"transition-[translate,opacity]",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"group-data-[state=open]:translate-x-0.5",
|
||||
"motion-reduce:translate-x-0",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the same motion contract to submenu content", () => {
|
||||
// Given
|
||||
render(
|
||||
|
||||
@@ -92,15 +92,15 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors duration-150 ease-out select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 motion-reduce:transition-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center transition-[opacity,scale] duration-150 ease-out motion-reduce:scale-100 motion-reduce:transition-none">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
<CheckIcon className="animate-in zoom-in-75 size-4 duration-150 ease-out motion-reduce:animate-none" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@@ -128,14 +128,14 @@ function DropdownMenuRadioItem({
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors duration-150 ease-out select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 motion-reduce:transition-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center transition-[opacity,scale] duration-150 ease-out motion-reduce:scale-100 motion-reduce:transition-none">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
<CircleIcon className="animate-in zoom-in-75 size-2 fill-current duration-150 ease-out motion-reduce:animate-none" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@@ -208,13 +208,13 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground group flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors duration-150 ease-out select-none data-[inset]:pl-8 motion-reduce:transition-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
<ChevronRightIcon className="ml-auto size-4 transition-[translate,opacity] duration-150 ease-out group-data-[state=open]:translate-x-0.5 group-data-[state=open]:opacity-100 motion-reduce:translate-x-0 motion-reduce:transition-none" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,6 +179,31 @@ describe("Select", () => {
|
||||
expect(trigger).toHaveAttribute("data-closing", "true");
|
||||
});
|
||||
|
||||
it("animates option rows and selected check indicators as internal feedback", () => {
|
||||
// Given
|
||||
renderTypeSelect({ open: true });
|
||||
|
||||
// When
|
||||
const selectedOption = screen.getByRole("option", { name: "All Types" });
|
||||
const checkIcon = selectedOption.querySelector("svg");
|
||||
|
||||
// Then
|
||||
expect(selectedOption).toHaveClass(
|
||||
"transition-colors",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(checkIcon).toHaveClass(
|
||||
"animate-in",
|
||||
"fade-in-0",
|
||||
"zoom-in-75",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"motion-reduce:animate-none",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses explicit open and close motion classes", () => {
|
||||
// Given
|
||||
renderTypeSelect({ open: true });
|
||||
|
||||
@@ -245,7 +245,7 @@ function SelectItem({
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-3 pr-12 pl-4 text-sm outline-hidden select-none 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 relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-3 pr-12 pl-4 text-sm outline-hidden transition-colors duration-150 ease-out select-none 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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -256,7 +256,7 @@ function SelectItem({
|
||||
</span>
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator asChild>
|
||||
<CheckIcon className="text-bg-button-secondary absolute right-4 size-5" />
|
||||
<CheckIcon className="text-bg-button-secondary animate-in fade-in-0 zoom-in-75 absolute right-4 size-5 duration-150 ease-out motion-reduce:animate-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user