From 8fb59682d59878bda0558d872f90dbe856d13ed3 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 5 Jun 2026 14:44:25 +0200 Subject: [PATCH] feat(ui): add multiselect selection microinteractions - Animate selected pills and item feedback in multiselect components - Add checkbox state transitions for provider group selections - Cover shared selection motion with focused unit tests --- .../shadcn/checkbox/checkbox.test.tsx | 52 +++++++ ui/components/shadcn/checkbox/checkbox.tsx | 5 +- .../select/enhanced-multi-select.test.tsx | 138 ++++++++++++++++++ .../shadcn/select/enhanced-multi-select.tsx | 14 +- .../shadcn/select/multiselect.test.tsx | 132 +++++++++++++++++ ui/components/shadcn/select/multiselect.tsx | 13 +- 6 files changed, 339 insertions(+), 15 deletions(-) create mode 100644 ui/components/shadcn/checkbox/checkbox.test.tsx create mode 100644 ui/components/shadcn/select/enhanced-multi-select.test.tsx diff --git a/ui/components/shadcn/checkbox/checkbox.test.tsx b/ui/components/shadcn/checkbox/checkbox.test.tsx new file mode 100644 index 0000000000..b5bd06b44b --- /dev/null +++ b/ui/components/shadcn/checkbox/checkbox.test.tsx @@ -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 ( + 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(); + + 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", + ); + }); +}); diff --git a/ui/components/shadcn/checkbox/checkbox.tsx b/ui/components/shadcn/checkbox/checkbox.tsx index 6a577c18e2..1ff7dd5f2d 100644 --- a/ui/components/shadcn/checkbox/checkbox.tsx +++ b/ui/components/shadcn/checkbox/checkbox.tsx @@ -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} > {indeterminate || checked === "indeterminate" ? ( diff --git a/ui/components/shadcn/select/enhanced-multi-select.test.tsx b/ui/components/shadcn/select/enhanced-multi-select.test.tsx new file mode 100644 index 0000000000..d09b6ddd41 --- /dev/null +++ b/ui/components/shadcn/select/enhanced-multi-select.test.tsx @@ -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( + {}} + 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( + {}} + 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( + , + ); + + 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", + ); + }); +}); diff --git a/ui/components/shadcn/select/enhanced-multi-select.tsx b/ui/components/shadcn/select/enhanced-multi-select.tsx index 0c6e403167..e5bed57d3c 100644 --- a/ui/components/shadcn/select/enhanced-multi-select.tsx +++ b/ui/components/shadcn/select/enhanced-multi-select.tsx @@ -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({ {option.label} maxCount && ( {`+ ${selectedValues.length - maxCount} more`}