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
This commit is contained in:
Alan Buscaglia committed 2026-06-05 14:44:25 +02:00
1 parent 799f062ee0
commit 8fb59682d5
6 files changed
+339 -15

No files matched your search

@@ -0,0 +1,52 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { Checkbox } from "./checkbox";
function ControlledCheckbox() {
const [checked, setChecked] = useState(false);
return (
<Checkbox
aria-label="Select provider"
checked={checked}
onCheckedChange={(value) => setChecked(value === true)}
/>
);
}
describe("Checkbox", () => {
it("animates the background and check mark as one state change", async () => {
// Given - A controlled checkbox in the unchecked state
const user = userEvent.setup();
render(<ControlledCheckbox />);
const checkbox = screen.getByRole("checkbox", { name: /select provider/i });
const indicator = checkbox.querySelector(
"[data-slot='checkbox-indicator']",
);
// When - The user checks the checkbox
await user.click(checkbox);
// Then - The background and check mark transitions use the same timing
expect(checkbox).toHaveClass(
"transition-colors",
"duration-200",
"ease-out",
"motion-reduce:transition-none",
);
expect(indicator).toHaveClass(
"transition-[opacity,transform]",
"duration-200",
"ease-out",
"data-[state=checked]:scale-100",
"data-[state=checked]:opacity-100",
"data-[state=unchecked]:scale-75",
"data-[state=unchecked]:opacity-0",
"motion-reduce:transition-none",
);
});
});
+3 -2
View File
@@ -41,7 +41,7 @@ function Checkbox({
checked={indeterminate ? "indeterminate" : checked}
className={cn(
// Base styles
"peer shrink-0 rounded-sm border transition-all outline-none",
"peer shrink-0 rounded-sm border transition-colors duration-200 ease-out outline-none motion-reduce:transition-none",
sizeStyles.root,
// Default state
"bg-bg-input-primary border-border-input-primary shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]",
@@ -58,8 +58,9 @@ function Checkbox({
{...props}
>
<CheckboxPrimitive.Indicator
forceMount
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
className="grid place-content-center text-current transition-[opacity,transform] duration-200 ease-out data-[state=checked]:scale-100 data-[state=checked]:opacity-100 data-[state=indeterminate]:scale-100 data-[state=indeterminate]:opacity-100 data-[state=unchecked]:scale-75 data-[state=unchecked]:opacity-0 motion-reduce:scale-100 motion-reduce:transition-none"
>
{indeterminate || checked === "indeterminate" ? (
<MinusIcon className={sizeStyles.icon} />
@@ -0,0 +1,138 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { EnhancedMultiSelect } from "./enhanced-multi-select";
const options = [
{ value: "aws-prod", label: "Production AWS" },
{ value: "azure-dev", label: "Development Azure" },
];
beforeAll(() => {
global.ResizeObserver = class ResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
} as unknown as typeof ResizeObserver;
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
});
describe("EnhancedMultiSelect", () => {
it("uses visible trigger and chevron open-state motion", () => {
render(
<EnhancedMultiSelect
options={options}
onValueChange={() => {}}
placeholder="Select providers"
aria-label="Select providers"
/>,
);
const trigger = screen.getByRole("combobox", { name: /select providers/i });
const icon = trigger.querySelector("svg");
expect(trigger).toHaveClass(
"group",
"transition-[background-color,border-color,color,box-shadow]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(icon).toHaveClass(
"transition-transform",
"duration-200",
"ease-out",
"group-aria-expanded:rotate-180",
"motion-reduce:rotate-0",
"motion-reduce:transition-none",
);
});
it("animates item selection feedback and checkbox visibility", async () => {
const user = userEvent.setup();
render(
<EnhancedMultiSelect
options={options}
onValueChange={() => {}}
placeholder="Select providers"
aria-label="Select providers"
/>,
);
await user.click(
screen.getByRole("combobox", { name: /select providers/i }),
);
const option = screen.getByRole("option", { name: /production aws/i });
const checkbox = option.querySelector("[data-slot='checkbox']");
const checkboxIndicator = checkbox?.querySelector(
"[data-slot='checkbox-indicator']",
);
expect(option).toHaveClass(
"transition-colors",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(checkbox).toHaveClass(
"transition-colors",
"duration-200",
"ease-out",
"motion-reduce:transition-none",
);
expect(checkboxIndicator).toHaveClass(
"transition-[opacity,transform]",
"duration-200",
"ease-out",
"data-[state=checked]:scale-100",
"data-[state=checked]:opacity-100",
"data-[state=unchecked]:scale-75",
"data-[state=unchecked]:opacity-0",
"motion-reduce:transition-none",
);
});
it("animates selected pills when values are added to the trigger", async () => {
const user = userEvent.setup();
const onValueChange = vi.fn();
render(
<EnhancedMultiSelect
options={options}
onValueChange={onValueChange}
placeholder="Select providers"
aria-label="Select providers"
/>,
);
await user.click(
screen.getByRole("combobox", { name: /select providers/i }),
);
await user.click(screen.getByRole("option", { name: /production aws/i }));
const pill = within(
screen.getByRole("combobox", { name: /select providers/i }),
)
.getByText("Production AWS")
.closest("[data-slot='enhanced-multiselect-pill']");
expect(onValueChange).toHaveBeenCalledWith(["aws-prod"]);
expect(pill).toHaveClass(
"animate-in",
"fade-in-0",
"zoom-in-95",
"duration-150",
"ease-out",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
@@ -183,7 +183,7 @@ export function EnhancedMultiSelect({
aria-controls={open ? listboxId : undefined}
aria-label={ariaLabel}
className={cn(
"border-border-input-primary bg-bg-input-primary text-text-neutral-primary data-[placeholder]:text-text-neutral-tertiary [&_svg:not([class*='text-'])]:text-text-neutral-tertiary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-input-primary active:bg-bg-input-primary focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex h-auto min-h-12 w-full items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 [&_svg]:pointer-events-auto",
"group border-border-input-primary bg-bg-input-primary text-text-neutral-primary data-[placeholder]:text-text-neutral-tertiary [&_svg:not([class*='text-'])]:text-text-neutral-tertiary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-input-primary active:bg-bg-input-primary focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex h-auto min-h-12 w-full items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm shadow-xs transition-[background-color,border-color,color,box-shadow] duration-150 ease-out outline-none focus-visible:ring-1 focus-visible:ring-offset-1 motion-reduce:transition-none [&_svg]:pointer-events-auto",
disabled && "cursor-not-allowed opacity-50",
className,
)}
@@ -200,7 +200,8 @@ export function EnhancedMultiSelect({
<Badge
key={value}
variant="tag"
className="m-1 cursor-default [&>svg]:pointer-events-auto"
data-slot="enhanced-multiselect-pill"
className="animate-in fade-in-0 zoom-in-95 m-1 cursor-default duration-150 ease-out motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none [&>svg]:pointer-events-auto"
>
<span className="cursor-default">{option.label}</span>
<span
@@ -224,7 +225,8 @@ export function EnhancedMultiSelect({
{selectedValues.length > maxCount && (
<Badge
variant="tag"
className="m-1 cursor-default [&>svg]:pointer-events-auto"
data-slot="enhanced-multiselect-pill"
className="animate-in fade-in-0 zoom-in-95 m-1 cursor-default duration-150 ease-out motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none [&>svg]:pointer-events-auto"
>
{`+ ${selectedValues.length - maxCount} more`}
<span
@@ -271,7 +273,7 @@ export function EnhancedMultiSelect({
className="flex h-full min-h-6"
/>
<ChevronDown
className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer"
className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer transition-transform duration-200 ease-out group-aria-expanded:rotate-180 motion-reduce:rotate-0 motion-reduce:transition-none"
aria-hidden="true"
/>
</div>
@@ -281,7 +283,7 @@ export function EnhancedMultiSelect({
<span className="text-text-neutral-tertiary mx-3 text-sm">
{placeholder}
</span>
<ChevronDown className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer" />
<ChevronDown className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer transition-transform duration-200 ease-out group-aria-expanded:rotate-180 motion-reduce:rotate-0 motion-reduce:transition-none" />
</div>
)}
</Button>
@@ -339,7 +341,7 @@ export function EnhancedMultiSelect({
aria-selected={isSelected}
aria-disabled={option.disabled}
className={cn(
"cursor-pointer",
"cursor-pointer transition-colors duration-150 ease-out motion-reduce:transition-none",
option.disabled && "cursor-not-allowed opacity-50",
)}
disabled={option.disabled}
@@ -83,6 +83,138 @@ describe("MultiSelect", () => {
).not.toBeInTheDocument();
});
it("uses visible trigger and chevron open-state motion", () => {
render(
<MultiSelect values={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
const trigger = screen.getByRole("combobox");
const icon = trigger.querySelector("svg");
expect(trigger).toHaveClass(
"transition-[background-color,border-color,color,box-shadow]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(icon).toHaveClass(
"transition-transform",
"duration-200",
"ease-out",
"group-aria-expanded:rotate-180",
"motion-reduce:rotate-0",
"motion-reduce:transition-none",
);
});
it("uses visible content open and close motion", async () => {
const user = userEvent.setup();
render(
<MultiSelect values={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
await user.click(screen.getByRole("combobox"));
const content = document.querySelector("[data-slot='multiselect-content']");
expect(content).toHaveClass(
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:zoom-out-95",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
it("animates item selection feedback and check visibility", async () => {
const user = userEvent.setup();
render(
<MultiSelect defaultValues={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
await user.click(screen.getByRole("combobox"));
const option = screen.getByRole("option", { name: /production aws/i });
const checkIcon = option.querySelector("svg");
expect(option).toHaveClass(
"transition-colors",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(checkIcon).toHaveClass(
"transition-[opacity,transform]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
});
it("animates selected pills when values are added to the trigger", async () => {
const user = userEvent.setup();
render(
<MultiSelect defaultValues={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("option", { name: /production aws/i }));
const pill = within(screen.getByRole("combobox"))
.getByText("Production AWS")
.closest("[data-selected-item]");
expect(pill).toHaveClass(
"animate-in",
"fade-in-0",
"zoom-in-95",
"duration-150",
"ease-out",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
it("filters items without crashing when search is enabled", async () => {
const user = userEvent.setup();
+6 -7
View File
@@ -153,15 +153,14 @@ export function MultiSelectTrigger({
data-slot="multiselect-trigger"
data-size={size}
className={cn(
"border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=multiselect-value]:line-clamp-1 *:data-[slot=multiselect-value]:flex *:data-[slot=multiselect-value]:items-center *:data-[slot=multiselect-value]:gap-2 dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
"group border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[background-color,border-color,color,box-shadow] duration-150 ease-out outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=multiselect-value]:line-clamp-1 *:data-[slot=multiselect-value]:flex *:data-[slot=multiselect-value]:items-center *:data-[slot=multiselect-value]:gap-2 motion-reduce:transition-none dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
className,
)}
>
{children}
<ChevronDown
className={cn(
"text-bg-button-secondary size-6 shrink-0 opacity-70 transition-transform duration-200",
open && "rotate-180",
"text-bg-button-secondary size-6 shrink-0 opacity-70 transition-transform duration-200 ease-out group-aria-expanded:rotate-180 motion-reduce:rotate-0 motion-reduce:transition-none",
)}
/>
</Button>
@@ -267,7 +266,7 @@ export function MultiSelectValue({
<Badge
variant="tag"
data-selected-item
className="group flex items-center gap-1.5 px-2 py-1 text-xs font-medium"
className="group animate-in fade-in-0 zoom-in-95 flex items-center gap-1.5 px-2 py-1 text-xs font-medium duration-150 ease-out motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none"
key={value}
onClick={
clickToRemove
@@ -415,7 +414,7 @@ export function MultiSelectItem({
keywords={keywords}
data-slot="multiselect-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary my-1 flex w-full cursor-pointer items-center justify-between gap-3 overflow-hidden rounded-lg px-4 py-3 text-sm outline-hidden select-none first:mt-0 last:mb-0 hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary my-1 flex w-full cursor-pointer items-center justify-between gap-3 overflow-hidden rounded-lg px-4 py-3 text-sm outline-hidden transition-colors duration-150 ease-out select-none first:mt-0 last:mb-0 hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 motion-reduce:transition-none dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
isSelected && "bg-slate-100 dark:bg-slate-800/50",
disabled && "cursor-not-allowed opacity-50 hover:bg-transparent",
className,
@@ -431,8 +430,8 @@ export function MultiSelectItem({
</span>
<CheckIcon
className={cn(
"text-bg-button-secondary size-5 shrink-0",
isSelected ? "opacity-100" : "opacity-0",
"text-bg-button-secondary size-5 shrink-0 transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none",
isSelected ? "scale-100 opacity-100" : "scale-95 opacity-0",
)}
/>
</CommandItem>